feat(chat): preserve message drafts in session temp store
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<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(''), 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)
|
||||
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 merge = (incoming) => {
|
||||
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) {
|
||||
error.value = messageError(e)
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -32,7 +34,7 @@ async function read(id, version) {
|
||||
}
|
||||
async function load() {
|
||||
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
|
||||
try {
|
||||
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
|
||||
if ((!text && !image) || sending.value || !room.value?.canSend) return
|
||||
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 = ''
|
||||
try {
|
||||
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
|
||||
clearDraft(savedDraft)
|
||||
if (version !== generation) return
|
||||
merge([message]); draft.value = ''; draftImage.value = null; pending = null
|
||||
merge([message])
|
||||
await scroll(); await read(id, version)
|
||||
} 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' })
|
||||
watch(() => props.roomId, load, { immediate: true })
|
||||
@@ -126,6 +131,6 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
</div>
|
||||
</ChatReactions>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
43
frontend/composables/useChatDraft.js
Normal file
43
frontend/composables/useChatDraft.js
Normal 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 }
|
||||
}
|
||||
@@ -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(''), draftImage = ref(null)
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([])
|
||||
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)
|
||||
@@ -48,8 +48,9 @@ const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten',
|
||||
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
|
||||
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
|
||||
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) {
|
||||
const byId = new Map(messages.value.map(message => [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() {
|
||||
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 = ''
|
||||
const savedDraft = startDraftSend()
|
||||
if (!savedDraft) return
|
||||
sending.value = true
|
||||
try {
|
||||
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
|
||||
clearDraft(savedDraft)
|
||||
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) {
|
||||
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
} finally { if (currentSelection(request)) sending.value = false }
|
||||
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
|
||||
} finally { finishDraftSend(savedDraft); if (currentSelection(request)) sending.value = false }
|
||||
}
|
||||
async function pollMessages() {
|
||||
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
|
||||
@@ -386,7 +390,7 @@ onBeforeUnmount(() => {
|
||||
</ChatReactions>
|
||||
</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>
|
||||
|
||||
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
||||
|
||||
@@ -10,6 +10,71 @@ export const useTempStore = defineStore('temp', () => {
|
||||
const pages = 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 config = {
|
||||
searchStrings: searchStrings.value,
|
||||
@@ -92,6 +157,11 @@ export const useTempStore = defineStore('temp', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
getChatDraft,
|
||||
setChatDraft,
|
||||
isChatDraftSending,
|
||||
startChatDraftSend,
|
||||
finishChatDraftSend,
|
||||
setStoredTempConfig,
|
||||
searchStrings,
|
||||
modifySearchString,
|
||||
|
||||
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
|
||||
|
||||
const settle = () => new Promise(setImmediate)
|
||||
const deferred = () => {
|
||||
@@ -33,6 +34,7 @@ function fixture(override = () => undefined) {
|
||||
|
||||
function setup(api = fixture(), query = {}) {
|
||||
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
|
||||
const drafts = createDraftHarness({ auth })
|
||||
const route = vue.reactive({ query })
|
||||
const calls = [], toasts = [], timers = 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,
|
||||
useToast: () => ({ add: item => toasts.push(item) }),
|
||||
useAuthStore: () => auth, useRoute: () => route,
|
||||
useChatDraft: drafts.useChatDraft,
|
||||
useRouter: () => ({ replace: async location => { route.query = location.query } }),
|
||||
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
|
||||
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
|
||||
@@ -54,7 +57,7 @@ function setup(api = fixture(), query = {}) {
|
||||
clearInterval: id => timers.delete(id), document,
|
||||
})
|
||||
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 => {
|
||||
@@ -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.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)
|
||||
})
|
||||
|
||||
123
frontend/tests/chat-drafts.test.cjs
Normal file
123
frontend/tests/chat-drafts.test.cjs
Normal 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, '')
|
||||
})
|
||||
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const vue = require('vue')
|
||||
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
|
||||
const settle = () => new Promise(setImmediate)
|
||||
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()')
|
||||
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)
|
||||
})
|
||||
|
||||
36
frontend/tests/helpers/chat-drafts.cjs
Normal file
36
frontend/tests/helpers/chat-drafts.cjs
Normal 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 }
|
||||
Reference in New Issue
Block a user