add @ mentions in chat
This commit is contained in:
@@ -12,6 +12,67 @@
|
|||||||
let container: HTMLDivElement | undefined = $state();
|
let container: HTMLDivElement | undefined = $state();
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
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;
|
const isOwn = (msg: any) => msg.authorId === chatStore.actorId;
|
||||||
|
|
||||||
// Colour comes from the sender's user record (loaded with the family on
|
// 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
|
// 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) {
|
function renderContent(text: string) {
|
||||||
const segs: { text: string; mention: boolean; url: string | null }[] = [];
|
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 last = 0;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
@@ -145,17 +207,16 @@
|
|||||||
{#if !isOwn(msg)}
|
{#if !isOwn(msg)}
|
||||||
<span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span>
|
<span class="msg-author" style="color:{colorOf(msg)}">{msg.authorName}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div class="bubble" style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}">
|
||||||
class="bubble"
|
|
||||||
style="background:{colorOf(msg) + '33'}; color:{colorOf(msg)}"
|
|
||||||
>
|
|
||||||
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)}
|
{#each renderContent(msg.content) as seg (msg.id + ':' + seg.text)}
|
||||||
{#if seg.mention}
|
{#if seg.mention}
|
||||||
<button class="mention" onclick={() => insertMention(seg.text.slice(1))}
|
<button class="mention" onclick={() => insertMention(seg.text.slice(1))}
|
||||||
>{seg.text}</button
|
>{seg.text}</button
|
||||||
>
|
>
|
||||||
{:else if seg.url}
|
{: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}
|
{:else}
|
||||||
{seg.text}
|
{seg.text}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -181,20 +242,34 @@
|
|||||||
|
|
||||||
<div class="chat-composer">
|
<div class="chat-composer">
|
||||||
{#if error}<div class="chat-error">{error}</div>{/if}
|
{#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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="chat-input"
|
class="chat-input"
|
||||||
placeholder="Message the family…"
|
placeholder="Message the family…"
|
||||||
bind:value={draft}
|
bind:value={draft}
|
||||||
oninput={onInput}
|
oninput={onComposerInput}
|
||||||
onkeydown={(e) => {
|
onkeydown={onComposerKeydown}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
send();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
|
<button class="chat-send" onclick={send} disabled={sending || !draft.trim()} aria-label="Send">
|
||||||
{@html sendIcon}
|
{@html sendIcon}
|
||||||
</button>
|
</button>
|
||||||
@@ -355,6 +430,65 @@
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
align-items: center;
|
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 {
|
.chat-error {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4.5rem;
|
bottom: 4.5rem;
|
||||||
|
|||||||
@@ -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 = {
|
export type ChatActor = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'admin' | 'member';
|
type: 'admin' | 'member';
|
||||||
@@ -5,19 +7,33 @@ export type ChatActor = {
|
|||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everyone in the family (children + parents) for the @mention picker.
|
||||||
export async function chatMe(pb: any, famId: string, actor: ChatActor) {
|
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(
|
export async function send(
|
||||||
pb: any,
|
pb: any,
|
||||||
famId: string,
|
famId: string,
|
||||||
actor: ChatActor,
|
actor: ChatActor,
|
||||||
body: { content?: string; clientId?: string }
|
body: { content?: string; clientId?: string; siteUrl?: string }
|
||||||
) {
|
) {
|
||||||
const content = (body.content || '').trim();
|
const content = (body.content || '').trim();
|
||||||
if (!content) throw new Error('content required');
|
if (!content) throw new Error('content required');
|
||||||
return pb.collection('messages').create({
|
const record = await pb.collection('messages').create({
|
||||||
famId,
|
famId,
|
||||||
authorType: actor.type,
|
authorType: actor.type,
|
||||||
authorId: actor.id,
|
authorId: actor.id,
|
||||||
@@ -27,14 +43,50 @@ export async function send(
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
|
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,
|
pb: any,
|
||||||
famId: string,
|
famId: string,
|
||||||
actor: ChatActor,
|
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({
|
const existing = await pb.collection('chat_typing').getFullList({
|
||||||
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
|
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export function createServices(
|
|||||||
crudSvc.create(pb, resource, famId, data),
|
crudSvc.create(pb, resource, famId, data),
|
||||||
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
|
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
|
||||||
crudSvc.update(pb, resource, famId, id, data),
|
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: {
|
chores: {
|
||||||
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid),
|
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid),
|
||||||
@@ -57,7 +58,8 @@ export function createServices(
|
|||||||
completions: {
|
completions: {
|
||||||
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
|
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
|
||||||
completionsSvc.toggle(pb, famId, uid, body),
|
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: {
|
rewards: {
|
||||||
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
|
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
|
||||||
@@ -78,8 +80,7 @@ export function createServices(
|
|||||||
},
|
},
|
||||||
complete: (famId: string, configId: string) =>
|
complete: (famId: string, configId: string) =>
|
||||||
pb.collection('bonus_configs').update(configId, { status: 'completed' }),
|
pb.collection('bonus_configs').update(configId, { status: 'completed' }),
|
||||||
destroy: (famId: string, configId: string) =>
|
destroy: (famId: string, configId: string) => pb.collection('bonus_configs').delete(configId)
|
||||||
pb.collection('bonus_configs').delete(configId)
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
get: (famId: string) => settingsSvc.getSettings(pb, famId),
|
get: (famId: string) => settingsSvc.getSettings(pb, famId),
|
||||||
@@ -88,8 +89,11 @@ export function createServices(
|
|||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
|
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
|
||||||
send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) =>
|
send: (
|
||||||
chatSvc.send(pb, famId, actor, body),
|
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 }) =>
|
typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) =>
|
||||||
chatSvc.typing(pb, famId, actor, body)
|
chatSvc.typing(pb, famId, actor, body)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { pb } from '$lib/pocketbase';
|
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 {
|
interface ChatInit {
|
||||||
famId: string;
|
famId: string;
|
||||||
@@ -8,7 +9,7 @@ interface ChatInit {
|
|||||||
actorName: string;
|
actorName: string;
|
||||||
actorColor: string;
|
actorColor: string;
|
||||||
pbToken?: string;
|
pbToken?: string;
|
||||||
|
members?: MentionMember[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
|
// Client id for optimistic chat messages. `crypto.randomUUID()` requires a
|
||||||
@@ -34,6 +35,7 @@ class ChatStore {
|
|||||||
actorName = $state('');
|
actorName = $state('');
|
||||||
actorColor = $state('');
|
actorColor = $state('');
|
||||||
pbToken = $state('');
|
pbToken = $state('');
|
||||||
|
members = $state<MentionMember[]>([]);
|
||||||
|
|
||||||
private unsubs: (() => void)[] = [];
|
private unsubs: (() => void)[] = [];
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
@@ -80,6 +82,7 @@ class ChatStore {
|
|||||||
this.actorName = opts.actorName;
|
this.actorName = opts.actorName;
|
||||||
this.actorColor = opts.actorColor;
|
this.actorColor = opts.actorColor;
|
||||||
this.pbToken = opts.pbToken || '';
|
this.pbToken = opts.pbToken || '';
|
||||||
|
this.members = opts.members || [];
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
this.typing = {};
|
this.typing = {};
|
||||||
this.unread = 0;
|
this.unread = 0;
|
||||||
@@ -160,9 +163,24 @@ class ChatStore {
|
|||||||
if (record.authorId !== this.actorId) {
|
if (record.authorId !== this.actorId) {
|
||||||
if (!this.open) this.unread++;
|
if (!this.open) this.unread++;
|
||||||
this.playDunk();
|
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) {
|
private onTyping(action: string, record: TypingRow) {
|
||||||
if (record.famId !== this.famId) return;
|
if (record.famId !== this.famId) return;
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
@@ -310,6 +328,7 @@ class ChatStore {
|
|||||||
for (const t of this.typingTimers.values()) clearTimeout(t);
|
for (const t of this.typingTimers.values()) clearTimeout(t);
|
||||||
this.typingTimers.clear();
|
this.typingTimers.clear();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.members = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export interface Member {
|
|||||||
updated: string;
|
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 {
|
export interface ChoreTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
famId: string;
|
famId: string;
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ export async function load(event) {
|
|||||||
const pbToken = event.cookies.get('pb_token') || '';
|
const pbToken = event.cookies.get('pb_token') || '';
|
||||||
|
|
||||||
let famId = '';
|
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 fam: any = null;
|
||||||
let famAccess = {
|
let famAccess = {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
|||||||
@@ -170,7 +170,8 @@
|
|||||||
actorType: chat.actor.type,
|
actorType: chat.actor.type,
|
||||||
actorName: chat.actor.name,
|
actorName: chat.actor.name,
|
||||||
actorColor: chat.actor.color || '#6366f1',
|
actorColor: chat.actor.color || '#6366f1',
|
||||||
pbToken: page.data.pbToken || ''
|
pbToken: page.data.pbToken || '',
|
||||||
|
members: chat.members || []
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export async function POST(event: RequestEvent) {
|
|||||||
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
|
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
|
||||||
: await s.chat.send(famId, actor, {
|
: await s.chat.send(famId, actor, {
|
||||||
content: body.content || '',
|
content: body.content || '',
|
||||||
clientId: body.clientId || ''
|
clientId: body.clientId || '',
|
||||||
|
siteUrl: event.url.origin
|
||||||
});
|
});
|
||||||
return json(data);
|
return json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user