feat(chat): preserve message drafts in session temp store

This commit is contained in:
2026-10-15 17:45:17 +02:00
parent e933782731
commit 697f538b6e
8 changed files with 426 additions and 16 deletions

View File

@@ -1,9 +1,11 @@
<script setup> <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 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 emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('') 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) const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
let generation = 0, timer, polling = false, pending = null let generation = 0, timer, polling = false
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}` const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
const merge = (incoming) => { const merge = (incoming) => {
const byId = new Map(messages.value.map(m => [m.id, m])) const byId = new Map(messages.value.map(m => [m.id, m]))
@@ -20,7 +22,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
function failed(e) { function failed(e) {
error.value = messageError(e) error.value = messageError(e)
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) { if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
room.value = null; messages.value = []; members.value = []; draftImage.value = null room.value = null; messages.value = []; members.value = []
if ((e?.statusCode || e?.status) === 401) emit('unauthorized') if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
} }
} }
@@ -32,7 +34,7 @@ async function read(id, version) {
} }
async function load() { async function load() {
const version = ++generation, id = props.roomId const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null room.value = null; messages.value = []; members.value = []; error.value = ''
loading.value = true; sending.value = false; olderLoading.value = false loading.value = true; sending.value = false; olderLoading.value = false
try { try {
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))]) const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
@@ -78,15 +80,18 @@ async function send() {
const text = draft.value.trim(), image = draftImage.value const text = draft.value.trim(), image = draftImage.value
if ((!text && !image) || sending.value || !room.value?.canSend) return if ((!text && !image) || sending.value || !room.value?.canSend) return
const version = generation, id = props.roomId const version = generation, id = props.roomId
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() } const savedDraft = startDraftSend()
if (!savedDraft) return
const pending = { text, image, clientId: savedDraft.value.clientId }
sending.value = true; error.value = '' sending.value = true; error.value = ''
try { try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending }) const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
clearDraft(savedDraft)
if (version !== generation) return if (version !== generation) return
merge([message]); draft.value = ''; draftImage.value = null; pending = null merge([message])
await scroll(); await read(id, version) await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) } } catch (e) { if (version === generation) failed(e) }
finally { if (version === generation) sending.value = false } finally { finishDraftSend(savedDraft); if (version === generation) sending.value = false }
} }
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
watch(() => props.roomId, load, { immediate: true }) watch(() => props.roomId, load, { immediate: true })
@@ -126,6 +131,6 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</div> </div>
</ChatReactions> </ChatReactions>
</div> </div>
<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" /> <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> </section>
</template> </template>

View File

@@ -0,0 +1,43 @@
export function useChatDraft(roomKey, { persist = true } = {}) {
const auth = useAuthStore()
const temp = useTempStore()
const apiBase = useRuntimeConfig().public.apiBase
const localDraft = ref(null)
const key = computed(() => {
const room = toValue(roomKey), user = auth.user?.id || auth.user?.user_id
return toValue(persist) && room && user && auth.activeTenant
? JSON.stringify([apiBase, user, String(auth.activeTenant), auth.profile?.id || '', room])
: null
})
watch([key, () => toValue(roomKey), () => auth.user?.id || auth.user?.user_id,
() => auth.activeTenant, () => auth.profile?.id], () => { localDraft.value = null }, { flush: 'sync' })
const current = () => key.value ? temp.getChatDraft(key.value) : localDraft.value
function update(patch) {
const value = { text: '', image: null, ...current(), ...patch, clientId: crypto.randomUUID() }
const next = value.text || value.image ? value : null
if (key.value) temp.setChatDraft(key.value, next)
else localDraft.value = next
}
const draft = computed({ get: () => current()?.text || '', set: text => update({ text }) })
const draftImage = computed({ get: () => current()?.image || null, set: image => update({ image }) })
const draftSending = computed(() => !!key.value && temp.isChatDraftSending(key.value))
const captureDraft = () => ({ key: key.value, value: current() })
function startDraftSend() {
if (draftSending.value) return null
const snapshot = captureDraft()
if (!snapshot.value) return null
if (snapshot.key) temp.startChatDraftSend(snapshot.key, snapshot.value.clientId)
return snapshot
}
function finishDraftSend(snapshot) {
if (snapshot.key) temp.finishChatDraftSend(snapshot.key, snapshot.value.clientId)
}
function clearDraft(snapshot) {
const saved = snapshot.key ? temp.getChatDraft(snapshot.key) : localDraft.value
// A late send response must only remove the draft that was actually sent.
if (!snapshot.value || saved?.clientId !== snapshot.value.clientId) return
if (snapshot.key) temp.setChatDraft(snapshot.key, null)
else localDraft.value = null
}
return { draft, draftImage, draftSending, captureDraft, clearDraft, startDraftSend, finishDraftSend }
}

View File

@@ -7,7 +7,7 @@ const router = useRouter()
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([]) const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('') const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null) const unreadRooms = ref({}), messages = ref([]), members = ref([])
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein' const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
const activeRoomKey = ref(requestedRoom()) const activeRoomKey = ref(requestedRoom())
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false) const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
@@ -48,8 +48,9 @@ const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten',
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null) const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration') const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
const isInternal = computed(() => activeRoom.value?.source === 'internal') const isInternal = computed(() => activeRoom.value?.source === 'internal')
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => isInternal.value ? activeRoomKey.value : '')
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false } function clearMessages() { messages.value = []; members.value = []; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function mergeMessages(incoming) { function mergeMessages(incoming) {
const byId = new Map(messages.value.map(message => [message.id, message])) const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message) for (const message of incoming || []) byId.set(message.id, message)
@@ -210,14 +211,17 @@ async function respondToInvitation(id, accept) {
async function sendMessage() { async function sendMessage() {
const text = draft.value.trim(), image = draftImage.value, request = selection() const text = draft.value.trim(), image = draftImage.value, request = selection()
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = '' const savedDraft = startDraftSend()
if (!savedDraft) return
sending.value = true
try { try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } }) const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
clearDraft(savedDraft)
if (!currentSelection(request)) return if (!currentSelection(request)) return
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request) mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) { } catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) } if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
} finally { if (currentSelection(request)) sending.value = false } } finally { finishDraftSend(savedDraft); if (currentSelection(request)) sending.value = false }
} }
async function pollMessages() { async function pollMessages() {
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
@@ -386,7 +390,7 @@ onBeforeUnmount(() => {
</ChatReactions> </ChatReactions>
</div> </div>
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" /> <ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
</main> </main>
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block"> <aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">

View File

@@ -10,6 +10,71 @@ export const useTempStore = defineStore('temp', () => {
const pages = ref({}) const pages = ref({})
const settings = ref({}) const settings = ref({})
// Drafts stay local: temp_config is also returned in tenant profile lists.
const chatDrafts = ref({})
const sendingChatDrafts = ref({})
const loadedChatDrafts = new Set()
const draftStoragePrefix = 'fedeo:chat-draft:v1:'
const toast = useToast()
let draftStorageWarningShown = false
function getChatDraft(key) {
if (!loadedChatDrafts.has(key)) {
loadedChatDrafts.add(key)
try {
const saved = JSON.parse(sessionStorage.getItem(draftStoragePrefix + key) || 'null')
if (saved && typeof saved.text === 'string' && typeof saved.clientId === 'string'
&& saved.text.length <= 10000 && (!saved.image || (
typeof saved.image.name === 'string' && typeof saved.image.dataUrl === 'string'
&& saved.image.dataUrl.length <= 480000
&& /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/.test(saved.image.dataUrl)
))) chatDrafts.value[key] = saved
} catch { /* Disabled storage or an invalid entry must not block the chat. */ }
}
return chatDrafts.value[key] || null
}
function setChatDraft(key, draft) {
loadedChatDrafts.add(key)
if (draft) chatDrafts.value[key] = draft
else delete chatDrafts.value[key]
try {
if (draft) sessionStorage.setItem(draftStoragePrefix + key, JSON.stringify(draft))
else sessionStorage.removeItem(draftStoragePrefix + key)
} catch {
// Do not restore an older version after a failed storage update.
try { sessionStorage.removeItem(draftStoragePrefix + key) } catch {}
if (!draftStorageWarningShown) {
draftStorageWarningShown = true
toast.add({ title: 'Entwurf nur vorübergehend gespeichert', description: 'Der Browserspeicher ist voll oder gesperrt. Bis zum Neuladen bleibt dein Entwurf im Chat erhalten.', color: 'warning' })
}
}
}
function isChatDraftSending(key) {
return !!sendingChatDrafts.value[key]
}
function startChatDraftSend(key, clientId) {
sendingChatDrafts.value[key] = clientId
}
function finishChatDraftSend(key, clientId) {
if (sendingChatDrafts.value[key] === clientId) delete sendingChatDrafts.value[key]
}
watch(() => auth.user?.id || auth.user?.user_id, (user, previous) => {
if (!previous || user === previous) return
chatDrafts.value = {}
sendingChatDrafts.value = {}
loadedChatDrafts.clear()
try {
for (const key of Object.keys(sessionStorage)) {
if (key.startsWith(draftStoragePrefix)) sessionStorage.removeItem(key)
}
} catch { /* Storage may be unavailable. In-memory drafts are still cleared. */ }
}, { flush: 'sync' })
const storeTempConfig = async () => { const storeTempConfig = async () => {
const config = { const config = {
searchStrings: searchStrings.value, searchStrings: searchStrings.value,
@@ -92,6 +157,11 @@ export const useTempStore = defineStore('temp', () => {
} }
return { return {
getChatDraft,
setChatDraft,
isChatDraftSending,
startChatDraftSend,
finishChatDraftSend,
setStoredTempConfig, setStoredTempConfig,
searchStrings, searchStrings,
modifySearchString, modifySearchString,

View File

@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
const fs = require('node:fs') const fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const vue = require('vue') const vue = require('vue')
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
const settle = () => new Promise(setImmediate) const settle = () => new Promise(setImmediate)
const deferred = () => { const deferred = () => {
@@ -33,6 +34,7 @@ function fixture(override = () => undefined) {
function setup(api = fixture(), query = {}) { function setup(api = fixture(), query = {}) {
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' }) const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
const drafts = createDraftHarness({ auth })
const route = vue.reactive({ query }) const route = vue.reactive({ query })
const calls = [], toasts = [], timers = new Map() const calls = [], toasts = [], timers = new Map()
const listeners = new Map() const listeners = new Map()
@@ -47,6 +49,7 @@ function setup(api = fixture(), query = {}) {
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick, ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
useToast: () => ({ add: item => toasts.push(item) }), useToast: () => ({ add: item => toasts.push(item) }),
useAuthStore: () => auth, useRoute: () => route, useAuthStore: () => auth, useRoute: () => route,
useChatDraft: drafts.useChatDraft,
useRouter: () => ({ replace: async location => { route.query = location.query } }), useRouter: () => ({ replace: async location => { route.query = location.query } }),
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }), useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler }, onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
@@ -54,7 +57,7 @@ function setup(api = fixture(), query = {}) {
clearInterval: id => timers.delete(id), document, clearInterval: id => timers.delete(id), document,
}) })
scope.run(() => vm.runInContext(source, context)) scope.run(() => vm.runInContext(source, context))
return { auth, route, calls, toasts, timers, listeners, document, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) } return { auth, route, calls, toasts, timers, listeners, document, mount, drafts, close: () => { unmount(); scope.stop(); drafts.close() }, run: code => vm.runInContext(code, context) }
} }
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => { test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
@@ -377,3 +380,69 @@ test('native image-only sends retain the draft after an error and clear it after
assert.equal(ui.run('draftImage.value'), null) assert.equal(ui.run('draftImage.value'), null)
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild')) assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
}) })
test('native drafts survive switching chats, hiding a room and reloading its history', async t => {
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: [{ key: 'project_1', exists: true, type: 'project' }] } : undefined))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = " Hallo\\nWelt 👋 "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Skizze" }')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "project_1"))')
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('draftImage.value'), null)
ui.run('draft.value = "Projektentwurf"')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
assert.equal(ui.run('draftImage.value.name'), 'Skizze')
await ui.run('loadActiveRoom()')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('draft.value'), 'Projektentwurf')
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
})
test('failed sends retain the exact draft after switching away, and late success clears only the sent room', async t => {
const pending = deferred()
let fail = true
const ui = setup(fixture((url, options) => {
if (url.endsWith('/messages') && options?.method === 'POST') {
if (fail) throw { data: { message: 'Offline' } }
return pending.promise
}
}))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = " Noch nicht fertig\\n "')
await ui.run('sendMessage()')
assert.equal(ui.run('draft.value'), ' Noch nicht fertig\n ')
fail = false
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
pending.resolve({ id: 2, body: 'Noch nicht fertig' })
await sending
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draft.value'), '')
assert.equal(Object.keys(ui.drafts.storage).length, 0)
})
test('editing a room after returning to a pending send preserves the new draft', async t => {
const pending = deferred()
const ui = setup(fixture((url, options) => url.endsWith('/messages') && options?.method === 'POST' ? pending.promise : undefined))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = "Vorheriger Entwurf"')
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draftSending.value'), true)
await ui.run('sendMessage()')
assert.equal(ui.calls.filter(({ url, options }) => url.endsWith('/messages') && options?.method === 'POST').length, 1)
ui.run('draft.value = "Weitergeschrieben"')
pending.resolve({ id: 2, body: 'Vorheriger Entwurf' })
await sending
assert.equal(ui.run('draft.value'), 'Weitergeschrieben')
assert.equal(ui.run('draftSending.value'), false)
})

View File

@@ -0,0 +1,123 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const vue = require('vue')
const { createDraftHarness, memoryStorage } = require('./helpers/chat-drafts.cjs')
const image = { dataUrl: 'data:image/webp;base64,AAAA', name: 'Skizze.webp' }
test('text and image drafts survive room changes and a browser reload in the same session', t => {
const storage = memoryStorage(), first = createDraftHarness({ storage })
t.after(first.close)
const room = vue.ref('allgemein'), composer = first.useChatDraft(room)
composer.draft.value = ' Hallo\nWelt 👋 '
composer.draftImage.value = image
room.value = 'project_1'
assert.equal(composer.draft.value, '')
assert.equal(composer.draftImage.value, null)
composer.draft.value = 'Projektentwurf'
room.value = 'allgemein'
assert.equal(composer.draft.value, ' Hallo\nWelt 👋 ')
assert.equal(composer.draftImage.value.name, image.name)
const reloaded = createDraftHarness({ storage })
t.after(reloaded.close)
const restored = reloaded.useChatDraft('allgemein')
assert.equal(restored.draft.value, composer.draft.value)
assert.equal(restored.draftImage.value.dataUrl, image.dataUrl)
assert.equal(restored.captureDraft().value.clientId, composer.captureDraft().value.clientId)
assert.equal(reloaded.useChatDraft('project_1').draft.value, 'Projektentwurf')
assert.equal(first.calls.length, 0)
})
test('tenant and profile scopes cannot read each other; logout removes the session drafts', t => {
const ui = createDraftHarness()
t.after(ui.close)
const composer = ui.useChatDraft('allgemein')
composer.draft.value = 'Privater Entwurf'
ui.auth.activeTenant = '2'
assert.equal(composer.draft.value, '')
composer.draft.value = 'Mandant 2'
ui.auth.activeTenant = '1'
assert.equal(composer.draft.value, 'Privater Entwurf')
ui.auth.profile = { id: 'other-profile' }
assert.equal(composer.draft.value, '')
ui.auth.profile = { id: 'profile-1' }
assert.equal(composer.draft.value, 'Privater Entwurf')
ui.auth.user = { id: 'another-user' }
assert.equal(composer.draft.value, '')
assert.equal(Object.keys(ui.storage).length, 0)
composer.draft.value = 'Neuer Benutzer'
ui.auth.user = null
assert.equal(composer.draft.value, '')
assert.equal(Object.keys(ui.storage).length, 0)
})
test('successful sends remove only the captured version, even after changing rooms', t => {
const ui = createDraftHarness()
t.after(ui.close)
const room = vue.ref('allgemein'), composer = ui.useChatDraft(room)
composer.draft.value = 'Gesendet'
const sent = composer.captureDraft()
room.value = 'project_1'
composer.draft.value = 'Noch nicht gesendet'
composer.clearDraft(sent)
assert.equal(composer.draft.value, 'Noch nicht gesendet')
room.value = 'allgemein'
assert.equal(composer.draft.value, '')
composer.draftImage.value = image
const older = composer.captureDraft()
composer.draft.value = 'Neuer Text zum Bild'
composer.clearDraft(older)
assert.equal(composer.draft.value, 'Neuer Text zum Bild')
assert.equal(composer.draftImage.value.name, image.name)
composer.clearDraft(composer.captureDraft())
assert.equal(composer.draft.value, '')
assert.equal(composer.draftImage.value, null)
})
test('empty drafts are removed and never enter the shared profile temp_config', async t => {
const ui = createDraftHarness()
t.after(ui.close)
const composer = ui.useChatDraft('direct_user-2')
composer.draft.value = 'Geheim'
ui.store.modifySearchString('contacts', 'Beispiel')
assert.equal(JSON.stringify(ui.calls).includes('Geheim'), false)
ui.store.setStoredTempConfig({ settings: { density: 'compact' } })
assert.equal(composer.draft.value, 'Geheim')
composer.draft.value = ''
assert.equal(Object.keys(ui.storage).length, 0)
})
test('guest conversations never reuse internal drafts and keep temporary text through a retry', t => {
const ui = createDraftHarness()
t.after(ui.close)
ui.useChatDraft('collaboration:one').draft.value = 'Interner Entwurf'
const room = vue.ref('collaboration:one'), guest = ui.useChatDraft(room, { persist: false })
assert.equal(guest.draft.value, '')
guest.draft.value = 'Gastentwurf'
assert.equal(guest.draft.value, 'Gastentwurf')
room.value = 'collaboration:two'
assert.equal(guest.draft.value, '')
assert.equal(Object.values(ui.storage).some(value => value.includes('Gastentwurf')), false)
})
test('corrupt or unavailable browser storage does not prevent writing or sending', t => {
const storage = memoryStorage(), initial = createDraftHarness({ storage })
t.after(initial.close)
initial.useChatDraft('allgemein').draft.value = 'Alt'
storage[Object.keys(storage)[0]] = 'invalid json'
const corrupt = createDraftHarness({ storage })
t.after(corrupt.close)
assert.equal(corrupt.useChatDraft('allgemein').draft.value, '')
const blocked = createDraftHarness({ storage: {
getItem() { throw Error('Blocked') }, setItem() { throw Error('Quota exceeded') }, removeItem() { throw Error('Blocked') },
} })
t.after(blocked.close)
const composer = blocked.useChatDraft('allgemein')
composer.draft.value = 'Im Speicher'
composer.draftImage.value = image
assert.equal(composer.draft.value, 'Im Speicher')
assert.equal(composer.draftImage.value.name, image.name)
assert.equal(blocked.toasts.length, 1)
composer.clearDraft(composer.captureDraft())
assert.equal(composer.draft.value, '')
})

View File

@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
const fs = require('node:fs') const fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const vue = require('vue') const vue = require('vue')
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
const settle = () => new Promise(setImmediate) const settle = () => new Promise(setImmediate)
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } } const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
@@ -89,3 +90,62 @@ test('image display rejects external URLs and ignores responses after a room cha
await ui.run('load()') await ui.run('load()')
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA') assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
}) })
function collaborationApi(onSend) {
return async (url, options) => {
if (options?.method === 'POST' && url.endsWith('/messages')) return onSend(options.body)
if (url.endsWith('/messages')) return { messages: [], hasMore: false }
if (url.endsWith('/members')) return { members: [] }
if (url.endsWith('/read')) return {}
return { canSend: true, name: 'Partnerchat' }
}
}
test('collaboration drafts retain text, images and the retry ID when reopening after a failed send', async t => {
const drafts = createDraftHarness(), attempts = []
t.after(drafts.close)
let fail = true
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(body => {
attempts.push(body)
if (fail) throw { data: { message: 'Offline' } }
return { id: 'message-1', seq: 1, own: true, clientId: body.clientId, body: body.text }
}) }
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
await settle()
first.run('draft.value = " Partnerentwurf\\n "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Plan" }')
await first.run('send()')
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
assert.equal(first.run('draftImage.value.name'), 'Plan')
await first.run('load()')
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
first.close()
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
t.after(second.close)
await settle()
assert.equal(second.run('draft.value'), ' Partnerentwurf\n ')
fail = false
await second.run('send()')
assert.equal(attempts[0].clientId, attempts[1].clientId)
assert.equal(second.run('draft.value'), '')
assert.equal(second.run('draftImage.value'), null)
assert.equal(Object.keys(drafts.storage).length, 0)
})
test('collaboration send completion after unmount does not erase a newer draft in the same room', async t => {
const drafts = createDraftHarness(), pending = deferred()
t.after(drafts.close)
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(() => pending.promise) }
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
await settle()
first.run('draft.value = "Gesendet"')
const sending = first.run('send()')
first.close()
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
t.after(second.close)
await settle()
second.run('draft.value = "Neuer Entwurf"')
pending.resolve({ id: 'message-1', seq: 1, body: 'Gesendet' })
await sending
assert.equal(second.run('draft.value'), 'Neuer Entwurf')
assert.equal(second.run('messages.value.length'), 0)
})

View File

@@ -0,0 +1,36 @@
const fs = require('node:fs')
const vm = require('node:vm')
const { randomUUID } = require('node:crypto')
const vue = require('vue')
function memoryStorage() {
const storage = Object.create(null)
Object.defineProperties(storage, {
getItem: { value: key => storage[key] ?? null },
setItem: { value: (key, value) => { storage[key] = value } },
removeItem: { value: key => { delete storage[key] } },
})
return storage
}
function createDraftHarness({ auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: { id: 'profile-1' } }), storage = memoryStorage() } = {}) {
const scope = vue.effectScope(), toasts = [], calls = []
const context = vm.createContext({ ...vue, crypto: { randomUUID }, sessionStorage: storage,
defineStore: (_, setup) => setup,
useAuthStore: () => auth,
useRuntimeConfig: () => ({ public: { apiBase: 'https://api.example.test' } }),
useToast: () => ({ add: toast => toasts.push(toast) }),
useNuxtApp: () => ({ $api: async (url, options) => { calls.push({ url, options }) } }),
})
const storeSource = fs.readFileSync(`${__dirname}/../../stores/temp.js`, 'utf8').replace(/^import .*$/mg, '').replace('export const', 'const')
const store = scope.run(() => vm.runInContext(`${storeSource}\nuseTempStore()`, context))
context.useTempStore = () => store
const composable = fs.readFileSync(`${__dirname}/../../composables/useChatDraft.js`, 'utf8').replace('export function', 'function')
vm.runInContext(composable, context)
return { auth, storage, store, calls, toasts,
useChatDraft: (key, options) => scope.run(() => context.useChatDraft(key, options)),
close: () => scope.stop(),
}
}
module.exports = { createDraftHarness, memoryStorage }