feat(communication): add chat reactions emojis and images
This commit is contained in:
65
frontend/components/ChatComposer.vue
Normal file
65
frontend/components/ChatComposer.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { prepareChatImage } from '~/utils/chat-image'
|
||||
|
||||
const props = defineProps({ modelValue: { type: String, default: '' }, image: { type: Object, default: null }, busy: Boolean, disabled: Boolean,
|
||||
placeholder: { type: String, default: 'Nachricht schreiben …' } })
|
||||
const emit = defineEmits(['update:modelValue', 'update:image', 'send'])
|
||||
const input = ref(null), textarea = ref(null), preparing = ref(false), error = ref(''), emojisOpen = ref(false)
|
||||
const emojis = ['😀', '😊', '😂', '🥰', '😎', '🤔', '😮', '😢', '😅', '🙈', '👍', '👎', '👋', '🙏', '💪', '👏', '❤️', '🎉', '🔥', '✅', '❌', '🚀', '💡', '📅', '📷', '☕', '🎂', '🎁', '🛠️', '🏗️', '🤝', '👌']
|
||||
let generation = 0
|
||||
const locked = computed(() => props.disabled || props.busy || preparing.value)
|
||||
function send(event) {
|
||||
if (event?.isComposing || locked.value || (!props.modelValue.trim() && !props.image)) return
|
||||
emojisOpen.value = false; emit('send')
|
||||
}
|
||||
async function insertEmoji(emoji) {
|
||||
if (locked.value) return
|
||||
const start = textarea.value?.selectionStart ?? props.modelValue.length, end = textarea.value?.selectionEnd ?? start
|
||||
const value = props.modelValue.slice(0, start) + emoji + props.modelValue.slice(end)
|
||||
if (value.length > 10000) return
|
||||
emit('update:modelValue', value); emojisOpen.value = false
|
||||
await nextTick(); textarea.value?.focus(); textarea.value?.setSelectionRange(start + emoji.length, start + emoji.length)
|
||||
}
|
||||
async function attach(file) {
|
||||
if (!file || locked.value) return
|
||||
const version = ++generation
|
||||
preparing.value = true; error.value = ''
|
||||
try {
|
||||
const image = await prepareChatImage(file)
|
||||
if (version === generation && !props.disabled) emit('update:image', image)
|
||||
} catch (e) { if (version === generation) error.value = e.message || 'Das Bild konnte nicht gelesen werden.' }
|
||||
finally { if (version === generation) preparing.value = false }
|
||||
}
|
||||
function paste(event) {
|
||||
const image = [...(event.clipboardData?.files || [])].find(file => file.type.startsWith('image/'))
|
||||
if (image) { event.preventDefault(); void attach(image) }
|
||||
}
|
||||
function drop(event) {
|
||||
if (event.dataTransfer?.files?.length) { event.preventDefault(); void attach(event.dataTransfer.files[0]) }
|
||||
}
|
||||
watch(() => props.disabled, disabled => { if (disabled) { ++generation; preparing.value = false; emojisOpen.value = false } })
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="shrink-0 space-y-2 border-t border-gray-200 bg-white p-3 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 sm:p-4" @submit.prevent="send" @dragover.prevent @drop="drop">
|
||||
<p v-if="error" role="alert" class="text-sm text-red-700">{{ error }}</p>
|
||||
<div v-if="image" class="flex items-center gap-3 rounded-lg bg-gray-50 p-2 dark:bg-gray-800">
|
||||
<img :src="image.dataUrl" :alt="image.name" class="size-16 rounded object-contain" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs">{{ image.name }}</span>
|
||||
<UButton icon="i-heroicons-x-mark" aria-label="Bild entfernen" color="neutral" variant="ghost" :disabled="locked" @click="emit('update:image', null)" />
|
||||
</div>
|
||||
<div v-if="emojisOpen" class="flex max-h-32 flex-wrap gap-1 overflow-y-auto rounded-lg border border-gray-200 p-2" role="group" aria-label="Emoji auswählen">
|
||||
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`${emoji} einfügen`" class="rounded p-2 text-xl hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-green-600" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||||
</div>
|
||||
<label class="block"><span class="sr-only">Nachricht</span><textarea ref="textarea" :value="modelValue" rows="2" maxlength="10000" :disabled="locked" :placeholder="placeholder"
|
||||
class="max-h-36 w-full resize-y rounded-lg border border-gray-300 bg-transparent p-2 text-sm dark:border-gray-600" @input="emit('update:modelValue', $event.target.value)" @keydown.enter.exact.prevent="send" @paste="paste" /></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton icon="i-heroicons-face-smile" aria-label="Emojis auswählen" title="Emojis" color="neutral" variant="ghost" :aria-expanded="emojisOpen" :disabled="locked" @click="emojisOpen = !emojisOpen" />
|
||||
<UButton icon="i-heroicons-photo" aria-label="Bild anhängen" title="Bild anhängen (JPEG, PNG, WebP, bis 10 MB)" color="neutral" variant="ghost" :loading="preparing" :disabled="locked" @click="input?.click()" />
|
||||
<input ref="input" type="file" accept="image/jpeg,image/png,image/webp" class="hidden" aria-label="Bilddatei" :disabled="locked" @change="attach($event.target.files?.[0]); $event.target.value = ''" />
|
||||
<span class="hidden min-w-0 flex-1 text-xs text-gray-400 sm:block">Enter sendet · Umschalt+Enter für neue Zeile</span>
|
||||
<UButton type="submit" class="ml-auto" icon="i-heroicons-paper-airplane" :loading="busy" :disabled="locked || (!modelValue.trim() && !image)">Senden</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
38
frontend/components/ChatImage.vue
Normal file
38
frontend/components/ChatImage.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
const props = defineProps({ api: { type: Function, required: true }, endpoint: { type: String, required: true }, message: { type: Object, required: true } })
|
||||
const dataUrl = ref(''), error = ref(''), loading = ref(false), expanded = ref(false)
|
||||
// Reserve dimensions while fetching so a new photo does not move below the composer.
|
||||
const imageStyle = computed(() => {
|
||||
const { width, height } = props.message.image
|
||||
const scale = expanded.value ? 1 : Math.min(1, 320 / width, 256 / height)
|
||||
return { width: `${Math.round(width * scale)}px`, aspectRatio: `${width} / ${height}`, maxHeight: expanded.value ? '70vh' : undefined }
|
||||
})
|
||||
let generation = 0
|
||||
async function load() {
|
||||
const version = ++generation
|
||||
dataUrl.value = ''; error.value = ''; expanded.value = false; loading.value = false
|
||||
if (props.message.pending || props.message.failed) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await props.api(`${props.endpoint}/image?messageId=${encodeURIComponent(props.message.id)}`)
|
||||
if (version !== generation) return
|
||||
// Even a partner instance can only supply bounded raster data, never an external tracking URL.
|
||||
if (typeof result.dataUrl !== 'string' || result.dataUrl.length > 700000 || !/^data:image\/(webp|png|jpeg);base64,[A-Za-z0-9+/]+=*$/.test(result.dataUrl)) throw new Error('Ungültiges Bild')
|
||||
dataUrl.value = result.dataUrl
|
||||
} catch { if (version === generation) error.value = 'Bild derzeit nicht verfügbar.' }
|
||||
finally { if (version === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [props.endpoint, props.message.id, props.message.pending, props.message.failed], load, { immediate: true })
|
||||
onBeforeUnmount(() => { ++generation; dataUrl.value = '' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-2 max-w-full" :style="imageStyle">
|
||||
<button v-if="dataUrl" type="button" class="block max-w-full rounded-lg focus-visible:ring-2" :aria-label="expanded ? 'Bild verkleinern' : 'Bild vergrößern'" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<img :src="dataUrl" :alt="message.image.name" :width="message.image.width" :height="message.image.height" class="h-auto max-w-full rounded-lg object-contain" :class="expanded ? 'max-h-[70vh]' : 'max-h-64'" />
|
||||
</button>
|
||||
<p v-else-if="loading" class="text-xs opacity-70" role="status">Bild wird geladen …</p>
|
||||
<p v-else-if="message.pending || message.failed" class="text-xs opacity-70">📷 {{ message.image.name }} · {{ message.failed ? 'Nicht zugestellt' : 'Wartet auf Zustellung' }}</p>
|
||||
<button v-else-if="error" type="button" class="text-xs underline" @click="load">{{ error }} Erneut laden</button>
|
||||
</div>
|
||||
</template>
|
||||
19
frontend/components/ChatReactionBar.vue
Normal file
19
frontend/components/ChatReactionBar.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
const props = defineProps({ reactions: { type: Array, default: () => [] }, disabled: Boolean, busy: Boolean })
|
||||
const emit = defineEmits(['react'])
|
||||
const open = ref(false)
|
||||
const emojis = ['👍', '❤️', '😂', '🎉', '😮', '😢', '🙏', '🔥', '✅', '👎']
|
||||
function react(emoji) { if (!props.disabled && !props.busy) { open.value = false; emit('react', emoji) } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex max-w-full flex-wrap items-center gap-1 text-gray-700 dark:text-gray-200">
|
||||
<button v-for="reaction in reactions" :key="reaction.emoji" type="button" class="rounded-full border px-2 py-0.5 text-xs"
|
||||
:class="reaction.own ? 'border-green-400 bg-green-100 text-green-900' : 'border-gray-200 bg-white text-gray-700'"
|
||||
:aria-pressed="reaction.own" :aria-label="`${reaction.emoji}: ${reaction.count}${reaction.own ? ', eigene Reaktion entfernen' : ', reagieren'}`" :disabled="disabled || busy" @click="react(reaction.emoji)">{{ reaction.emoji }} {{ reaction.count }}</button>
|
||||
<button v-if="!disabled" type="button" class="rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-100" aria-label="Reaktion hinzufügen" :aria-expanded="open" :disabled="busy" @click="open = !open">☺ +</button>
|
||||
<div v-if="open && !disabled" class="flex basis-full flex-wrap gap-1 rounded-lg bg-white p-1" role="group" aria-label="Reaktion auswählen">
|
||||
<button v-for="emoji in emojis" :key="emoji" type="button" :aria-label="`Mit ${emoji} reagieren`" class="rounded p-1 hover:bg-gray-100" :disabled="busy" @click="react(emoji)">{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
39
frontend/components/ChatReactions.vue
Normal file
39
frontend/components/ChatReactions.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
const props = defineProps({ api: { type: Function, required: true }, endpoint: { type: String, required: true }, messages: { type: Array, required: true }, disabled: Boolean })
|
||||
const emit = defineEmits(['error'])
|
||||
const reactions = ref({}), busy = ref(false)
|
||||
let timer, disposed = false, fetching = false, revision = 0
|
||||
const ids = computed(() => props.messages.filter(m => !m.pending && !m.failed).map(m => m.id))
|
||||
async function refresh() {
|
||||
if (disposed || fetching || busy.value || document.hidden) return
|
||||
const version = ++revision, endpoint = props.endpoint, list = [...ids.value]
|
||||
fetching = true
|
||||
try {
|
||||
const next = {}
|
||||
for (let index = 0; index < list.length; index += 100) {
|
||||
const result = await props.api(`${endpoint}/reactions?ids=${encodeURIComponent(list.slice(index, index + 100).join(','))}`)
|
||||
Object.assign(next, result.reactions || {})
|
||||
if (disposed || version !== revision || props.endpoint !== endpoint) return
|
||||
}
|
||||
if (!disposed && version === revision) reactions.value = next
|
||||
} catch { /* Temporary polling errors must not obscure messages. */ }
|
||||
finally { fetching = false }
|
||||
}
|
||||
async function react(messageId, emoji) {
|
||||
if (disposed || busy.value || props.disabled || !ids.value.includes(messageId)) return
|
||||
const version = ++revision, endpoint = props.endpoint
|
||||
const active = !(reactions.value[messageId] || []).some(r => r.emoji === emoji && r.own)
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await props.api(`${endpoint}/reactions`, { method: 'PUT', body: { messageId, emoji, active } })
|
||||
if (!disposed && version === revision && endpoint === props.endpoint) reactions.value = { ...reactions.value, [messageId]: result.reactions?.[messageId] || [] }
|
||||
} catch (error) { if (!disposed && version === revision) emit('error', error) }
|
||||
finally { if (!disposed) busy.value = false }
|
||||
}
|
||||
watch(() => props.endpoint, () => { ++revision; reactions.value = {} })
|
||||
watch(ids, () => { void refresh() }, { immediate: true })
|
||||
onMounted(() => { timer = setInterval(refresh, 3000) })
|
||||
onBeforeUnmount(() => { disposed = true; ++revision; clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template><slot :reactions="reactions" :react="react" :busy="busy" /></template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['changed', 'unauthorized'])
|
||||
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
|
||||
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('')
|
||||
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
||||
let generation = 0, timer, polling = false, pending = null
|
||||
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
||||
@@ -20,7 +20,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
|
||||
function failed(e) {
|
||||
error.value = messageError(e)
|
||||
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
||||
room.value = null; messages.value = []; members.value = []
|
||||
room.value = null; messages.value = []; members.value = []; draftImage.value = null
|
||||
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@ async function read(id, version) {
|
||||
}
|
||||
async function load() {
|
||||
const version = ++generation, id = props.roomId
|
||||
room.value = null; messages.value = []; members.value = []; draft.value = ''; error.value = ''; pending = null
|
||||
loading.value = true
|
||||
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null
|
||||
loading.value = true; sending.value = false; olderLoading.value = false
|
||||
try {
|
||||
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
|
||||
if (version !== generation) return
|
||||
@@ -75,18 +75,18 @@ async function older() {
|
||||
finally { olderLoading.value = false }
|
||||
}
|
||||
async function send() {
|
||||
const text = draft.value.trim()
|
||||
if (!text || sending.value || !room.value?.canSend) return
|
||||
const text = draft.value.trim(), image = draftImage.value
|
||||
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
||||
const version = generation, id = props.roomId
|
||||
if (!pending || pending.text !== text) pending = { text, clientId: crypto.randomUUID() }
|
||||
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() }
|
||||
sending.value = true; error.value = ''
|
||||
try {
|
||||
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
||||
if (version !== generation) return
|
||||
merge([message]); draft.value = ''; pending = null
|
||||
merge([message]); draft.value = ''; draftImage.value = null; pending = null
|
||||
await scroll(); await read(id, version)
|
||||
} catch (e) { if (version === generation) failed(e) }
|
||||
finally { sending.value = false }
|
||||
finally { if (version === generation) sending.value = false }
|
||||
}
|
||||
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||
watch(() => props.roomId, load, { immediate: true })
|
||||
@@ -114,17 +114,18 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
|
||||
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
|
||||
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>
|
||||
<ChatReactions :key="roomId" v-slot="{ reactions, react, busy }" :api="api" :endpoint="endpoint(roomId)" :messages="messages" :disabled="!room?.canSend || room?.offline" @error="error = messageError($event)">
|
||||
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[85%] rounded-xl px-4 py-2" :class="message.own ? 'bg-green-700 text-white' : 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
|
||||
<p class="text-xs font-semibold">{{ message.own ? 'Du' : message.senderName }}</p>
|
||||
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
||||
<ChatImage v-if="message.image" :api="api" :endpoint="endpoint(roomId)" :message="message" />
|
||||
<p v-if="message.body" class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
||||
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}{{ message.pending ? ' · Wartet auf Zustellung' : message.failed ? ' · Zustellung fehlgeschlagen' : ' · Bestätigt' }}</p>
|
||||
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" :disabled="!room?.canSend || room?.offline || !!message.pending || !!message.failed" @react="react(message.id, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</ChatReactions>
|
||||
</div>
|
||||
<form v-if="room" class="flex items-end gap-3 border-t border-gray-200 p-4 dark:border-gray-700" @submit.prevent="send">
|
||||
<label class="min-w-0 flex-1"><span class="sr-only">Nachricht</span><textarea v-model="draft" rows="2" maxlength="10000" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" class="w-full resize-none rounded-lg border border-gray-300 p-2 text-sm dark:border-gray-600" @keydown.enter.exact.prevent="send" /></label>
|
||||
<UButton type="submit" :loading="sending" :disabled="!room.canSend || !draft.trim()">Senden</UButton>
|
||||
</form>
|
||||
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ const router = useRouter()
|
||||
|
||||
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
||||
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref('')
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null)
|
||||
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
|
||||
const activeRoomKey = ref(requestedRoom())
|
||||
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
|
||||
@@ -49,7 +49,7 @@ const activeRoom = computed(() => visibleRooms.value.find(room => room.key === a
|
||||
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
||||
const isInternal = computed(() => activeRoom.value?.source === 'internal')
|
||||
|
||||
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||
function mergeMessages(incoming) {
|
||||
const byId = new Map(messages.value.map(message => [message.id, message]))
|
||||
for (const message of incoming || []) byId.set(message.id, message)
|
||||
@@ -208,13 +208,13 @@ async function respondToInvitation(id, accept) {
|
||||
finally { if (currentScope(scope)) invitationBusy.value = '' }
|
||||
}
|
||||
async function sendMessage() {
|
||||
const text = draft.value.trim(), request = selection()
|
||||
if (!text || sending.value || !isInternal.value || !activeRoom.value?.exists) return
|
||||
const text = draft.value.trim(), image = draftImage.value, request = selection()
|
||||
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
|
||||
sending.value = true; draft.value = ''
|
||||
try {
|
||||
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text } })
|
||||
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
|
||||
if (!currentSelection(request)) return
|
||||
mergeMessages([message]); await markRead(request); await scrollToBottom(request)
|
||||
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request)
|
||||
} catch (error) {
|
||||
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
} finally { if (currentSelection(request)) sending.value = false }
|
||||
@@ -371,21 +371,22 @@ onBeforeUnmount(() => {
|
||||
<p class="font-medium">Noch keine Nachrichten</p>
|
||||
<p class="text-sm">Beginne die Unterhaltung.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<ChatReactions v-else :key="`${session}:${activeRoomKey}`" v-slot="{ reactions, react, busy }" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :messages="messages" @error="error => toast.add({ title: errorMessage(error), color: 'error' })">
|
||||
<div class="space-y-4">
|
||||
<div v-for="message in messages" :key="message.id" class="flex" :class="message.own ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[80%] rounded-2xl px-4 py-2" :class="message.own ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-900'">
|
||||
<div v-if="!message.own" class="mb-1 text-xs font-semibold text-primary-600">{{ message.senderDisplayName }}</div>
|
||||
<p class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
||||
<ChatImage v-if="message.image" :api="$api" :endpoint="nativeEndpoint(activeRoomKey)" :message="message" />
|
||||
<p v-if="message.body" class="whitespace-pre-wrap break-words text-sm">{{ message.body }}</p>
|
||||
<div class="mt-1 text-right text-[11px]" :class="message.own ? 'text-primary-100' : 'text-gray-400'">{{ formatMessageTime(message.timestamp) }}</div>
|
||||
<ChatReactionBar :reactions="reactions[message.id] || []" :busy="busy" @react="react(message.id, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatReactions>
|
||||
</div>
|
||||
|
||||
<form class="flex items-end gap-3 border-t border-gray-200 p-4" @submit.prevent="sendMessage">
|
||||
<UTextarea v-model="draft" class="flex-1" autoresize :rows="1" :maxrows="6" placeholder="Nachricht schreiben …" :disabled="!activeRoom?.exists" @keydown.enter.exact.prevent="sendMessage" />
|
||||
<UButton type="submit" icon="i-heroicons-paper-airplane" :loading="sending" :disabled="!draft.trim() || !activeRoom?.exists">Senden</UButton>
|
||||
</form>
|
||||
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
|
||||
</main>
|
||||
|
||||
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
||||
|
||||
@@ -356,3 +356,24 @@ test('returning to chat immediately reflects a disabled or re-enabled project wi
|
||||
await settle()
|
||||
assert.equal(ui.run('groupedRooms.value.find(group => group.label === "Projekte").rooms[0].key'), 'project_1')
|
||||
})
|
||||
|
||||
|
||||
test('native image-only sends retain the draft after an error and clear it after success', async t => {
|
||||
let fail = true
|
||||
const ui = setup(fixture((url, options) => {
|
||||
if (url.endsWith('/messages') && options?.method === 'POST') {
|
||||
if (fail) throw { status: 503, data: { message: 'Offline' } }
|
||||
return { id: 2, body: '', image: { name: 'Bild', width: 80, height: 40 }, own: true }
|
||||
}
|
||||
}))
|
||||
t.after(ui.close)
|
||||
await ui.run('initialize()')
|
||||
ui.run('draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Bild" }')
|
||||
await ui.run('sendMessage()')
|
||||
assert.equal(ui.run('draftImage.value.name'), 'Bild')
|
||||
fail = false
|
||||
await ui.run('sendMessage()')
|
||||
assert.equal(ui.run('messages.value.at(-1).image.name'), 'Bild')
|
||||
assert.equal(ui.run('draftImage.value'), null)
|
||||
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
|
||||
})
|
||||
|
||||
91
frontend/tests/chat-media.test.cjs
Normal file
91
frontend/tests/chat-media.test.cjs
Normal file
@@ -0,0 +1,91 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const settle = () => new Promise(setImmediate)
|
||||
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
|
||||
|
||||
function setup(component, values, extra = {}) {
|
||||
const props = vue.reactive(values), events = [], scope = vue.effectScope()
|
||||
let unmount = () => {}
|
||||
const source = fs.readFileSync(`${__dirname}/../components/${component}.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/mg, '')
|
||||
const context = vm.createContext({ ref: vue.ref, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
|
||||
defineProps: () => props, defineEmits: () => (name, value) => {
|
||||
events.push({ name, value })
|
||||
if (name === 'update:modelValue') props.modelValue = value
|
||||
if (name === 'update:image') props.image = value
|
||||
}, onMounted: () => {}, onBeforeUnmount: handler => { unmount = handler }, clearInterval: () => {}, document: { hidden: false }, ...extra })
|
||||
scope.run(() => vm.runInContext(source, context))
|
||||
return { props, events, run: code => vm.runInContext(code, context), close: () => { unmount(); scope.stop() } }
|
||||
}
|
||||
|
||||
test('composer inserts emoji at selection and sends image-only messages while respecting disabled state', async t => {
|
||||
const ui = setup('ChatComposer', { modelValue: 'Hallo Welt', image: null, busy: false, disabled: false }, { prepareChatImage: async file => ({ dataUrl: 'data:image/webp;base64,AAAA', name: file.name }) })
|
||||
t.after(ui.close)
|
||||
ui.run('textarea.value = { selectionStart: 6, selectionEnd: 10, focus() {}, setSelectionRange() {} }')
|
||||
await ui.run('insertEmoji("👋")')
|
||||
assert.equal(ui.props.modelValue, 'Hallo 👋')
|
||||
ui.props.modelValue = ''
|
||||
ui.run('send()')
|
||||
assert.equal(ui.events.some(e => e.name === 'send'), false)
|
||||
await ui.run('attach({ name: "Bild.png" })')
|
||||
ui.run('send()')
|
||||
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
|
||||
ui.props.disabled = true
|
||||
ui.run('send()')
|
||||
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
|
||||
})
|
||||
|
||||
test('a late image conversion cannot attach a draft after the room unmounts', async () => {
|
||||
const pending = deferred()
|
||||
const ui = setup('ChatComposer', { modelValue: '', image: null, busy: false, disabled: false }, { prepareChatImage: () => pending.promise })
|
||||
const attaching = ui.run('attach({ name: "Bild.png" })')
|
||||
assert.equal(ui.run('preparing.value'), true)
|
||||
ui.close()
|
||||
pending.resolve({ dataUrl: 'private image', name: 'Bild.png' })
|
||||
await attaching
|
||||
assert.equal(ui.props.image, null)
|
||||
assert.equal(ui.events.length, 0)
|
||||
})
|
||||
|
||||
test('reaction refresh batches messages and stale poll results cannot overwrite an own reaction', async t => {
|
||||
const pending = deferred(), calls = []
|
||||
let delay = false
|
||||
const ui = setup('ChatReactions', { endpoint: '/room/a', messages: [{ id: 1 }, { id: 2, pending: true }], disabled: false,
|
||||
api: async (url, options) => {
|
||||
calls.push({ url, options })
|
||||
if (options?.method === 'PUT') return { reactions: { 1: [{ emoji: '👍', count: 1, own: true }] } }
|
||||
if (delay) return pending.promise
|
||||
return { reactions: {} }
|
||||
} })
|
||||
t.after(ui.close)
|
||||
await settle()
|
||||
assert.equal(calls[0].url, '/room/a/reactions?ids=1')
|
||||
delay = true
|
||||
const polling = ui.run('refresh()')
|
||||
await ui.run('react(1, "👍")')
|
||||
pending.resolve({ reactions: {} })
|
||||
await polling
|
||||
assert.equal(ui.run('reactions.value[1][0].own'), true)
|
||||
assert.equal(calls.find(c => c.options?.method === 'PUT').options.body.active, true)
|
||||
ui.props.disabled = true
|
||||
const count = calls.length
|
||||
await ui.run('react(1, "👍")')
|
||||
assert.equal(calls.length, count)
|
||||
})
|
||||
|
||||
test('image display rejects external URLs and ignores responses after a room change', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup('ChatImage', { endpoint: '/room/a', message: { id: 1 }, api: () => pending.promise })
|
||||
t.after(ui.close)
|
||||
ui.props.endpoint = '/room/b'
|
||||
await vue.nextTick()
|
||||
pending.resolve({ dataUrl: 'https://tracking.example/image.png' })
|
||||
await settle()
|
||||
assert.equal(ui.run('dataUrl.value'), '')
|
||||
assert.equal(ui.run('error.value'), 'Bild derzeit nicht verfügbar.')
|
||||
ui.props.api = async () => ({ dataUrl: 'data:image/webp;base64,AAAA' })
|
||||
await ui.run('load()')
|
||||
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
|
||||
})
|
||||
16
frontend/utils/chat-image.js
Normal file
16
frontend/utils/chat-image.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export async function prepareChatImage(file) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) throw new Error('Bitte ein JPEG-, PNG- oder WebP-Bild auswählen.')
|
||||
if (file.size > 10 * 1024 * 1024) throw new Error('Das Originalbild darf höchstens 10 MB groß sein.')
|
||||
const bitmap = await createImageBitmap(file)
|
||||
try {
|
||||
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale))
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||
for (const quality of [0.85, 0.7, 0.55, 0.4]) {
|
||||
const dataUrl = canvas.toDataURL('image/webp', quality)
|
||||
if (dataUrl.length <= 480_000) return { dataUrl, name: (file.name || 'Bild').slice(0, 200) }
|
||||
}
|
||||
throw new Error('Dieses Bild ist zu groß. Bitte einen kleineren Ausschnitt verwenden.')
|
||||
} finally { bitmap.close() }
|
||||
}
|
||||
Reference in New Issue
Block a user