From 287d7dc4e7f01e2c03bc33e38882d4df7d893a36 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 21:43:20 +0200 Subject: [PATCH] =?UTF-8?q?Nexo=20notifications=20=E2=80=94=20global=20toa?= =?UTF-8?q?st=20+=20title=20badge=20for=20messages=20off-thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a `dating.message.created` push arrives and the user is not on the matching chat thread, the layout now surfaces: - A floating toast (top-right on desktop, bottom on mobile so it clears the bottom-tab bar) showing the counterpart's name and the body preview, with click → `/dating/chat/`. Stack capped at 3, auto-dismisses after 5.5 s. Per-toast close button. - A `() Nexo` document title with the total unread count across matches, so the user notices the new message even when the tab is not focused. Self-sent broadcasts are filtered (the server fans out to every match member, including the sender, so multi-device syncs work; the sender's own UI doesn't toast itself). Visiting a chat clears the unread count for that match. `MessageToast.svelte` is pure presentation; the layout owns the realtime subscription, the toast queue, the per-match unread counts and the cached match list (used to fill in the counterpart name on the toast title). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/web/routes/dating/+layout.svelte | 109 +++++++++++- .../dating/_components/MessageToast.svelte | 159 ++++++++++++++++++ 2 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/web/routes/dating/_components/MessageToast.svelte diff --git a/src/web/routes/dating/+layout.svelte b/src/web/routes/dating/+layout.svelte index 52b6994..f051260 100644 --- a/src/web/routes/dating/+layout.svelte +++ b/src/web/routes/dating/+layout.svelte @@ -4,7 +4,8 @@ import { createDatingApp } from './_lib/app'; import { setNexoContext } from './_lib/context'; import { connectDatingRealtime, type DatingRealtimeHandle } from './_lib/realtime'; - import type { DatingSessionResponse } from './_lib/types'; + import type { DatingMatch, DatingSessionResponse } from './_lib/types'; + import MessageToast, { type MessageToastEntry } from './_components/MessageToast.svelte'; let { children } = $props(); @@ -59,6 +60,110 @@ void refreshSession(); }); + // ── Global notifications ───────────────────────────────────────────── + // + // The realtime distributor pushes every `dating.message.created` + // event to every match member, including the sender. The chat + // page handles its own thread; for everything else (matches list, + // discover, profile, etc.) we render a floating toast and bump a + // document-title badge so the user notices a new message without + // hunting through the matches tab. + const TOAST_TTL_MS = 5500; + let toasts = $state([]); + let unreadByMatch = $state>({}); + let matchCache = new Map(); + let toastSeq = 0; + + const baseTitle = 'Nexo'; + const totalUnread = $derived( + Object.values(unreadByMatch).reduce((acc, count) => acc + count, 0) + ); + + $effect(() => { + if (typeof document !== 'undefined') { + document.title = totalUnread > 0 ? `(${totalUnread}) ${baseTitle}` : baseTitle; + } + }); + + // Re-prime the match cache once the user has authenticated so + // toast titles can carry the counterpart's name. Failures are + // silent — the toast still works without a name. + $effect(() => { + if (session?.authenticated !== true) return; + void api + .matches() + .then((list) => { + matchCache = new Map(list.map((entry) => [entry.id, entry])); + }) + .catch(() => {}); + }); + + $effect(() => { + const current = session; + if (current?.authenticated !== true) return; + const off = ensureRealtime().subscribe((event) => { + if (event.type !== 'dating.message.created') return; + const message = event.message; + if (current.authenticated !== true) return; + if (message.senderId === current.user.id) return; + const onThisChat = + page.url.pathname === `/dating/chat/${message.matchId}`; + if (onThisChat) return; + + // Resolve counterpart name from the cached match (best + // effort; fall back to a generic title). + const match = matchCache.get(message.matchId); + const counterpart = + match?.profiles.find((profile) => profile.userId !== current.user.id) ?? null; + const title = counterpart?.displayName || 'Nuevo mensaje'; + + const id = `t-${++toastSeq}`; + toasts = [ + ...toasts, + { + id, + matchId: message.matchId, + title, + body: message.body + } + ].slice(-3); + unreadByMatch = { + ...unreadByMatch, + [message.matchId]: (unreadByMatch[message.matchId] ?? 0) + 1 + }; + + // Refresh the match cache opportunistically so the next + // toast for an unknown match has a name. + if (match === undefined) { + void api + .matches() + .then((list) => { + matchCache = new Map(list.map((entry) => [entry.id, entry])); + }) + .catch(() => {}); + } + + window.setTimeout(() => dismissToast(id), TOAST_TTL_MS); + }); + return off; + }); + + // Visiting a chat clears its unread count. + $effect(() => { + const path = page.url.pathname; + const match = path.match(/^\/dating\/chat\/([^/]+)/); + if (match === null) return; + const matchId = match[1]; + if ((unreadByMatch[matchId] ?? 0) === 0) return; + const next = { ...unreadByMatch }; + delete next[matchId]; + unreadByMatch = next; + }); + + function dismissToast(id: string) { + toasts = toasts.filter((entry) => entry.id !== id); + } + onDestroy(() => { realtime?.dispose(); realtime = null; @@ -143,6 +248,8 @@ + +