import { pb } from '$lib/pocketbase'; import type { ChatMessage, TypingRow } from '$lib/types'; interface ChatInit { famId: string; actorId: string; actorType: 'admin' | 'member'; actorName: string; actorColor: string; deviceToken?: string; memberId?: string; } 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(''); deviceToken = $state(''); memberId = $state(''); private unsubs: (() => void)[] = []; private destroyed = false; private initPromise: Promise | null = null; private lastSeenAt = 0; private typingTimers = new Map>(); 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) { this.famId = opts.famId; this.actorId = opts.actorId; this.actorType = opts.actorType; this.actorName = opts.actorName; this.actorColor = opts.actorColor; this.deviceToken = opts.deviceToken || ''; this.memberId = opts.memberId || ''; if (this.initialized && this.famId === opts.famId) return; if (this.initPromise) { await this.initPromise; if (this.initialized && this.famId === opts.famId) return; } this.cleanup(); this.destroyed = false; 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.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 && !this.open) { this.unread++; } } 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); } 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 proxy ── private async chatFetch(method: string, path: string, body?: unknown) { const headers: Record = { 'Content-Type': 'application/json' }; if (this.actorType === 'member' && this.deviceToken) { headers['x-device-token'] = this.deviceToken; headers['x-device-famid'] = this.famId; } const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); return data; } async send(content: string) { const text = content.trim(); if (!text || !this.famId) return; const temp: ChatMessage = { id: 'temp-' + Date.now(), famId: this.famId, authorType: this.actorType, authorId: this.actorId, authorName: this.actorName, authorColor: this.actorColor, content: text, createdAt: new Date().toISOString() }; this.messages = [...this.messages, temp]; this.lastSeenAt = Date.now(); // Signal we stopped typing (message sent). this.setTyping(false).catch(() => {}); try { const saved = await this.chatFetch('POST', `/api/chat/${this.famId}/messages`, { content: text }); // SSE may have already delivered the real record via onMessage, so // drop both the temp and any pre-existing copy of the saved id to // avoid duplicate keys in the keyed each block. this.messages = this.messages .filter((m) => m.id !== temp.id && m.id !== saved.id) .concat([{ ...saved, authorName: temp.authorName, authorColor: temp.authorColor }]) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } 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.chatFetch('POST', `/api/chat/${this.famId}/typing`, { 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; } } export const chatStore = new ChatStore();