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
+92 -7
View File
@@ -2,6 +2,8 @@ package social
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -23,16 +25,21 @@ type AdminMessage struct {
Body string
SenderIP string
CreatedAt time.Time
// UnreadSeats is the message's unread bitmask; non-zero means at least one recipient
// has not read it. The console shows a plain read/unread flag derived from it.
UnreadSeats int16
}
// AdminMessageFilter narrows the admin message list. A nil GameID/SenderID leaves that
// field unfiltered; NameMask/ExtMask are glob masks (account.LikePattern) matched
// case-insensitively against the sender's display name / any identity's external id.
// case-insensitively against the sender's display name / any identity's external id;
// UnreadOnly keeps only messages still unread by at least one recipient.
type AdminMessageFilter struct {
GameID uuid.UUID
SenderID uuid.UUID
NameMask string
ExtMask string
GameID uuid.UUID
SenderID uuid.UUID
NameMask string
ExtMask string
UnreadOnly bool
}
// AdminListMessages returns the filtered chat messages — real messages only, nudges
@@ -75,12 +82,15 @@ func adminMessageWhere(f AdminMessageFilter) (string, []any) {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities ie WHERE ie.account_id = a.account_id AND ie.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
if f.UnreadOnly {
where += ` AND m.unread_seats <> 0`
}
return where, args
}
func (s *Store) adminListMessages(ctx context.Context, f AdminMessageFilter, limit, offset int) ([]AdminMessage, error) {
where, args := adminMessageWhere(f)
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at
q := `SELECT m.message_id, m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.body, COALESCE(m.sender_ip, ''), m.created_at, m.unread_seats
FROM backend.chat_messages m
JOIN backend.accounts a ON a.account_id = m.sender_id
WHERE ` + where +
@@ -94,7 +104,7 @@ WHERE ` + where +
var out []AdminMessage
for rows.Next() {
var m AdminMessage
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt); err != nil {
if err := rows.Scan(&m.ID, &m.GameID, &m.SenderID, &m.SenderName, &m.Source, &m.Body, &m.SenderIP, &m.CreatedAt, &m.UnreadSeats); err != nil {
return nil, fmt.Errorf("social: scan admin message: %w", err)
}
out = append(out, m)
@@ -111,3 +121,78 @@ func (s *Store) adminCountMessages(ctx context.Context, f AdminMessageFilter) (i
}
return n, nil
}
// AdminSeatRead is one game seat's status for a message's per-seat read breakdown: the
// seat index, its occupant's display name, and its role for the message — "sender",
// "read" or "unread". Still-empty (open) seats are omitted.
type AdminSeatRead struct {
Seat int
AccountID uuid.UUID
DisplayName string
Role string
}
// AdminMessageCard is one chat message with the per-seat read breakdown, for the
// console's message detail page.
type AdminMessageCard struct {
AdminMessage
Kind string
Seats []AdminSeatRead
}
// AdminMessageDetail loads one chat message and the read status of every seated player
// in its game, for the moderation message card. The per-seat status is derived from the
// message's unread bitmask (a set bit is an unread recipient seat); the sender's own
// seat is marked "sender".
func (svc *Service) AdminMessageDetail(ctx context.Context, messageID uuid.UUID) (AdminMessageCard, error) {
return svc.store.adminMessageCard(ctx, messageID)
}
func (s *Store) adminMessageCard(ctx context.Context, messageID uuid.UUID) (AdminMessageCard, error) {
var d AdminMessageCard
q := `SELECT m.game_id, m.sender_id, a.display_name, ` + adminMessageSource + ` AS source, m.kind, m.body, COALESCE(m.sender_ip, ''), m.created_at, m.unread_seats
FROM backend.chat_messages m
JOIN backend.accounts a ON a.account_id = m.sender_id
WHERE m.message_id = $1`
if err := s.db.QueryRowContext(ctx, q, messageID).Scan(
&d.GameID, &d.SenderID, &d.SenderName, &d.Source, &d.Kind, &d.Body, &d.SenderIP, &d.CreatedAt, &d.UnreadSeats,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return AdminMessageCard{}, ErrMessageNotFound
}
return AdminMessageCard{}, fmt.Errorf("social: admin message card: %w", err)
}
d.ID = messageID
rows, err := s.db.QueryContext(ctx,
`SELECT p.seat, COALESCE(p.account_id::text, ''), p.display_name
FROM backend.game_players p WHERE p.game_id = $1 ORDER BY p.seat`, d.GameID)
if err != nil {
return AdminMessageCard{}, fmt.Errorf("social: admin message seats: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
seat int
accStr string
seatNam string
)
if err := rows.Scan(&seat, &accStr, &seatNam); err != nil {
return AdminMessageCard{}, fmt.Errorf("social: scan message seat: %w", err)
}
if accStr == "" {
continue // a still-empty (open) seat has no reader
}
accID, _ := uuid.Parse(accStr)
sr := AdminSeatRead{Seat: seat, AccountID: accID, DisplayName: seatNam}
switch {
case accID == d.SenderID:
sr.Role = "sender"
case d.UnreadSeats&(int16(1)<<uint(seat)) != 0:
sr.Role = "unread"
default:
sr.Role = "read"
}
d.Seats = append(d.Seats, sr)
}
return d, rows.Err()
}