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
+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, '&')
.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>
`
});
}
+58 -6
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}'`
});
@@ -52,4 +104,4 @@ export async function typing(
await pb.collection('chat_typing').create(row);
}
return { ok: true };
}
}
+11 -7
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)
},
@@ -102,4 +106,4 @@ export function createServices(
};
}
export type Services = ReturnType<typeof createServices>;
export type Services = ReturnType<typeof createServices>;