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
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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user