add @ mentions in chat
This commit is contained in:
@@ -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, '>')
|
||||
.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: `
|
||||
<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>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user