diff --git a/frontend/src/lib/components/Chat.svelte b/frontend/src/lib/components/Chat.svelte index f3d5ac8..8275d2b 100644 --- a/frontend/src/lib/components/Chat.svelte +++ b/frontend/src/lib/components/Chat.svelte @@ -12,6 +12,67 @@ let container: HTMLDivElement | undefined = $state(); let idleTimer: ReturnType | undefined; + // @-mention autocomplete — active while the draft ends with `@query` at the + // start of the message or after whitespace. + let token = $state<{ at: number; query: string } | null>(null); + let mentionIndex = $state(0); + + function mentionTokenOf(text: string): { at: number; query: string } | null { + const atIdx = text.lastIndexOf('@'); + if (atIdx === -1) return null; + if (atIdx > 0 && text[atIdx - 1] !== ' ' && text[atIdx - 1] !== '\n') return null; + return { at: atIdx, query: text.slice(atIdx + 1) }; + } + + let mentionList = $derived.by(() => { + if (!token) return []; + const q = token.query.toLowerCase(); + const list = chatStore.members.filter((m) => m.id !== chatStore.actorId); + if (!q) return list; + return list.filter((m) => m.name.toLowerCase().startsWith(q)); + }); + + function selectMention(m: { name: string }) { + if (!token) return; + draft = draft.slice(0, token.at) + '@' + m.name + ' '; + token = null; + mentionIndex = 0; + } + + function onComposerInput(e: Event) { + token = mentionTokenOf((e.currentTarget as HTMLInputElement).value); + mentionIndex = 0; + onInput(); + } + + function onComposerKeydown(e: KeyboardEvent) { + if (token && mentionList.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + mentionIndex = (mentionIndex + 1) % mentionList.length; + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + mentionIndex = (mentionIndex - 1 + mentionList.length) % mentionList.length; + return; + } + if (e.key === 'Tab') { + e.preventDefault(); + selectMention(mentionList[mentionIndex]); + return; + } + if (e.key === 'Escape') { + token = null; + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + } + const isOwn = (msg: any) => msg.authorId === chatStore.actorId; // Colour comes from the sender's user record (loaded with the family on @@ -96,10 +157,11 @@ } // Render content, wrapping @mentions in a styled token and https:// URLs in - // clickable links (opened in a new window). + // clickable links (opened in a new window). Mentions may be multi-word names + // (space-separated words after the @). function renderContent(text: string) { const segs: { text: string; mention: boolean; url: string | null }[] = []; - const re = /(@[A-Za-z0-9_.-]+|https?:\/\/[^\s]+)/g; + const re = /(@[A-Za-z0-9_.-]+(?:[\s]+[A-Za-z0-9_.-]+)*|https?:\/\/[^\s]+)/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { @@ -145,17 +207,16 @@ {#if !isOwn(msg)} {msg.authorName} {/if} -
+
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)} {#if seg.mention} {:else if seg.url} - {seg.url} + {seg.url} {:else} {seg.text} {/if} @@ -181,20 +242,34 @@
{#if error}
{error}
{/if} - { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - send(); - } - }} - disabled={sending} - /> +
+ {#if token && mentionList.length > 0} +
+ {#each mentionList as m, i} + + {/each} +
+ {/if} + +
@@ -355,6 +430,65 @@ border-top: 1px solid #e5e7eb; align-items: center; } + .composer-wrap { + position: relative; + flex: 1; + } + .composer-wrap .chat-input { + width: 100%; + } + .mention-pop { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + right: 0; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + padding: 0.25rem; + max-height: 220px; + overflow-y: auto; + z-index: 120; + } + .mention-opt { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.6rem; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; + text-align: left; + } + .mention-opt:hover, + .mention-opt.active { + background: #eef2ff; + } + .m-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: none; + } + .m-name { + font-weight: 600; + color: #1f2937; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .m-role { + font-size: 0.68rem; + font-weight: 600; + color: #9ca3af; + flex: none; + } .chat-error { position: absolute; bottom: 4.5rem; diff --git a/frontend/src/lib/server/email.ts b/frontend/src/lib/server/email.ts index 577ba3f..1177a35 100644 --- a/frontend/src/lib/server/email.ts +++ b/frontend/src/lib/server/email.ts @@ -94,3 +94,36 @@ export async function sendWelcomeEmail(opts: { ` }); } + +// A parent was @mentioned in family chat — tell them about it with a link +// straight back to the family site. Message text is HTML-escaped. +function escHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export async function sendMentionEmail(opts: { + to: string; + parentName: string; + authorName: string; + message: string; + dashboardUrl: string; +}) { + const { to, parentName, authorName, message, dashboardUrl } = opts; + + await resend.emails.send({ + from: 'no-reply@walthamstow.xyz', + to, + subject: `${authorName} mentioned you in family chat`, + html: ` +

Hi ${escHtml(parentName)},

+

${escHtml(authorName)} mentioned you in your family chat:

+
${escHtml(message)}
+

Open your family dashboard to read and reply.

+

Talk soon,
The FamDone team

+ ` + }); +} diff --git a/frontend/src/lib/server/services/chat.ts b/frontend/src/lib/server/services/chat.ts index e46b411..f92ffae 100644 --- a/frontend/src/lib/server/services/chat.ts +++ b/frontend/src/lib/server/services/chat.ts @@ -1,3 +1,5 @@ +import { sendMentionEmail } from '../email'; + export type ChatActor = { id: string; type: 'admin' | 'member'; @@ -5,19 +7,33 @@ export type ChatActor = { color: string; }; +// Everyone in the family (children + parents) for the @mention picker. export async function chatMe(pb: any, famId: string, actor: ChatActor) { - return { famId, actor }; + const users = await pb + .collection('users') + .getFullList({ filter: `famId = '${famId}'` }) + .catch(() => []); + return { + famId, + actor, + members: users.map((m: any) => ({ + id: m.id, + name: m.name || '', + color: m.color || '#6366f1', + role: m.role === 'parent' ? 'parent' : 'child' + })) + }; } export async function send( pb: any, famId: string, actor: ChatActor, - body: { content?: string; clientId?: string } + body: { content?: string; clientId?: string; siteUrl?: string } ) { const content = (body.content || '').trim(); if (!content) throw new Error('content required'); - return pb.collection('messages').create({ + const record = await pb.collection('messages').create({ famId, authorType: actor.type, authorId: actor.id, @@ -27,14 +43,50 @@ export async function send( createdAt: new Date().toISOString(), clientId: body.clientId ? String(body.clientId).slice(0, 64) : '' }); + // Email any @mentioned parents (their in-app toast is client-side, via the + // same mention tokens). Fire-and-forget — never fail the message itself. + notifyMentionedParents(pb, famId, actor, record, body.siteUrl || '').catch(() => {}); + return record; } -export async function typing( +// Scan the message for `@Parent Name` tokens (case-insensitive) and email every +// mentioned parent who isn't the sender, with a link back to the family site. +async function notifyMentionedParents( pb: any, famId: string, actor: ChatActor, - body: { typing?: boolean } + message: any, + siteUrl: string ) { + const content = String(message.content || ''); + const parents = await pb + .collection('users') + .getFullList({ filter: `famId = '${famId}' && role = 'parent'` }) + .catch(() => []); + const mentioned = parents.filter((u: any) => { + if (u.id === actor.id || !u.email) return false; + const name = (u.name || '').trim().toLowerCase(); + return !!name && content.toLowerCase().includes('@' + name); + }); + if (!mentioned.length) return; + + const fam = await pb + .collection('fams') + .getOne(famId) + .catch(() => null); + const dashboardUrl = `${siteUrl}/${fam?.slug || famId}`; + for (const parent of mentioned) { + await sendMentionEmail({ + to: parent.email, + parentName: parent.name || 'there', + authorName: message.authorName || 'Family member', + message: content, + dashboardUrl + }); + } +} + +export async function typing(pb: any, famId: string, actor: ChatActor, body: { typing?: boolean }) { const existing = await pb.collection('chat_typing').getFullList({ filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'` }); @@ -52,4 +104,4 @@ export async function typing( await pb.collection('chat_typing').create(row); } return { ok: true }; -} \ No newline at end of file +} diff --git a/frontend/src/lib/server/services/index.ts b/frontend/src/lib/server/services/index.ts index ad3e7e4..299f831 100644 --- a/frontend/src/lib/server/services/index.ts +++ b/frontend/src/lib/server/services/index.ts @@ -48,7 +48,8 @@ export function createServices( crudSvc.create(pb, resource, famId, data), update: (resource: string, famId: string, id: string, data: Record) => crudSvc.update(pb, resource, famId, id, data), - remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id) + remove: (resource: string, famId: string, id: string) => + crudSvc.remove(pb, resource, famId, id) }, chores: { myChores: (famId: string) => choresSvc.myChores(pb, famId, uid), @@ -57,7 +58,8 @@ export function createServices( completions: { toggle: (famId: string, body: { assignedChoreId: string; date: string }) => completionsSvc.toggle(pb, famId, uid, body), - revoke: (famId: string, completionId: string) => completionsSvc.revoke(pb, famId, completionId) + revoke: (famId: string, completionId: string) => + completionsSvc.revoke(pb, famId, completionId) }, rewards: { claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id), @@ -78,8 +80,7 @@ export function createServices( }, complete: (famId: string, configId: string) => pb.collection('bonus_configs').update(configId, { status: 'completed' }), - destroy: (famId: string, configId: string) => - pb.collection('bonus_configs').delete(configId) + destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId) }, settings: { get: (famId: string) => settingsSvc.getSettings(pb, famId), @@ -88,8 +89,11 @@ export function createServices( }, chat: { me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor), - send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) => - chatSvc.send(pb, famId, actor, body), + send: ( + famId: string, + actor: chatSvc.ChatActor, + body: { content?: string; clientId?: string; siteUrl?: string } + ) => chatSvc.send(pb, famId, actor, body), typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) => chatSvc.typing(pb, famId, actor, body) }, @@ -102,4 +106,4 @@ export function createServices( }; } -export type Services = ReturnType; \ No newline at end of file +export type Services = ReturnType; diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index d66e41b..19611f8 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -1,5 +1,6 @@ import { pb } from '$lib/pocketbase'; -import type { ChatMessage, TypingRow } from '$lib/types'; +import type { ChatMessage, TypingRow, MentionMember } from '$lib/types'; +import { addAutoDismissNotice } from './notices.svelte'; interface ChatInit { famId: string; @@ -8,7 +9,7 @@ interface ChatInit { actorName: string; actorColor: string; pbToken?: string; - + members?: MentionMember[]; } // Client id for optimistic chat messages. `crypto.randomUUID()` requires a @@ -34,6 +35,7 @@ class ChatStore { actorName = $state(''); actorColor = $state(''); pbToken = $state(''); + members = $state([]); private unsubs: (() => void)[] = []; private destroyed = false; @@ -80,6 +82,7 @@ class ChatStore { this.actorName = opts.actorName; this.actorColor = opts.actorColor; this.pbToken = opts.pbToken || ''; + this.members = opts.members || []; this.messages = []; this.typing = {}; this.unread = 0; @@ -160,9 +163,24 @@ class ChatStore { 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') { @@ -310,6 +328,7 @@ class ChatStore { for (const t of this.typingTimers.values()) clearTimeout(t); this.typingTimers.clear(); this.initialized = false; + this.members = []; } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 10b6a1b..e408e26 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -66,6 +66,14 @@ export interface Member { updated: string; } +// Family member entry for chat @-mentions (children + parents). +export interface MentionMember { + id: string; + name: string; + color: string; + role: 'parent' | 'child'; +} + export interface ChoreTemplate { id: string; famId: string; diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index a0de686..57035f5 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -52,7 +52,11 @@ export async function load(event) { const pbToken = event.cookies.get('pb_token') || ''; let famId = ''; - let chat: { famId: string; actor: ChatActor } | null = null; + let chat: { + famId: string; + actor: ChatActor; + members?: { id: string; name: string; color: string; role: 'parent' | 'child' }[]; + } | null = null; let fam: any = null; let famAccess = { disabled: false, diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 8bf4be6..0189173 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -170,7 +170,8 @@ actorType: chat.actor.type, actorName: chat.actor.name, actorColor: chat.actor.color || '#6366f1', - pbToken: page.data.pbToken || '' + pbToken: page.data.pbToken || '', + members: chat.members || [] }); }); diff --git a/frontend/src/routes/api/chat/+server.ts b/frontend/src/routes/api/chat/+server.ts index bb0af43..e64d742 100644 --- a/frontend/src/routes/api/chat/+server.ts +++ b/frontend/src/routes/api/chat/+server.ts @@ -34,10 +34,11 @@ export async function POST(event: RequestEvent) { ? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) }) : await s.chat.send(famId, actor, { content: body.content || '', - clientId: body.clientId || '' + clientId: body.clientId || '', + siteUrl: event.url.origin }); return json(data); } catch (e) { return json({ error: e instanceof Error ? e.message : 'chat request failed' }, { status: 400 }); } -} \ No newline at end of file +}