Compare commits
2 Commits
3f95d50d2e
...
097106fa69
| Author | SHA1 | Date | |
|---|---|---|---|
| 097106fa69 | |||
| 8a02640cdc |
@@ -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}
|
||||
<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();
|
||||
}
|
||||
}}
|
||||
disabled={sending}
|
||||
/>
|
||||
<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={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;
|
||||
|
||||
@@ -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}'`
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -13,5 +13,5 @@
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"version": "1.10.1"
|
||||
"version": "1.10.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user