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 @@ + +