Nexo notifications — global toast + title badge for messages off-thread

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/<matchId>`. Stack capped
  at 3, auto-dismisses after 5.5 s. Per-toast close button.
- A `(<n>) 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) <noreply@anthropic.com>
master
dev 5 months ago
parent 869a9b320b
commit 287d7dc4e7

@ -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<MessageToastEntry[]>([]);
let unreadByMatch = $state<Record<string, number>>({});
let matchCache = new Map<string, DatingMatch>();
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 @@
</main>
</div>
<MessageToast {toasts} dismiss={dismissToast} />
<style>
.nexo-shell {
min-height: 100vh;

@ -0,0 +1,159 @@
<!--
Floating toast for incoming chat messages received while the user
is not on that conversation. Stacks up to a small max so a chatty
counterpart doesn't fill the screen, and each toast auto-dismisses
after a few seconds.
Lives in the layout: the realtime subscription decides when to
queue a toast and gives us a typed list. The component itself is
pure presentation — it doesn't know about the network.
-->
<script lang="ts">
import { goto } from '$app/navigation';
export interface MessageToastEntry {
readonly id: string;
readonly matchId: string;
readonly title: string;
readonly body: string;
}
interface Props {
toasts: MessageToastEntry[];
dismiss: (id: string) => void;
}
let { toasts, dismiss }: Props = $props();
async function open(toast: MessageToastEntry) {
dismiss(toast.id);
await goto(`/dating/chat/${toast.matchId}`);
}
</script>
{#if toasts.length > 0}
<div class="toast-stack" aria-live="polite" aria-label="Notificaciones">
{#each toasts as toast (toast.id)}
<button type="button" class="toast" onclick={() => open(toast)}>
<span class="ico" aria-hidden="true">✉</span>
<span class="meta">
<strong>{toast.title}</strong>
<small>{toast.body}</small>
</span>
<span
class="close"
aria-label="Descartar"
onclick={(event) => {
event.stopPropagation();
dismiss(toast.id);
}}
role="button"
tabindex="0"
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
dismiss(toast.id);
}
}}
>×</span>
</button>
{/each}
</div>
{/if}
<style>
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 60;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
.toast {
pointer-events: auto;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
align-items: center;
min-width: 16rem;
max-width: 24rem;
padding: 0.7rem 0.85rem;
border-radius: 12px;
background: canvas;
color: inherit;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
box-shadow: 0 8px 24px color-mix(in srgb, currentColor 12%, transparent);
font: inherit;
text-align: left;
cursor: pointer;
animation: slide-in 0.18s ease-out;
}
.toast:hover {
border-color: color-mix(in srgb, #c44a78 50%, transparent);
}
.toast:focus-visible {
outline: 2px solid #c44a78;
outline-offset: 2px;
}
.ico {
width: 32px;
height: 32px;
border-radius: 999px;
background: color-mix(in srgb, #c44a78 18%, canvas);
color: #c44a78;
display: grid;
place-items: center;
font-size: 1rem;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.meta strong {
font-size: 0.92rem;
}
.meta small {
font-size: 0.82rem;
opacity: 0.78;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.close {
font-size: 1.1rem;
opacity: 0.55;
cursor: pointer;
padding: 0 0.25rem;
}
.close:hover {
opacity: 1;
}
@keyframes slide-in {
from {
transform: translateY(-6px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 600px) {
.toast-stack {
top: auto;
bottom: 5rem; /* clear the AppNav bottom-tab bar */
left: 1rem;
right: 1rem;
}
.toast {
min-width: 0;
max-width: 100%;
}
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.