aaac816dc2
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.
174 lines
6.5 KiB
Go
174 lines
6.5 KiB
Go
package social
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
// MarkRead clears viewerID's unread bit on every chat entry of the game they have not
|
|
// yet read — the acknowledgement the client sends when the player opens the move
|
|
// history (or the chat). For each entry that flips to read it records the
|
|
// publish-to-read latency. It returns the number of entries marked, and is a harmless
|
|
// no-op (zero) when the viewer holds no seat or has nothing unread, so the caller may
|
|
// invoke it without first checking participation.
|
|
func (svc *Service) MarkRead(ctx context.Context, gameID, viewerID uuid.UUID) (int, error) {
|
|
ctx, span := svc.tracer.Start(ctx, "social.MarkRead",
|
|
trace.WithAttributes(attribute.String("game.id", gameID.String())))
|
|
defer span.End()
|
|
marks, err := svc.store.markRead(ctx, gameID, viewerID)
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
return 0, err
|
|
}
|
|
now := svc.now()
|
|
for _, m := range marks {
|
|
svc.metrics.recordRead(ctx, m.kind, now.Sub(m.createdAt))
|
|
}
|
|
span.SetAttributes(attribute.Int("marked.count", len(marks)))
|
|
return len(marks), nil
|
|
}
|
|
|
|
// ClearNudges marks accountID's pending nudges in the game read once they have acted
|
|
// (taken their move): a nudge answered by moving stops counting as unread. It records
|
|
// the publish-to-read latency for each nudge it clears. It satisfies game.NudgeClearer
|
|
// and is wired into the move path; failures are the caller's to log (the move has
|
|
// already committed).
|
|
func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID) error {
|
|
ctx, span := svc.tracer.Start(ctx, "social.ClearNudges",
|
|
trace.WithAttributes(attribute.String("game.id", gameID.String())))
|
|
defer span.End()
|
|
times, err := svc.store.clearNudges(ctx, gameID, accountID)
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
return err
|
|
}
|
|
now := svc.now()
|
|
for _, t := range times {
|
|
svc.metrics.recordRead(ctx, kindNudge, now.Sub(t))
|
|
}
|
|
span.SetAttributes(attribute.Int("marked.count", len(times)))
|
|
return nil
|
|
}
|
|
|
|
// UnreadGames returns the set of games in which viewerID has at least one unread chat
|
|
// entry, for seeding the lobby's per-card unread badge in a single query.
|
|
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
|
return svc.store.unreadGames(ctx, viewerID)
|
|
}
|
|
|
|
// HasUnread reports whether viewerID has any unread chat entry in the game, for the
|
|
// per-game unread flag of a single game's state and move-result views.
|
|
func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
|
|
return svc.store.hasUnread(ctx, gameID, viewerID)
|
|
}
|
|
|
|
// readMark is one chat entry that flipped from unread to read, carrying what the
|
|
// publish-to-read latency metric needs.
|
|
type readMark struct {
|
|
kind string
|
|
createdAt time.Time
|
|
}
|
|
|
|
// markRead clears the viewer's seat bit on the game's entries still unread by them,
|
|
// resolving the seat through the game_players join, and returns the cleared entries.
|
|
// The bitwise terms are cast through int4 so the operators resolve unambiguously.
|
|
func (s *Store) markRead(ctx context.Context, gameID, viewerID uuid.UUID) ([]readMark, error) {
|
|
const q = `UPDATE backend.chat_messages m
|
|
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
|
FROM backend.game_players p
|
|
WHERE p.game_id = m.game_id AND p.account_id = $2
|
|
AND m.game_id = $1 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0
|
|
RETURNING m.kind, m.created_at`
|
|
rows, err := s.db.QueryContext(ctx, q, gameID, viewerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("social: mark read: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []readMark
|
|
for rows.Next() {
|
|
var m readMark
|
|
if err := rows.Scan(&m.kind, &m.createdAt); err != nil {
|
|
return nil, fmt.Errorf("social: scan mark read: %w", err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// clearNudges clears the actor's seat bit on the game's still-unread nudges and returns
|
|
// their post times (for the latency metric).
|
|
func (s *Store) clearNudges(ctx context.Context, gameID, accountID uuid.UUID) ([]time.Time, error) {
|
|
const q = `UPDATE backend.chat_messages m
|
|
SET unread_seats = (m.unread_seats::int & ~(1 << p.seat::int))::smallint
|
|
FROM backend.game_players p
|
|
WHERE p.game_id = m.game_id AND p.account_id = $2
|
|
AND m.game_id = $1 AND m.kind = 'nudge' AND (m.unread_seats::int & (1 << p.seat::int)) <> 0
|
|
RETURNING m.created_at`
|
|
rows, err := s.db.QueryContext(ctx, q, gameID, accountID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("social: clear nudges: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []time.Time
|
|
for rows.Next() {
|
|
var t time.Time
|
|
if err := rows.Scan(&t); err != nil {
|
|
return nil, fmt.Errorf("social: scan clear nudges: %w", err)
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// unreadGames returns the games where viewerID has an unread entry, resolving their
|
|
// seat per game through the game_players join.
|
|
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
|
|
const q = `SELECT DISTINCT m.game_id
|
|
FROM backend.chat_messages m
|
|
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $1
|
|
WHERE m.unread_seats <> 0 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0`
|
|
rows, err := s.db.QueryContext(ctx, q, viewerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("social: unread games: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make(map[uuid.UUID]bool)
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("social: scan unread games: %w", err)
|
|
}
|
|
out[id] = true
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// hasUnread reports whether viewerID has an unread entry in the one game.
|
|
func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) {
|
|
const q = `SELECT EXISTS(
|
|
SELECT 1 FROM backend.chat_messages m
|
|
JOIN backend.game_players p ON p.game_id = m.game_id AND p.account_id = $2
|
|
WHERE m.game_id = $1 AND (m.unread_seats::int & (1 << p.seat::int)) <> 0)`
|
|
var ok bool
|
|
if err := s.db.QueryRowContext(ctx, q, gameID, viewerID).Scan(&ok); err != nil {
|
|
return false, fmt.Errorf("social: has unread: %w", err)
|
|
}
|
|
return ok, nil
|
|
}
|
|
|
|
// countUnread counts the chat entries with at least one recipient seat still unread,
|
|
// for the chat_unread_messages gauge.
|
|
func (s *Store) countUnread(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM backend.chat_messages WHERE unread_seats <> 0`).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("social: count unread: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|