feat(chat): unread read-receipts with lobby/game dot and history-open ack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s

Persist per-message read state as a chat_messages.unread_seats bitmask
(migration 00008): a text message seeds every recipient seat's bit, a nudge
only the awaited seat's. A seat's bit clears when the player opens the move
history or chat (POST /games/:id/chat/read, sent only when something is
unread), and a nudge additionally clears when its recipient answers by moving
(a wired game NudgeClearer, dependency-inverted so game keeps off social).

UI shows a per-viewer unread dot in the lobby (next to the opponent) and the
game score bar — the unread_chat game-view flag seeds it from authoritative
REST views, live chat/nudge events raise it. Opening the move history counts
as reading (even without entering chat): the 💬 fade-blinks twice and the
client acks. Admin Messages gains an unread-only filter, a read/unread column,
and a per-message card with the per-seat read breakdown. Observability:
chat_read_duration histogram + chat_unread_messages gauge + social tracing.
This commit is contained in:
Ilia Denisov
2026-06-17 11:12:38 +02:00
parent d53ff18a67
commit aaac816dc2
51 changed files with 1000 additions and 111 deletions
+22 -2
View File
@@ -3,7 +3,7 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
@@ -33,6 +33,9 @@
try {
const list = await gateway.gamesList();
games = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat);
atGameLimit = list.atGameLimit;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
@@ -262,7 +265,10 @@
onclick={() => openGame(g)}
>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="who">
<span class="who-name">{opponents(g) || '—'}</span>
{#if app.chatUnread[g.id]}<span class="unread-dot"></span>{/if}
</span>
<span class="sub">{scoreline(g)}</span>
</span>
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
@@ -441,11 +447,25 @@
min-width: 0;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: 600;
}
.who-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A small red dot beside the opponent name: this game has an unread chat entry. */
.unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);