add @ mentions in chat

This commit is contained in:
JCEEE
2026-09-09 20:57:25 +01:00
parent 3f95d50d2e
commit 8a02640cdc
9 changed files with 296 additions and 40 deletions
+148 -14
View File
@@ -12,6 +12,67 @@
let container: HTMLDivElement | undefined = $state();
let idleTimer: ReturnType<typeof setTimeout> | 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)}
<span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span>
{/if}
<div
class="bubble"
style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}"
>
<div class="bubble" style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}">
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)}
{#if seg.mention}
<button class="mention" onclick={() => insertMention(seg.text.slice(1))}
>{seg.text}</button
>
{:else if seg.url}
<a class="msg-link" href={seg.url} target="_blank" rel="noopener noreferrer">{seg.url}</a>
<a class="msg-link" href={seg.url} target="_blank" rel="noopener noreferrer"
>{seg.url}</a
>
{:else}
{seg.text}
{/if}
@@ -181,20 +242,34 @@
<div class="chat-composer">
{#if error}<div class="chat-error">{error}</div>{/if}
<div class="composer-wrap">
{#if token && mentionList.length > 0}
<div class="mention-pop">
{#each mentionList as m, i}
<button
type="button"
class="mention-opt"
class:active={i === mentionIndex}
onclick={() => selectMention(m)}
onmouseenter={() => (mentionIndex = i)}
>
<span class="m-dot" style="background:{m.color}"></span>
<span class="m-name">{m.name}</span>
<span class="m-role">{m.role === 'parent' ? 'Parent' : ''}</span>
</button>
{/each}
</div>
{/if}
<input
type="text"
class="chat-input"
placeholder="Message the family…"
bind:value={draft}
oninput={onInput}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
}}
oninput={onComposerInput}
onkeydown={onComposerKeydown}
disabled={sending}
/>
</div>
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
{@html sendIcon}
</button>
@@ -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;
+33
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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: `
<p>Hi ${escHtml(parentName)},</p>
<p><strong>${escHtml(authorName)}</strong> mentioned you in your family chat:</p>
<blockquote style="margin:1rem 0;padding:0.6rem 1rem;border-left:3px solid #6366f1;background:#f5f5ff;color:#374151;border-radius:6px">${escHtml(message)}</blockquote>
<p><a href="${dashboardUrl}">Open your family dashboard</a> to read and reply.</p>
<p>Talk soon,<br />The FamDone team</p>
`
});
}
+57 -5
View File
@@ -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}'`
});
+10 -6
View File
@@ -48,7 +48,8 @@ export function createServices(
crudSvc.create(pb, resource, famId, data),
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
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)
},
+21 -2
View File
@@ -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<MentionMember[]>([]);
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 = [];
}
}
+8
View File
@@ -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;
+5 -1
View File
@@ -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,
+2 -1
View File
@@ -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 || []
});
});
</script>
+2 -1
View File
@@ -34,7 +34,8 @@ 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) {