diff --git a/frontend/icons/message-square.svg b/frontend/icons/message-square.svg new file mode 100644 index 0000000..6a2e4e5 --- /dev/null +++ b/frontend/icons/message-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/components/Chat.svelte b/frontend/src/lib/components/Chat.svelte new file mode 100644 index 0000000..5e82a7c --- /dev/null +++ b/frontend/src/lib/components/Chat.svelte @@ -0,0 +1,353 @@ + + +
+
+ Family Chat + +
+ +
+ {#if chatStore.messages.length === 0} +

No messages yet. Say hi! πŸ‘‹

+ {:else} + {#each chatStore.messages as msg (msg.id)} +
+ {#if !isOwn(msg)} + + {msg.authorName?.charAt(0).toUpperCase()} + + {/if} +
+ {#if !isOwn(msg)} + {msg.authorName} + {/if} +
+ {#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)} + {#if seg.mention} + + {:else} + {seg.text} + {/if} + {/each} + {formatWeekday(msg.createdAt)} +
+
+
+ {/each} + {/if} +
+ +
+ {#if chatStore.typingNames.length > 0} + + {chatStore.typingNames.join(', ')} + {chatStore.typingNames.length === 1 ? 'is' : 'are'} typing… + + {:else} + + {/if} +
+ +
+ {#if error}
{error}
{/if} + { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }} + disabled={sending} + /> + +
+
+ + diff --git a/frontend/src/lib/components/icons.ts b/frontend/src/lib/components/icons.ts index 70f4fd0..d60d98d 100644 --- a/frontend/src/lib/components/icons.ts +++ b/frontend/src/lib/components/icons.ts @@ -9,3 +9,5 @@ export const prefsIcon = '' export const chevronRight = '' export const bellIcon = '' +export const chatIcon = '' +export const sendIcon = '' diff --git a/frontend/src/lib/components/index.ts b/frontend/src/lib/components/index.ts index 7f53072..39a8b2b 100644 --- a/frontend/src/lib/components/index.ts +++ b/frontend/src/lib/components/index.ts @@ -6,3 +6,4 @@ export { default as Card } from './Card.svelte'; export { default as CardGrid } from './CardGrid.svelte'; export { default as Button } from './Button.svelte'; export { default as Accordion } from './Accordion.svelte'; +export { default as Chat } from './Chat.svelte'; diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts new file mode 100644 index 0000000..2376ed1 --- /dev/null +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -0,0 +1,257 @@ +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(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index cd2987e..e6dac4d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -74,7 +74,7 @@ export interface ChoreTemplate { updated: string; } - export interface AssignedChore { +export interface AssignedChore { id: string; famId: string; memberId: string; @@ -172,6 +172,27 @@ export interface Settings { simulateEow?: boolean; } +export interface ChatMessage { + id: string; + famId: string; + authorType: 'admin' | 'member'; + authorId: string; + authorName: string; + authorColor?: string; + content: string; + createdAt: string; +} + +export interface TypingRow { + id: string; + famId: string; + actorId: string; + actorType: 'admin' | 'member'; + authorName: string; + authorColor?: string; + typing: boolean; +} + export interface Session { famId: string; userId: string; diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index ae70dcb..323289a 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -16,12 +16,40 @@ async function paydayCheck(famId: string, headers: Record) { } catch {} } +async function resolveChatIdentity( + api: 'admin' | 'member', + opts: { + session?: { famId: string; userId: string }; + deviceToken?: string; + famId?: string; + } +) { + try { + const headers: Record = { 'Content-Type': 'application/json' }; + if (api === 'admin' && opts.session) { + headers['x-session-famid'] = opts.session.famId; + headers['x-session-userid'] = opts.session.userId; + } else if (api === 'member' && opts.deviceToken && opts.famId) { + headers['x-device-token'] = opts.deviceToken; + headers['x-device-famid'] = opts.famId; + } else { + return null; + } + const res = await fetch(`${HONO_URL}/api/chat/me`, { headers }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + export async function load(event) { const session = event.locals.session || null; const isParent = session !== null; const deviceToken = event.cookies.get('device_token') || ''; let famId = ''; + let chat: { famId: string; actor: any } | null = null; if (isParent) { famId = session.famId; @@ -29,6 +57,7 @@ export async function load(event) { 'x-session-famid': session.famId, 'x-session-userid': session.userId }); + chat = await resolveChatIdentity('admin', { session }); } else if (deviceToken) { try { const res = await fetch(`${HONO_URL}/api/members/seasons`, { @@ -44,8 +73,9 @@ export async function load(event) { 'x-device-token': deviceToken, 'x-device-famid': famId }); + chat = await resolveChatIdentity('member', { deviceToken, famId }); } } - return { session, isParent, role: session?.role || 'child', famId }; + return { session, isParent, role: session?.role || 'child', famId, chat, deviceToken }; } diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 37b57a7..4281a1d 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -3,7 +3,9 @@ import { onMount } from 'svelte'; import { initPbFromCookie } from '$lib/pocketbase'; import { famStore } from '$lib/stores/fam.svelte'; - import { Sidebar, TopNav, Footer } from '$lib/components'; + import { chatStore } from '$lib/stores/chat.svelte'; + import { Sidebar, TopNav, Footer, Chat } from '$lib/components'; + import { chatIcon } from '$lib/components/icons'; import type { Session } from '$lib/types'; let { children, data } = $props(); @@ -24,7 +26,9 @@ const rewards = famStore.rewards; if (!mounted) { mounted = true; - prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)); + prevClaimedIds = new Set( + rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id) + ); return; } for (const r of rewards) { @@ -35,25 +39,52 @@ setTimeout(() => (claimToast = ''), 5000); } } - prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)); + prevClaimedIds = new Set( + rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id) + ); }); onMount(() => { initPbFromCookie(); if (page.data.famId) famStore.init(page.data.famId); + const chat = page.data.chat; + if (chat?.famId && chat?.actor) { + chatStore.init({ + famId: chat.famId, + actorId: chat.actor.id, + actorType: chat.actor.type, + actorName: chat.actor.name, + actorColor: chat.actor.color || '#6366f1', + deviceToken: page.data.deviceToken || '' + }); + } }); -
- - -
- {@render children()} -
- {#if claimToast} -
{claimToast}
+
+
+ + + + +
+ {@render children()} +
+ {#if claimToast} +
{claimToast}
+ {/if} +
+
+ {#if chatStore.open} + {/if} -
+
diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts index 4868cd5..8678367 100644 --- a/proxy/scripts/seed.ts +++ b/proxy/scripts/seed.ts @@ -349,6 +349,43 @@ async function main() { ], }); + // Chat collections (global family messages + transient typing presence) + ids.messages = await createCollection(token, { + name: "messages", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + select("authorType", ["admin", "member"], true), + text("authorId", true), + text("authorName", true), + text("authorColor"), + text("content", true), + ], + }); + + ids.chat_typing = await createCollection(token, { + name: "chat_typing", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + rel("famId", ids.fams!, true), + text("actorId", true), + select("actorType", ["admin", "member"], true), + text("authorName", true), + text("authorColor"), + bool("typing"), + ], + }); + console.log("\nβœ… All collections created successfully"); console.log("Collection IDs:", ids); } diff --git a/proxy/src/index.ts b/proxy/src/index.ts index cf44790..88b48a8 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -2313,6 +2313,144 @@ app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => { } }); +// ── Chat ──────────────────────────────────────────────── + +// Resolve the acting user for a chat write. Admins authenticate via the +// httpOnly `session` cookie (parsed server-side) or explicit session headers +// (server-to-server); members via device token. +async function resolveChatActor(c: any) { + // Server-side admin (layout load forwards session headers). + const hsFamId = c.req.header("x-session-famid"); + const hsUserId = c.req.header("x-session-userid"); + if (hsFamId && hsUserId) { + const admins = await pb.getList( + "fam_admins", + `famId = '${hsFamId}' && userId = '${hsUserId}'`, + ); + const admin = admins.items?.[0]; + if (admin) { + return { + famId: hsFamId, + actor: { + id: admin.id, + type: "admin", + name: admin.name, + color: admin.color, + }, + }; + } + } + const cookieHeader = c.req.header("cookie") || ""; + const cookieMatch = cookieHeader.match(/session=([^;]+)/); + if (cookieMatch) { + try { + const s = JSON.parse(decodeURIComponent(cookieMatch[1])); + if (s?.famId && s?.userId) { + const admins = await pb.getList( + "fam_admins", + `famId = '${s.famId}' && userId = '${s.userId}'`, + ); + const admin = admins.items?.[0]; + if (admin) { + return { + famId: s.famId, + actor: { + id: admin.id, + type: "admin", + name: admin.name, + color: admin.color, + }, + }; + } + } + } catch {} + } + const famId = c.req.header("x-device-famid"); + const deviceToken = c.req.header("x-device-token"); + if (famId && deviceToken) { + const hash = crypto.createHash("sha256").update(deviceToken).digest("hex"); + const members = await pb.getList( + "members", + `famId = '${famId}' && deviceToken = '${hash}'`, + ); + const member = members.items?.[0]; + if (member) { + return { + famId, + actor: { + id: member.id, + type: "member", + name: member.name, + color: member.color, + }, + }; + } + } + return null; +} + +app.post("/api/chat/:famId/messages", async (c) => { + try { + const auth = await resolveChatActor(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const { content } = await c.req.json(); + if (!content || !content.trim()) { + return c.json({ error: "content required" }, 400); + } + const record = await pb.create("messages", { + famId: auth.famId, + authorType: auth.actor.type, + authorId: auth.actor.id, + authorName: auth.actor.name, + authorColor: auth.actor.color, + content: content.trim(), + createdAt: new Date().toISOString(), + }); + return c.json(record); + } catch (err) { + return handleError(c, err); + } +}); + +app.post("/api/chat/:famId/typing", async (c) => { + try { + const auth = await resolveChatActor(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + const { typing } = await c.req.json(); + const existing = await pb.getList( + "chat_typing", + `famId = '${auth.famId}' && actorId = '${auth.actor.id}' && actorType = '${auth.actor.type}'`, + ); + const row = { + famId: auth.famId, + actorId: auth.actor.id, + actorType: auth.actor.type, + authorName: auth.actor.name, + authorColor: auth.actor.color, + typing: !!typing, + }; + if (existing.items?.length) { + await pb.update("chat_typing", existing.items[0].id, row); + } else { + await pb.create("chat_typing", row); + } + return c.json({ ok: true }); + } catch (err) { + return handleError(c, err); + } +}); + +// Current user's chat identity (name, color, actorId) + famId. +app.get("/api/chat/me", async (c) => { + try { + const auth = await resolveChatActor(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + return c.json({ famId: auth.famId, actor: auth.actor }); + } catch (err) { + return handleError(c, err); + } +}); + // ── Start server ───────────────────────────────────────── const port = parseInt(process.env.PROXY_PORT || "3456", 10); diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts index f7518e2..f42db8a 100644 --- a/proxy/src/migrate.ts +++ b/proxy/src/migrate.ts @@ -1383,5 +1383,78 @@ export async function migrate(): Promise { ); } + // ── 9. Create chat collections (messages + chat_typing) ── + const famsColChat = await getCollection("fams"); + if (famsColChat) { + // 9a. messages + const msgsCol = await getCollection("messages"); + if (!msgsCol) { + console.log("[migrate] Creating messages collection..."); + await createCollection({ + name: "messages", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: "famId", + type: "relation", + required: true, + collectionId: famsColChat.id, + maxSelect: 1, + cascadeDelete: true, + }, + { name: "authorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, + { name: "authorId", type: "text", required: true }, + { name: "authorName", type: "text", required: true }, + { name: "authorColor", type: "text", required: false }, + { name: "content", type: "text", required: true }, + { name: "createdAt", type: "date", required: false }, + ], + }); + } else { + console.log(` ↳ messages already exists`); + await updateCollection("messages", { listRule: "", viewRule: "" }); + } + + // 9b. chat_typing (transient presence rows, one per actor) + const typingCol = await getCollection("chat_typing"); + if (!typingCol) { + console.log("[migrate] Creating chat_typing collection..."); + await createCollection({ + name: "chat_typing", + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: "famId", + type: "relation", + required: true, + collectionId: famsColChat.id, + maxSelect: 1, + cascadeDelete: true, + }, + { name: "actorId", type: "text", required: true }, + { name: "actorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, + { name: "authorName", type: "text", required: true }, + { name: "authorColor", type: "text", required: false }, + { name: "typing", type: "bool", required: false }, + ], + }); + } else { + console.log(` ↳ chat_typing already exists`); + await updateCollection("chat_typing", { listRule: "", viewRule: "" }); + } + } else { + console.log(` ↳ fams collection not found β€” chat collections deferred`); + } + console.log("[migrate] Done"); }