import { pb } from '$lib/pocketbase'; import type { ChatMessage, TypingRow, MentionMember } from '$lib/types'; import { addAutoDismissNotice } from './notices.svelte'; interface ChatInit { famId: string; actorId: string; actorType: 'admin' | 'member'; actorName: string; actorColor: string; pbToken?: string; members?: MentionMember[]; } // Client id for optimistic chat messages. `crypto.randomUUID()` requires a // secure context (HTTPS/localhost) — over plain HTTP on a LAN it's undefined, // so fall back to a time+random string that's still unique enough per session. function genClientId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return Date.now().toString(36) + Math.random().toString(36).slice(2); } class ChatStore { famId = $state(''); messages = $state([]); typing = $state>({}); unread = $state(0); open = $state(false); initialized = $state(false); actorId = $state(''); actorType = $state<'admin' | 'member'>('member'); actorName = $state(''); actorColor = $state(''); pbToken = $state(''); members = $state([]); private unsubs: (() => void)[] = []; private destroyed = false; private initPromise: Promise | null = null; private lastSeenAt = 0; private typingTimers = new Map>(); private audioCtx: AudioContext | null = null; typingNames = $derived.by(() => { const names: string[] = []; for (const row of Object.values(this.typing)) { if (row.actorId !== this.actorId) names.push(row.authorName); } return names; }); async init(opts: ChatInit) { // Re-initialize whenever the family OR the actor changes (e.g. switching // between parent and child in the same browser session). The store is a // module singleton — keeping the previous actor's identity/subscription // would make one direction's realtime silently dead. const sameIdentity = this.initialized && this.famId === opts.famId && this.actorId === opts.actorId && this.actorType === opts.actorType; if (sameIdentity) return; if (this.initPromise) { await this.initPromise; if ( this.initialized && this.famId === opts.famId && this.actorId === opts.actorId && this.actorType === opts.actorType ) return; } this.cleanup(); this.destroyed = false; this.famId = opts.famId; this.actorId = opts.actorId; this.actorType = opts.actorType; this.actorName = opts.actorName; this.actorColor = opts.actorColor; this.pbToken = opts.pbToken || ''; this.members = opts.members || []; this.messages = []; this.typing = {}; this.unread = 0; this.lastSeenAt = Date.now(); this.initPromise = (async () => { try { // Last week + this week of history (custom createdAt field — the // auto `created` field can't be filtered in this PB version). const since = new Date(Date.now() - 14 * 86400000).toISOString(); const msgs = (await pb.collection('messages').getFullList({ filter: `famId = '${opts.famId}' && createdAt >= '${since}'`, sort: 'createdAt' })) as ChatMessage[]; this.messages = msgs; this.initialized = true; } catch (e) { console.error('ChatStore.init failed:', e); this.initPromise = null; throw e; } await this.subscribe(); this.initPromise = null; })(); return this.initPromise!; } private async subscribe() { const msgSub = pb .collection('messages') .subscribe('*', (data: any) => { if (this.destroyed) return; this.onMessage(data.action, data.record); }) .then((unsub) => this.unsubs.push(unsub)) .catch((err: Error) => console.error('[chatStore] messages subscribe failed:', err)); const typingSub = pb .collection('chat_typing') .subscribe('*', (data: any) => { if (this.destroyed) return; this.onTyping(data.action, data.record); }) .then((unsub) => this.unsubs.push(unsub)) .catch((err: Error) => console.error('[chatStore] typing subscribe failed:', err)); await Promise.allSettled([msgSub, typingSub]); } private onMessage(action: string, record: ChatMessage) { if (record.famId !== this.famId) return; if (action === 'delete') { this.messages = this.messages.filter((m) => m.id !== record.id); return; } if (action === 'update') { this.messages = this.messages.map((m) => (m.id === record.id ? { ...m, ...record } : m)); return; } // create // If this is our own optimistic temp (same clientId), replace it with // the server-confirmed record instead of appending a duplicate. const tempIdx = this.messages.findIndex( (m) => m.clientId && record.clientId && m.clientId === record.clientId ); if (tempIdx !== -1) { this.messages = this.messages .map((m, i) => (i === tempIdx ? record : m)) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } else if (!this.messages.some((m) => m.id === record.id)) { this.messages = [...this.messages, record].sort((a, b) => a.createdAt.localeCompare(b.createdAt) ); } // Stop showing their typing indicator once the message lands. this.clearTyping(record.authorId); if (record.authorId !== this.actorId) { if (!this.open) this.unread++; this.playDunk(); this.notifyIfMentioned(record); } } // Toast the current user when a message @mentions them by name. private notifyIfMentioned(record: ChatMessage) { const lower = (record.content || '').toLowerCase(); const me = this.members.find((m) => m.id === this.actorId); if (!me || !me.name) return; if (!lower.includes('@' + me.name.toLowerCase())) return; addAutoDismissNotice({ type: 'info', title: 'You were mentioned 👋', message: `${record.authorName || 'Someone'}: ${record.content}`, dismissible: true }); } private onTyping(action: string, record: TypingRow) { if (record.famId !== this.famId) return; if (action === 'delete') { this.removeTyping(record.actorId); return; } if (!record.typing) { this.removeTyping(record.actorId); return; } this.typing = { ...this.typing, [record.actorId]: record }; const prev = this.typingTimers.get(record.actorId); if (prev) clearTimeout(prev); this.typingTimers.set( record.actorId, setTimeout(() => this.removeTyping(record.actorId), 5000) ); } private clearTyping(actorId: string) { if (this.typing[actorId]) this.removeTyping(actorId); } // Short, low-volume "dunk" blip played when a message from someone else is // broadcast in. Synthesised with the Web Audio API so there's no asset to // ship. Browsers require a user gesture before audio can start, but chat // messages only arrive after the user has interacted, so the context // resumes fine. Failures are silently ignored. private playDunk() { if (typeof window === 'undefined' || typeof AudioContext === 'undefined') return; try { const Ctor = window.AudioContext || (window as any).webkitAudioContext; if (!Ctor) return; if (!this.audioCtx) this.audioCtx = new Ctor(); const ctx = this.audioCtx; if (ctx.state === 'suspended') ctx.resume(); const now = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; // Quick downward pitch sweep gives the soft "dun-nk" thud. osc.frequency.setValueAtTime(190, now); osc.frequency.exponentialRampToValueAtTime(70, now + 0.18); // Low volume with a fast attack/decay envelope. gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22); osc.connect(gain).connect(ctx.destination); osc.start(now); osc.stop(now + 0.24); } catch { /* audio is best-effort */ } } private removeTyping(actorId: string) { if (!this.typing[actorId]) return; const next = { ...this.typing }; delete next[actorId]; this.typing = next; const t = this.typingTimers.get(actorId); if (t) clearTimeout(t); this.typingTimers.delete(actorId); } // ── Writes via SvelteKit server (forwards session/device auth) ── private async serverChat(payload: Record) { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'chat request failed'); return data; } async send(content: string) { const text = content.trim(); if (!text || !this.famId) return; const clientId = genClientId(); const temp: ChatMessage = { id: 'temp-' + clientId, famId: this.famId, authorType: this.actorType, authorId: this.actorId, authorName: this.actorName, authorColor: this.actorColor, content: text, createdAt: new Date().toISOString(), clientId }; this.messages = [...this.messages, temp]; this.lastSeenAt = Date.now(); // Signal we stopped typing (message sent). this.setTyping(false).catch(() => {}); try { // Server writes to PB; the real record arrives via SSE and // overwrites this optimistic temp (matched by clientId) in onMessage. await this.serverChat({ action: 'send', famId: this.famId, content: text, clientId }); } catch (e) { this.messages = this.messages.filter((m) => m.id !== temp.id); console.error('Chat send failed:', e); throw e; } } private typingThrottle = 0; async setTyping(typing: boolean) { if (!this.famId) return; const now = Date.now(); if (typing && now - this.typingThrottle < 1500) return; this.typingThrottle = now; try { await this.serverChat({ action: 'typing', famId: this.famId, typing }); } catch (e) { console.error('Chat typing failed:', e); } } toggle() { this.open = !this.open; if (this.open) { this.unread = 0; this.lastSeenAt = Date.now(); } } openChat() { this.open = true; this.unread = 0; this.lastSeenAt = Date.now(); } closeChat() { this.open = false; } cleanup() { this.destroyed = true; for (const unsub of this.unsubs) unsub(); this.unsubs = []; for (const t of this.typingTimers.values()) clearTimeout(t); this.typingTimers.clear(); this.initialized = false; this.members = []; } } export const chatStore = new ChatStore();