feat(communication): add chat reactions emojis and images

This commit is contained in:
2026-10-03 17:56:07 +02:00
parent 59538f46b7
commit 29a9ec2242
25 changed files with 737 additions and 57 deletions

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>