137 lines
9.2 KiB
Vue
137 lines
9.2 KiB
Vue
<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([]), error = ref('')
|
|
// Guest access has separate credentials and must never reuse an internal user's drafts.
|
|
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => `collaboration:${props.roomId}`, { persist: () => props.base === '/api/collaboration' })
|
|
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
|
|
let generation = 0, timer, polling = false
|
|
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
|
|
const merge = (incoming) => {
|
|
const byId = new Map(messages.value.map(m => [m.id, m]))
|
|
incoming.forEach(m => {
|
|
if (m.own && m.seq) for (const [id, old] of byId) if (old.own && old.clientId === m.clientId && !old.seq) byId.delete(id)
|
|
if (!m.seq && [...byId.values()].some(old => old.own && old.seq && old.clientId === m.clientId)) return
|
|
byId.set(m.id, m)
|
|
})
|
|
messages.value = [...byId.values()].sort((a, b) => (a.seq || Number.MAX_SAFE_INTEGER) - (b.seq || Number.MAX_SAFE_INTEGER))
|
|
}
|
|
const lastSequence = () => Math.max(0, ...messages.value.map(m => m.seq || 0))
|
|
const messageError = e => e?.data?.message || e?.data?.error || 'Unterraum konnte nicht geladen werden.'
|
|
const scroll = async () => { await nextTick(); if (viewport.value) viewport.value.scrollTop = viewport.value.scrollHeight }
|
|
function failed(e) {
|
|
error.value = messageError(e)
|
|
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
|
|
room.value = null; messages.value = []; members.value = []
|
|
if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
|
|
}
|
|
}
|
|
async function read(id, version) {
|
|
if (version !== generation || document.hidden || !messages.value.length) return
|
|
if (!lastSequence()) return
|
|
await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: lastSequence() } })
|
|
if (version === generation) emit('changed')
|
|
}
|
|
async function load() {
|
|
const version = ++generation, id = props.roomId
|
|
room.value = null; messages.value = []; members.value = []; error.value = ''
|
|
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
|
|
room.value = detail; members.value = people.members; merge(result.messages); hasMore.value = result.hasMore
|
|
await scroll(); await read(id, version)
|
|
} catch (e) { if (version === generation) failed(e) }
|
|
finally { if (version === generation) loading.value = false }
|
|
}
|
|
async function poll() {
|
|
if (polling || loading.value || !room.value || document.hidden) return
|
|
const version = generation, id = props.roomId
|
|
polling = true
|
|
try {
|
|
const after = lastSequence()
|
|
const [detail, result] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, `/messages?after=${after}`))])
|
|
if (version !== generation) return
|
|
const rightsChanged = room.value?.revision !== detail.revision
|
|
room.value = detail
|
|
messages.value = messages.value.filter(m => m.seq ? m.seq >= detail.historyFromSeq : !rightsChanged)
|
|
const nearBottom = !viewport.value || viewport.value.scrollHeight - viewport.value.scrollTop - viewport.value.clientHeight < 90
|
|
if (result.messages.length) {
|
|
merge(result.messages)
|
|
if (nearBottom) { await scroll(); await read(id, version) }
|
|
}
|
|
error.value = ''
|
|
} catch (e) { if (version === generation) failed(e) }
|
|
finally { polling = false }
|
|
}
|
|
async function older() {
|
|
if (!messages.value.length || olderLoading.value) return
|
|
const version = generation, id = props.roomId, height = viewport.value?.scrollHeight || 0
|
|
olderLoading.value = true
|
|
try {
|
|
const result = await props.api(endpoint(id, `/messages?before=${messages.value[0].seq}`))
|
|
if (version !== generation) return
|
|
merge(result.messages); hasMore.value = result.hasMore
|
|
await nextTick(); if (viewport.value) viewport.value.scrollTop += viewport.value.scrollHeight - height
|
|
} catch (e) { if (version === generation) failed(e) }
|
|
finally { olderLoading.value = false }
|
|
}
|
|
async function send() {
|
|
const text = draft.value.trim(), image = draftImage.value
|
|
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
|
const version = generation, id = props.roomId
|
|
const savedDraft = startDraftSend()
|
|
if (!savedDraft) return
|
|
const pending = { text, image, clientId: savedDraft.value.clientId }
|
|
sending.value = true; error.value = ''
|
|
try {
|
|
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
|
clearDraft(savedDraft)
|
|
if (version !== generation) return
|
|
merge([message])
|
|
await scroll(); await read(id, version)
|
|
} catch (e) { if (version === generation) failed(e) }
|
|
finally { finishDraftSend(savedDraft); 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 })
|
|
onMounted(() => { timer = setInterval(poll, 3000) })
|
|
onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
|
</script>
|
|
|
|
<template>
|
|
<section class="flex min-w-0 flex-col bg-white dark:bg-gray-900" :class="embedded ? 'min-h-0 overflow-hidden' : 'min-h-[32rem] rounded-xl border border-gray-200 dark:border-gray-700'">
|
|
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
|
|
<div class="flex flex-wrap items-center justify-between gap-2"><h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2><slot name="actions" /></div>
|
|
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
|
|
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
|
|
</header>
|
|
<p v-if="error" role="alert" class="m-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }} <button class="ml-2 underline" @click="load">Erneut laden</button></p>
|
|
<p v-if="room?.offline" role="status" class="mx-4 mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Die Partnerinstanz ist gerade nicht erreichbar. Du siehst bereits geladene Nachrichten. Neue Nachrichten warten auf Zustellung.</p>
|
|
<div v-if="room?.overview && Object.keys(room.overview).length" class="m-4 rounded-lg bg-green-50 p-3 text-sm text-gray-800">
|
|
<h3 class="mb-1 font-semibold">Freigegebene Projektinformationen</h3>
|
|
<p v-if="room.overview.title">{{ room.overview.title }}</p>
|
|
<p v-if="room.overview.status">Status: {{ room.overview.status }}</p>
|
|
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
|
|
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
|
</div>
|
|
<div ref="viewport" class="flex-1 space-y-3 overflow-y-auto p-4" :class="embedded ? 'min-h-0' : 'max-h-[60vh] min-h-[18rem]'" aria-live="polite">
|
|
<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>
|
|
<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>
|
|
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
|
|
</section>
|
|
</template>
|