/** * Conversation actions for the staff-chat drawer: load history, send (optimistic), * mark read. Reads/writes the shared thread store; sending goes over the socket. * * Optimistic-send mirrors components/requests/RequestForm.vue: push a temp * message with a clientMsgId immediately; the server echoes it back and * useChatSocket reconciles by clientMsgId (sets pending=false, fills id). */ export const useChat = () => { const threads = useChatThreads() const unread = useChatUnread() const { send } = useChatSocket() const user = useCookie('logship_user') const me = Number(useCookie('logship_user_id').value || 0) const loadHistory = async (peerId: number) => { try { const res: any = await $fetch('/api/chat/history', { query: { peer: peerId }, headers: useRequestHeaders(['cookie']) }) const records = (res?.records || []).map((m: any) => ({ ...m, pending: false })) threads.value = { ...threads.value, [peerId]: records } } catch (err) { console.error('[useChat] loadHistory failed:', err) } } const loadUnread = async () => { try { const res: any = await $fetch('/api/chat/unread', { headers: useRequestHeaders(['cookie']) }) if (res?.counts) unread.value = { ...res.counts } } catch (err) { console.error('[useChat] loadUnread failed:', err) } } const newClientId = () => { try { return crypto.randomUUID() } catch { return `${Date.now()}-${Math.floor(Math.random() * 1e9)}` } } const sendMessage = (peerId: number, body: string, meta: any = null) => { const text = (body || '').trim() if (!text || !peerId) return const clientMsgId = newClientId() const optimistic = { id: null, senderUserId: me, recipientUserId: Number(peerId), body: text, createdAt: Date.now(), readAt: null, clientMsgId, meta: meta || null, pending: true } const list = threads.value[peerId] ? [...threads.value[peerId]] : [] list.push(optimistic) threads.value = { ...threads.value, [peerId]: list } send({ type: 'message', to: Number(peerId), body: text, clientMsgId, fromName: user.value?.Name || user.value?.name || '', ...(meta ? { meta } : {}) }) } const sendTyping = (peerId: number, isTyping: boolean) => { if (!peerId) return send({ type: 'typing', to: Number(peerId), typing: !!isTyping }) } // Voice message: optimistic bubble (playable instantly via a local object URL), // upload over REST. The server fans out the real message over the socket and // useChatSocket reconciles by clientMsgId; transcription arrives later as a // `message-update`. const sendVoice = async (peerId: number, blob: Blob, durationSec = 0, lang = 'auto') => { if (!blob || !peerId) return const clientMsgId = newClientId() const localUrl = typeof URL !== 'undefined' ? URL.createObjectURL(blob) : '' const dur = Math.round(durationSec || 0) const optimistic = { id: null, senderUserId: me, recipientUserId: Number(peerId), body: '🎤 Sprachnachricht', createdAt: Date.now(), readAt: null, clientMsgId, meta: { kind: 'voice', localUrl, duration: dur, mime: blob.type || 'audio/webm', transcript: null, transcribing: true }, pending: true } const list = threads.value[peerId] ? [...threads.value[peerId]] : [] list.push(optimistic) threads.value = { ...threads.value, [peerId]: list } try { const fd = new FormData() fd.append('to', String(peerId)) fd.append('clientMsgId', clientMsgId) fd.append('duration', String(dur)) fd.append('fromName', user.value?.Name || user.value?.name || '') fd.append('lang', lang) fd.append('audio', blob, 'voice.webm') await $fetch('/api/chat/voice', { method: 'POST', headers: useRequestHeaders(['cookie']), body: fd }) } catch (err) { console.error('[useChat] sendVoice failed:', err) const cur = threads.value[peerId] ? [...threads.value[peerId]] : [] const i = cur.findIndex((m: any) => m.clientMsgId === clientMsgId) if (i >= 0) { cur[i] = { ...cur[i], pending: false, meta: { ...(cur[i].meta || {}), transcribing: false, error: true } } threads.value = { ...threads.value, [peerId]: cur } } } } // Image message: take the picked File, downscale it, upload to Strapi (PUBLIC media — same path the // mobile pages use; Strapi generates thumbnail/small/medium/large formats), then post the resulting // URLs to the chat server (which delivers + pushes). Optimistic bubble previews the local file // instantly; useChatSocket reconciles by clientMsgId, swapping in the Strapi thumbnail/full URLs. const sendImage = async (peerId: number, file: File) => { if (!file || !peerId) return const clientMsgId = newClientId() const localUrl = typeof URL !== 'undefined' ? URL.createObjectURL(file) : '' const optimistic = { id: null, senderUserId: me, recipientUserId: Number(peerId), body: '📷 Bild', createdAt: Date.now(), readAt: null, clientMsgId, meta: { kind: 'image', localUrl, thumbUrl: localUrl, fullUrl: localUrl }, pending: true } const list = threads.value[peerId] ? [...threads.value[peerId]] : [] list.push(optimistic) threads.value = { ...threads.value, [peerId]: list } const markError = () => { const cur = threads.value[peerId] ? [...threads.value[peerId]] : [] const i = cur.findIndex((m: any) => m.clientMsgId === clientMsgId) if (i >= 0) { cur[i] = { ...cur[i], pending: false, meta: { ...(cur[i].meta || {}), error: true } } threads.value = { ...threads.value, [peerId]: cur } } } try { const config = useRuntimeConfig() // 1) downscale + upload to Strapi. const { blob } = await downscaleImage(file) const up = new FormData() up.append('files', blob, 'chat.jpg') const res = await fetch(config.public.strapi + '/media-api/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + (config.public.strapitoken as string) }, body: up }) if (!res.ok) throw new Error('strapi upload ' + res.status) const uploaded = await res.json() const f: any = Array.isArray(uploaded) ? uploaded[0] : uploaded if (!f) throw new Error('no strapi file') const base = config.public.strapi as string const fmtUrl = (fmt: any) => (fmt?.hash ? base + '/files-api/' + fmt.hash + fmt.ext : null) const orig = f.hash ? base + '/files-api/' + f.hash + f.ext : null // thumbnail for the history bubble; large→medium→small for the full-size (click) view. const thumbUrl = fmtUrl(f.formats?.thumbnail) || fmtUrl(f.formats?.small) || orig const fullUrl = fmtUrl(f.formats?.large) || fmtUrl(f.formats?.medium) || fmtUrl(f.formats?.small) || orig if (!thumbUrl) throw new Error('no image url') // 2) post the message carrying the Strapi URLs. await $fetch('/api/chat/image', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { to: peerId, clientMsgId, fromName: user.value?.Name || user.value?.name || '', thumbUrl, fullUrl, width: f.width || 0, height: f.height || 0 } }) } catch (err) { console.error('[useChat] sendImage failed:', err) markError() } } // Toggle an emoji reaction on a persisted message (id required). The server // relays a `reaction` frame to both parties; useChatSocket updates the chips. const react = (messageId: number, emoji: string, on: boolean) => { if (!messageId || !emoji) return send({ type: 'react', messageId: Number(messageId), emoji, on: !!on }) } const markRead = async (peerId: number) => { const u = { ...unread.value } delete u[peerId] unread.value = u try { await $fetch('/api/chat/read', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { peer: Number(peerId) } }) } catch (err) { console.error('[useChat] markRead failed:', err) } } return { threads, loadHistory, loadUnread, sendMessage, sendVoice, sendImage, react, sendTyping, markRead, me } }