/** * Owns the single staff-chat WebSocket (one per tab). Started once by * plugins/04.chat-socket.client.ts for non-limited staff. Pipes inbound frames * into global state (presence / unread / threads) and exposes send(). * * Also the source of truth for *activity*: it watches real user interaction and * tells the server active/idle so presence reflects "actually at the keyboard", * not merely "tab open". Heartbeat ping keeps the socket alive through nginx. * * Module-level singleton (the socket + timers) so repeated useChatSocket() calls * share one connection. */ // Build-time flag injected by Vite ONLY in the Capacitor app build (see nuxt.config.ts). Undefined // on the web build, so the `typeof` guard below keeps same-origin + cookie WS auth there. declare const __CAP_BUILD__: boolean | undefined const PING_MS = 25000 // < nginx idle timeout const IDLE_MS = 5 * 60 * 1000 // no interaction → away const RECONNECT_MS = 5000 let ws: WebSocket | null = null let pingTimer: any = null let idleTimer: any = null let reconnectTimer: any = null let activityBound = false let selfActive = true // Per-peer auto-clear timers so a lost typing:false frame can't leave "tippt…" stuck. const typingTimers: Record = {} export const useChatSocket = () => { const presence = useChatPresence() const unread = useChatUnread() const connected = useChatConnected() const threads = useChatThreads() const open = useChatOpen() const activePeer = useChatActivePeer() const typing = useChatTyping() const soundMuted = useChatSoundMuted() const staffUsers = useState('chatStaffUsers', () => []) // Seed the mute preference from localStorage once (client only). if (typeof window !== 'undefined') { try { if (localStorage.getItem('logship_chat_sound') === 'muted') soundMuted.value = true } catch {} } // Captured here (valid Nuxt context) so the socket's message handler — which // runs later, outside any active instance — can still raise a toast. const toast = useToast() const nuxtApp = useNuxtApp() const me = Number(useCookie('logship_user_id').value || 0) const sendRaw = (frame: any) => { if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(frame)) } catch {} } } // Re-seed per-peer unread counts from the DB. Runs on every (re)connect so the // header badge always reflects messages that arrived while we were away/offline. const seedUnread = async () => { try { const res: any = await $fetch('/api/chat/unread', { headers: useRequestHeaders(['cookie']) }) if (res?.counts) unread.value = { ...res.counts } } catch { /* not ready / gone */ } } const senderName = (id: number) => { const u = (staffUsers.value || []).find((x: any) => Number(x.id) === Number(id)) return u?.name || 'Neue Nachricht' } // In-app toast for a live inbound message we're not currently looking at. The // toast is ephemeral; the persistent cue is the header badge (unread count). const toastInbound = (msg: any) => { const sid = Number(msg.senderUserId) const openConversation = () => { // On mobile/native the teleported drawer doesn't render — route to the full-screen chat page // instead (mirrors ); the /chat route + 04.chat-socket plugin open this conversation. if (import.meta.client && window.innerWidth <= 1024) { nuxtApp.runWithContext(() => navigateTo('/chat?chat=' + sid)) return } open.value = true activePeer.value = sid // ChatDrawer watches activePeer → loads history + marks read } nuxtApp.runWithContext(() => { toast.add({ duration: 8000, timeout: 8000, // v2/v3 key compat (see plugins/03.notifications-stream.client.ts) title: senderName(sid), description: (msg.body || '').toString().slice(0, 140), icon: 'i-heroicons-chat-bubble-left-right', color: 'primary', actions: [{ label: 'Öffnen', click: openConversation, onClick: openConversation }] } as any) }) } // Short WebAudio blip — no asset, respects the mute preference. The user has // necessarily interacted with the app (they're logged in), so autoplay is allowed. const playBlip = () => { if (soundMuted.value || typeof window === 'undefined') return try { const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext if (!Ctx) return const ctx = new Ctx() const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = 'sine' osc.frequency.value = 660 gain.gain.setValueAtTime(0.0001, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.14, ctx.currentTime + 0.01) gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.25) osc.connect(gain); gain.connect(ctx.destination) osc.start() osc.stop(ctx.currentTime + 0.26) osc.onended = () => { try { ctx.close() } catch {} } } catch { /* audio not available */ } } // Desktop notification when the tab is in the background. Permission is obtained // by the push plugin — we never prompt here, only use it if already granted. const notifyInbound = (msg: any) => { try { if (typeof document === 'undefined' || !document.hidden) return if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return const sid = Number(msg.senderUserId) const n = new Notification(senderName(sid), { body: (msg.body || '').toString().slice(0, 140), tag: 'chat-' + sid, icon: '/favicon.ico' }) n.onclick = () => { try { window.focus() } catch {} open.value = true activePeer.value = sid try { n.close() } catch {} } } catch { /* notifications not available */ } } const applyInbound = (msg: any) => { if (!msg) return const peerKey = Number(msg.senderUserId) === me ? Number(msg.recipientUserId) : Number(msg.senderUserId) const list = threads.value[peerKey] ? [...threads.value[peerKey]] : [] // Reconcile an optimistic message (same clientMsgId) or de-dupe by id. const idx = list.findIndex((m: any) => (msg.clientMsgId && m.clientMsgId && m.clientMsgId === msg.clientMsgId) || (msg.id && m.id && m.id === msg.id)) if (idx >= 0) list[idx] = { ...list[idx], ...msg, pending: false } else list.push({ ...msg, pending: false }) threads.value = { ...threads.value, [peerKey]: list } // Inbound (not our own echo): bump unread + toast unless we're looking at it now. if (Number(msg.senderUserId) !== me) { const viewing = open.value && Number(activePeer.value) === Number(msg.senderUserId) && typeof document !== 'undefined' && !document.hidden if (!viewing) { const sid = Number(msg.senderUserId) unread.value = { ...unread.value, [sid]: (unread.value[sid] || 0) + 1 } toastInbound(msg) playBlip() notifyInbound(msg) } } } const handle = (frame: any) => { switch (frame?.type) { case 'connected': connected.value = true seedUnread() // refresh the badge for anything that landed while we were away break case 'presence-snapshot': { const map: Record = {} ;(frame.online || []).forEach((id: number) => { map[id] = 'online' }) ;(frame.away || []).forEach((id: number) => { map[id] = 'away' }) presence.value = map break } case 'presence': presence.value = { ...presence.value, [frame.userId]: frame.status } break case 'message': applyInbound(frame.message) break case 'message-update': { // A message changed server-side (e.g. a voice transcript finished). Merge // it into the right thread by id/clientMsgId — never bumps unread/toast. const msg = frame.message if (!msg) break const peerKey = Number(msg.senderUserId) === me ? Number(msg.recipientUserId) : Number(msg.senderUserId) const list = threads.value[peerKey] if (!list) break const idx = list.findIndex((m: any) => (msg.id && m.id === msg.id) || (msg.clientMsgId && m.clientMsgId && m.clientMsgId === msg.clientMsgId)) if (idx < 0) break const next = [...list] next[idx] = { ...next[idx], ...msg, meta: { ...(next[idx].meta || {}), ...(msg.meta || {}) }, pending: false } threads.value = { ...threads.value, [peerKey]: next } break } case 'reaction': { const messageId = Number(frame.messageId) const peerKey = Number(frame.senderUserId) === me ? Number(frame.recipientUserId) : Number(frame.senderUserId) const list = threads.value[peerKey] if (!list) break const idx = list.findIndex((m: any) => Number(m.id) === messageId) if (idx < 0) break const msg = list[idx] const reactions = Array.isArray(msg.reactions) ? [...msg.reactions] : [] const ri = reactions.findIndex((r: any) => Number(r.userId) === Number(frame.userId) && r.emoji === frame.emoji) if (frame.on) { if (ri < 0) reactions.push({ userId: Number(frame.userId), emoji: frame.emoji }) } else if (ri >= 0) reactions.splice(ri, 1) const next = [...list] next[idx] = { ...msg, reactions } threads.value = { ...threads.value, [peerKey]: next } break } case 'read': { const u = { ...unread.value } delete u[frame.peer] unread.value = u break } case 'read-receipt': { // The peer read our messages → stamp readAt on our outgoing ones (✓✓). const peerKey = Number(frame.peer) const list = threads.value[peerKey] if (!list) break const now = Date.now() let changed = false const next = list.map((m: any) => { if (Number(m.senderUserId) === me && !m.readAt) { changed = true; return { ...m, readAt: now } } return m }) if (changed) threads.value = { ...threads.value, [peerKey]: next } break } case 'typing': { const from = Number(frame.from) if (!from) break if (typingTimers[from]) { clearTimeout(typingTimers[from]); delete typingTimers[from] } if (frame.typing) { typing.value = { ...typing.value, [from]: true } typingTimers[from] = setTimeout(() => { const t = { ...typing.value }; delete t[from]; typing.value = t delete typingTimers[from] }, 6000) } else { const t = { ...typing.value }; delete t[from]; typing.value = t } break } case 'pong': default: break } } // ── activity / idle ────────────────────────────────────────────────────── const sendActivity = (active: boolean) => { selfActive = active sendRaw({ type: 'activity', active }) } const resetIdle = () => { if (idleTimer) clearTimeout(idleTimer) idleTimer = setTimeout(() => { if (selfActive) sendActivity(false) }, IDLE_MS) } const onInteract = () => { if (!selfActive) sendActivity(true) resetIdle() } const onVisibility = () => { if (document.hidden) { if (selfActive) sendActivity(false) } else { onInteract() } } const bindActivity = () => { if (activityBound || typeof window === 'undefined') return activityBound = true ;['mousedown', 'keydown', 'scroll', 'touchstart', 'click', 'mousemove'].forEach(ev => window.addEventListener(ev, onInteract, { passive: true })) document.addEventListener('visibilitychange', onVisibility) } // ── heartbeat ──────────────────────────────────────────────────────────── const startPing = () => { if (pingTimer) return pingTimer = setInterval(() => sendRaw({ type: 'ping' }), PING_MS) } const stopPing = () => { if (pingTimer) { clearInterval(pingTimer); pingTimer = null } } const scheduleReconnect = () => { if (reconnectTimer) return reconnectTimer = setTimeout(() => { reconnectTimer = null; connect() }, RECONNECT_MS) } // Web: same-origin WS + cookie auth. Bundled app (__CAP_BUILD__): the WebView origin is // https://localhost and a WS upgrade can't send an Authorization header or app.logship.de cookies, // so connect to the API host with the bearer token in the query (re-read on each reconnect → handles // token rotation). The server (ws.ts) accepts ?access_token as a fallback to the logship_it cookie. const buildWsUrl = (): string => { if (typeof __CAP_BUILD__ !== 'undefined') { const base = String(useRuntimeConfig().public.apiBase || '').replace(/^http/, 'ws') const token = useAuthSession().token.value return `${base}/chat/ws${token ? `?access_token=${encodeURIComponent(token)}` : ''}` } const proto = location.protocol === 'https:' ? 'wss' : 'ws' return `${proto}://${location.host}/chat/ws` } const connect = () => { if (typeof window === 'undefined') return if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return try { const socket = new WebSocket(buildWsUrl()) ws = socket socket.onopen = () => { startPing() bindActivity() selfActive = true sendActivity(true) resetIdle() } socket.onmessage = (e) => { try { handle(JSON.parse(e.data)) } catch {} } socket.onclose = () => { connected.value = false presence.value = {} stopPing() if (ws === socket) ws = null scheduleReconnect() } socket.onerror = () => { try { socket.close() } catch {} } } catch (e) { scheduleReconnect() } } const disconnect = () => { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } stopPing() if (idleTimer) { clearTimeout(idleTimer); idleTimer = null } if (ws) { try { ws.close() } catch {}; ws = null } connected.value = false } return { connect, disconnect, send: sendRaw } }