feat(communication): federate tenant collaboration with signed delivery

This commit is contained in:
2026-09-30 14:48:05 +02:00
parent ea641bb4d0
commit 7b5696eda4
16 changed files with 914 additions and 14 deletions

View File

@@ -7,9 +7,14 @@ let generation = 0, timer, polling = false, pending = null
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 => byId.set(m.id, m))
messages.value = [...byId.values()].sort((a, b) => a.seq - b.seq)
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) {
@@ -21,7 +26,8 @@ function failed(e) {
}
async function read(id, version) {
if (version !== generation || document.hidden || !messages.value.length) return
await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: messages.value.at(-1).seq } })
if (!lastSequence()) return
await props.api(endpoint(id, '/read'), { method: 'POST', body: { seq: lastSequence() } })
if (version === generation) emit('changed')
}
async function load() {
@@ -41,10 +47,12 @@ async function poll() {
const version = generation, id = props.roomId
polling = true
try {
const after = messages.value.at(-1)?.seq || 0
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)
@@ -94,6 +102,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
<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>
@@ -109,7 +118,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
<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>
<p class="mt-1 text-right text-[10px] opacity-70">{{ time(message.createdAt) }}</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>
</div>
</div>
</div>