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
+1
View File
@@ -34,6 +34,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** |
| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) |
| GL | Simultaneous quick-game cap (10): grey "New Game" + a lobby notice at the cap; backend gate on quick enqueue + invitation creation (409 `game_limit_reached`), accepting invitations exempt; `at_game_limit` rides `games.list` | owner ad-hoc | **done** |
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
+5 -1
View File
@@ -41,7 +41,11 @@ is exempt), and the `games.list` response carries an `at_game_limit` flag for th
`internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. `internal/account`
including obfuscated forms) and stored with the sender's IP. Each message carries an
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
clears a nudge when its recipient moves — both record the publish-to-read latency.
`internal/account`
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
development log mailer). The engine now also handles **multi-player drop-out**: in
a 34 player game a resignation or timeout drops that seat and the rest play on
+2
View File
@@ -169,6 +169,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path.
games.SetNudgeClearer(socialSvc.ClearNudges)
feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub)
+2 -1
View File
@@ -35,7 +35,8 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
@@ -0,0 +1,28 @@
{{define "content" -}}
{{with .Data}}
<h1>Message</h1>
<nav class="subnav"><a href="/_gm/messages">&laquo; messages</a> · <a href="/_gm/games/{{.GameID}}">game</a> · <a href="/_gm/messages?game={{.GameID}}">game messages</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Time</b> {{.CreatedAt}}</li>
<li><b>Sender</b> <a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>IP</b> {{.IP}}</li>
<li><b>Kind</b> {{.Kind}}</li>
<li><b>Read</b> {{if .Unread}}unread{{else}}read{{end}}</li>
<li><b>Message</b> {{.Body}}</li>
</ul>
</section>
<section class="panel"><h2>Read by seat</h2>
<table class="list">
<thead><tr><th>Seat</th><th>Player</th><th>Status</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}</a></td><td>{{if eq .Role "read"}}<span class="ok">read</span>{{else if eq .Role "unread"}}unread{{else}}sender{{end}}</td></tr>
{{else}}
<tr><td colspan="3"><span class="note">no seats</span></td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -6,6 +6,7 @@
{{if .UserID}}<input type="hidden" name="user" value="{{.UserID}}">{{end}}
<input name="name" value="{{.NameMask}}" placeholder="sender name mask (* ?)">
<input name="ext" value="{{.ExtMask}}" placeholder="sender external id mask (* ?)">
<label class="check"><input type="checkbox" name="unread" value="1"{{if .UnreadOnly}} checked{{end}}> unread only</label>
<button type="submit">Filter</button>
<a class="export" href="/_gm/messages.csv?{{.FilterQuery}}">Export CSV ↓</a>
</form>
@@ -13,19 +14,20 @@
<p class="note">Filtered{{if .GameID}} to game <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a>{{end}}{{if .UserID}} from <a href="/_gm/users/{{.UserID}}">sender</a>{{end}} · <a href="/_gm/messages">clear</a></p>
{{end}}
<table class="list">
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Game</th></tr></thead>
<thead><tr><th>Time</th><th>Source</th><th>Sender</th><th>IP</th><th>Message</th><th>Read</th><th>Game</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.CreatedAt}}</td>
<td><a href="/_gm/messages/{{.ID}}">{{.CreatedAt}}</a></td>
<td>{{.Source}}</td>
<td><a href="/_gm/users/{{.SenderID}}">{{.SenderName}}</a></td>
<td>{{.IP}}</td>
<td>{{.Body}}</td>
<td>{{if .Unread}}unread{{else}}read{{end}}</td>
<td><a href="/_gm/games/{{.GameID}}">game</a></td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no messages</span></td></tr>
<tr><td colspan="7"><span class="note">no messages</span></td></tr>
{{end}}
</tbody>
</table>
+29 -1
View File
@@ -94,11 +94,13 @@ type MessagesView struct {
ExtMask string
GameID string
UserID string
UnreadOnly bool
FilterQuery template.URL
}
// MessageRow is one chat message in the moderation list: its sender (linked to the user
// card), source, IP, body, game (linked to the game card) and time.
// card), source, IP, body, game (linked to the game card), time, and whether it is still
// unread by at least one recipient.
type MessageRow struct {
ID string
SenderID string
@@ -108,6 +110,32 @@ type MessageRow struct {
Body string
GameID string
CreatedAt string
Unread bool
}
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
// console message card.
type ChatMessageDetailView struct {
ID string
GameID string
SenderID string
SenderName string
Source string
Kind string
Body string
IP string
CreatedAt string
Unread bool
Seats []ChatSeatStatusRow
}
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
type ChatSeatStatusRow struct {
Seat int
AccountID string
DisplayName string
Role string
}
// UserDetailView is one account with its stats, identities and recent games.
+21 -2
View File
@@ -45,8 +45,12 @@ type Service struct {
// its asynchronous TriggerMove); nil disables the fast path (the scan still covers
// these games). Kept as a func so the game package never imports the robot package.
aiTrigger func(gameID uuid.UUID)
metrics *gameMetrics
log *zap.Logger
// clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package.
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -89,6 +93,14 @@ func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) {
svc.aiTrigger = trigger
}
// SetNudgeClearer installs the hook that marks a mover's pending nudges read after
// their move commits. It must be called during startup wiring; the default (nil)
// leaves nudges to be cleared only when the recipient opens the move history or chat.
// The social package wires its ClearNudges here.
func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, accountID uuid.UUID) error) {
svc.clearNudges = fn
}
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
// installed, so callers can invoke it unconditionally after a create or commit.
@@ -567,6 +579,13 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
return MoveResult{}, err
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// A nudge the actor answered by moving stops counting as unread (best-effort; the
// move has committed, so a cleanup failure is logged, not surfaced).
if svc.clearNudges != nil {
if err := svc.clearNudges(ctx, gameID, accountID); err != nil {
svc.log.Warn("clear nudges after move", zap.Error(err))
}
}
// Record the seat's think time (turn start to commit) for the move-duration
// metric; the timeout path commits separately and is excluded by design.
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
+168
View File
@@ -0,0 +1,168 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/social"
)
// TestChatUnreadAndMarkRead checks a text message marks every recipient seat unread (not the
// sender), surfaces through HasUnread / UnreadGames, and that MarkRead clears the reader's bit.
func TestChatUnreadAndMarkRead(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", ""); err != nil {
t.Fatalf("post: %v", err)
}
// The opponent has it unread; the sender does not.
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); !unread {
t.Error("recipient should have the message unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("sender should not have their own message unread")
}
if set, err := svc.UnreadGames(ctx, seats[1]); err != nil {
t.Fatalf("unread games: %v", err)
} else if !set[gameID] {
t.Error("UnreadGames should include the game for the recipient")
}
// Reading it clears the recipient's bit and reports one entry marked.
if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
} else if n != 1 {
t.Errorf("marked %d, want 1", n)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("message should be read after MarkRead")
}
// A second MarkRead is a no-op — nothing is left unread.
if n, _ := svc.MarkRead(ctx, gameID, seats[1]); n != 0 {
t.Errorf("second mark read marked %d, want 0", n)
}
}
// TestNudgeUnreadTargetsOnlyToMove checks, in a three-player game, that a nudge marks ONLY the
// awaited (to-move) seat unread — never the other waiting players. This is the owner's nudge
// targeting invariant, now observable through the unread bitmask.
func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move
// A waiting player nudges; only the awaited seat 0 is targeted.
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Error("the awaited seat should have the nudge unread")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread {
t.Error("a non-awaited waiting seat must not receive the nudge")
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[1]); unread {
t.Error("the nudging sender must not have it unread")
}
}
// TestTextMessageUnreadReachesAllOpponents checks a text message in a three-player game marks both
// opponents (every seated player but the sender) unread.
func TestTextMessageUnreadReachesAllOpponents(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 3) // seat 0 is to move and may chat
if _, err := svc.PostMessage(ctx, gameID, seats[0], "hello all", ""); err != nil {
t.Fatalf("post: %v", err)
}
for _, s := range []int{1, 2} {
if unread, _ := svc.HasUnread(ctx, gameID, seats[s]); !unread {
t.Errorf("seat %d should have the text message unread", s)
}
}
if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the sender should not have their own message unread")
}
}
// TestNudgeClearedByMove checks the move path clears a nudge the actor answered by moving: the
// game service's wired NudgeClearer flips the awaited seat's nudge to read on commit.
func TestNudgeClearedByMove(t *testing.T) {
ctx := context.Background()
gameSvc, gameID, seats, play := newDraftGame(t)
socialSvc := newSocialService()
gameSvc.SetNudgeClearer(socialSvc.ClearNudges)
// The waiting seat 1 nudges the to-move seat 0.
if _, err := socialSvc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); !unread {
t.Fatal("seat 0 should have the nudge unread before moving")
}
// Seat 0 answers by moving — the commit clears its pending nudge.
if _, err := gameSvc.SubmitPlay(ctx, gameID, seats[0], play.Tiles); err != nil {
t.Fatalf("submit play: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, gameID, seats[0]); unread {
t.Error("the nudge should be cleared once the awaited player has moved")
}
}
// TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read
// breakdown of the message card.
func TestAdminChatUnreadFilterAndDetail(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
msg, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "")
if err != nil {
t.Fatalf("post: %v", err)
}
// The unread-only filter (scoped to the game) lists the still-unread message.
if items, err := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); err != nil {
t.Fatalf("admin list: %v", err)
} else if len(items) != 1 || items[0].ID != msg.ID {
t.Fatalf("unread filter = %+v, want the one message", items)
}
// The detail card marks the sender 'sender' and the recipient 'unread'.
card, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail: %v", err)
}
roles := map[uuid.UUID]string{}
for _, s := range card.Seats {
roles[s.AccountID] = s.Role
}
if roles[seats[0]] != "sender" {
t.Errorf("sender role = %q, want sender", roles[seats[0]])
}
if roles[seats[1]] != "unread" {
t.Errorf("recipient role = %q, want unread", roles[seats[1]])
}
// After the recipient reads, the filter drops it and the seat flips to 'read'.
if _, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil {
t.Fatalf("mark read: %v", err)
}
if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: gameID, UnreadOnly: true}, 50, 0); len(items) != 0 {
t.Errorf("unread filter should be empty after read, got %d", len(items))
}
card2, err := svc.AdminMessageDetail(ctx, msg.ID)
if err != nil {
t.Fatalf("detail after read: %v", err)
}
for _, s := range card2.Seats {
if s.AccountID == seats[1] && s.Role != "read" {
t.Errorf("recipient role after read = %q, want read", s.Role)
}
}
}
@@ -13,11 +13,12 @@ import (
)
type ChatMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
UnreadSeats int16
}
@@ -17,13 +17,14 @@ type chatMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UnreadSeats postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +66,31 @@ func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTabl
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UnreadSeatsColumn = postgres.IntegerColumn("unread_seats")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn, UnreadSeatsColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn, UnreadSeatsColumn}
)
return chatMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
UnreadSeats: UnreadSeatsColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,20 @@
-- +goose Up
-- A per-message bitmask of the seats that have NOT yet read this chat entry: bit i is
-- set while seat i still has the message unread, and is cleared when that seat reads it
-- (opens the move history / chat, or — for a nudge — takes its move). A text message
-- starts with the bits of every seated recipient except the sender; a nudge starts with
-- only the awaited player's bit. The mask is inverted so "anything unread" is a plain
-- `unread_seats <> 0`, which the lobby badge, the admin filter and the unread gauge all
-- use. The read time itself is not retained. See docs/ARCHITECTURE.md §8 (Read receipts).
SET search_path = backend, pg_catalog;
ALTER TABLE chat_messages ADD COLUMN unread_seats smallint NOT NULL DEFAULT 0;
-- Partial index serving the unread scans (per-viewer lobby lookup, admin unread filter,
-- the unread-count gauge): only the comparatively few still-unread rows are indexed.
CREATE INDEX chat_messages_unread_idx ON chat_messages (game_id) WHERE unread_seats <> 0;
-- +goose Down
SET search_path = backend, pg_catalog;
DROP INDEX chat_messages_unread_idx;
ALTER TABLE chat_messages DROP COLUMN unread_seats;
+4
View File
@@ -108,6 +108,10 @@ type gameDTO struct {
// game, the finish time once finished.
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []seatDTO `json:"seats"`
// UnreadChat is a per-viewer flag: the requesting player has at least one unread chat
// entry (message or nudge) in this game. It seeds the lobby and in-game unread badge.
// gameDTOFromGame leaves it false (it has no viewer); the handlers fill it.
UnreadChat bool `json:"unread_chat"`
}
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
+1
View File
@@ -96,6 +96,7 @@ func (s *Server) registerRoutes() {
if s.social != nil {
u.POST("/games/:id/chat", s.handleChatPost)
u.GET("/games/:id/chat", s.handleChatList)
u.POST("/games/:id/chat/read", s.handleChatRead)
u.POST("/games/:id/nudge", s.handleNudge)
u.GET("/friends", s.handleListFriends)
u.GET("/friends/incoming", s.handleIncomingRequests)
@@ -3,6 +3,7 @@ package server
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"html/template"
"net/http"
@@ -82,6 +83,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
}
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/messages/:id", s.consoleChatMessageDetail)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/upload", s.consoleUploadDictionary)
gm.POST("/dictionary/install", s.consoleInstallDictionary)
@@ -187,10 +189,11 @@ func (s *Server) consoleMessages(c *gin.Context) {
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
total, _ := s.social.AdminCountMessages(ctx, filter)
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
@@ -211,10 +214,14 @@ func (s *Server) consoleMessages(c *gin.Context) {
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
if filter.UnreadOnly {
q.Set("unread", "1")
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
UnreadOnly: filter.UnreadOnly,
FilterQuery: template.URL(q.Encode()),
}
if filter.GameID != uuid.Nil {
@@ -227,12 +234,41 @@ func (s *Server) consoleMessages(c *gin.Context) {
view.Items = append(view.Items, adminconsole.MessageRow{
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
Source: m.Source, IP: m.SenderIP, Body: m.Body,
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt),
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), Unread: m.UnreadSeats != 0,
})
}
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// consoleChatMessageDetail renders one chat message with the per-seat read breakdown of
// its game (who has read it, who has not).
func (s *Server) consoleChatMessageDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/messages")
if !ok {
return
}
card, err := s.social.AdminMessageDetail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, social.ErrMessageNotFound) {
s.renderConsoleMessage(c, "Not found", "no such message", "/_gm/messages")
return
}
s.consoleError(c, err)
return
}
view := adminconsole.ChatMessageDetailView{
ID: card.ID.String(), GameID: card.GameID.String(), SenderID: card.SenderID.String(),
SenderName: card.SenderName, Source: card.Source, Kind: card.Kind, Body: card.Body,
IP: card.SenderIP, CreatedAt: fmtTime(card.CreatedAt), Unread: card.UnreadSeats != 0,
}
for _, st := range card.Seats {
view.Seats = append(view.Seats, adminconsole.ChatSeatStatusRow{
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: st.DisplayName, Role: st.Role,
})
}
s.renderConsole(c, "chatmessage", "messages", "Message", view)
}
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
const adminMessagesExportCap = 100000
@@ -243,10 +279,11 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
if err != nil {
@@ -256,12 +293,16 @@ func (s *Server) consoleMessagesCSV(c *gin.Context) {
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"})
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id", "read"})
for _, m := range items {
read := "read"
if m.UnreadSeats != 0 {
read = "unread"
}
// The sender name and message body are user-controlled; defuse spreadsheet formula
// injection so a moderator opening the export can't trigger a formula.
_ = w.Write([]string{
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(),
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), read,
})
}
w.Flush()
+41
View File
@@ -439,10 +439,16 @@ func (s *Server) handleListGames(c *gin.Context) {
return
}
memo := map[string]string{}
// One query seeds the lobby's per-card unread badge for every listed game.
var unread map[uuid.UUID]bool
if s.social != nil {
unread, _ = s.social.UnreadGames(c.Request.Context(), uid)
}
out := make([]gameDTO, 0, len(games))
for _, g := range games {
dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo)
dto.UnreadChat = unread[g.ID]
out = append(out, dto)
}
c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit})
@@ -480,6 +486,22 @@ func (s *Server) handleNudge(c *gin.Context) {
c.JSON(http.StatusOK, chatDTOFrom(msg))
}
// handleChatRead marks every chat entry the caller has not yet read in the game as read
// — the acknowledgement the client sends when the player opens the move history or chat
// — and records each entry's publish-to-read latency. The client calls it only when it
// believes something is unread, so the backend is not hit on every history open.
func (s *Server) handleChatRead(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
if _, err := s.social.MarkRead(c.Request.Context(), gameID, uid); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// userGame reads the authenticated account and the :id game param, aborting with the
// right status when either is missing. ok is false when the request was aborted.
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
@@ -504,5 +526,24 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
if uid, ok := userID(c); ok {
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
}
c.JSON(http.StatusOK, dto)
}
// setUnreadChat fills the per-game unread-chat flag for viewer uid on one game's DTO,
// when the social domain is present. It is best-effort: a lookup error leaves the flag
// false (a missing badge) rather than failing the surrounding game response.
func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) {
if s.social == nil {
return
}
gid, err := uuid.Parse(g.ID)
if err != nil {
return
}
if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil {
g.UnreadChat = unread
}
}
+1
View File
@@ -130,6 +130,7 @@ func (s *Server) handleGameState(c *gin.Context) {
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
s.setUnreadChat(c.Request.Context(), &dto.Game, uid)
c.JSON(http.StatusOK, dto)
}
+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()
}
+20 -5
View File
@@ -100,7 +100,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err := Clean(body); err != nil {
return Message{}, err
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID))
if err != nil {
return Message{}, err
}
@@ -148,7 +148,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
return Message{}, ErrNudgeTooSoon
}
}
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil, int16(1)<<uint(toMove))
if err != nil {
return Message{}, err
}
@@ -258,8 +258,22 @@ func parseIP(raw string) *string {
return &canon
}
// insertChatMessage stores one chat row and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (Message, error) {
// recipientMask is the initial unread bitmask for a text message: a set bit for every
// seated recipient (by seat index) other than the sender. Still-empty (open) seats are
// skipped. With at most four seats the result always fits the smallint column.
func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 {
var mask int16
for i, id := range seats {
if id == uuid.Nil || id == senderID {
continue
}
mask |= int16(1) << uint(i)
}
return mask
}
// insertChatMessage stores one chat row, seeding its unread bitmask, and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string, unreadMask int16) (Message, error) {
id, err := uuid.NewV7()
if err != nil {
return Message{}, fmt.Errorf("social: new message id: %w", err)
@@ -271,7 +285,8 @@ func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUI
stmt := table.ChatMessages.INSERT(
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
).VALUES(id, gameID, senderID, kind, body, ipVal).
table.ChatMessages.UnreadSeats,
).VALUES(id, gameID, senderID, kind, body, ipVal, unreadMask).
RETURNING(table.ChatMessages.AllColumns)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+173
View File
@@ -0,0 +1,173 @@
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
}
+32 -2
View File
@@ -2,6 +2,7 @@ package social
import (
"context"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
@@ -15,7 +16,8 @@ const meterName = "scrabble/backend/social"
// no-ops (see defaultSocialMetrics); SetMetrics installs the real meter during
// startup wiring.
type socialMetrics struct {
messages metric.Int64Counter
messages metric.Int64Counter
readDuration metric.Float64Histogram
}
// defaultSocialMetrics returns instruments backed by a no-op meter.
@@ -31,7 +33,13 @@ func newSocialMetrics(meter metric.Meter) *socialMetrics {
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("chat_messages_total")
}
return &socialMetrics{messages: c}
h, err := meter.Float64Histogram("chat_read_duration",
metric.WithDescription("Time from a chat entry being posted to a recipient reading it, in seconds, labelled by kind (message/nudge)."),
metric.WithUnit("s"))
if err != nil {
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("chat_read_duration")
}
return &socialMetrics{messages: c, readDuration: h}
}
// SetMetrics installs the meter the social domain records to. It must be called
@@ -41,9 +49,31 @@ func (svc *Service) SetMetrics(meter metric.Meter) {
return
}
svc.metrics = newSocialMetrics(meter)
// chat_unread_messages observes the current backlog of unread chat entries at scrape
// time (the partial index keeps the count cheap). A registration error is ignored —
// the gauge is best-effort observability.
_, _ = meter.Int64ObservableGauge("chat_unread_messages",
metric.WithDescription("Chat entries with at least one recipient seat that has not read them yet."),
metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
n, err := svc.store.countUnread(ctx)
if err != nil {
return err
}
o.Observe(n)
return nil
}))
}
// recordChat counts one posted chat entry of the given kind (message or nudge).
func (m *socialMetrics) recordChat(ctx context.Context, kind string) {
m.messages.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
}
// recordRead records one chat entry's publish-to-read latency, labelled by kind. A
// non-positive duration (clock skew) is dropped.
func (m *socialMetrics) recordRead(ctx context.Context, kind string, d time.Duration) {
if d <= 0 {
return
}
m.readDuration.Record(ctx, d.Seconds(), metric.WithAttributes(attribute.String("kind", kind)))
}
+9
View File
@@ -13,11 +13,16 @@ import (
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// tracerName scopes the social domain's OpenTelemetry spans.
const tracerName = "scrabble/backend/social"
// GameReader is the slice of the game domain the social package needs: the seated
// accounts in seat order, the seat index whose turn it is, and the game status, plus
// a shared-game test. game.Service satisfies it, so chat, nudge and the
@@ -71,6 +76,8 @@ var (
ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired")
// ErrNotParticipant is returned when an account is not seated in the game.
ErrNotParticipant = errors.New("social: account is not a player in this game")
// ErrMessageNotFound is returned when an admin message lookup finds no such message.
ErrMessageNotFound = errors.New("social: message not found")
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
ErrChatBlocked = errors.New("social: chat is disabled for this account")
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
@@ -104,6 +111,7 @@ type Service struct {
games GameReader
pub notify.Publisher
metrics *socialMetrics
tracer trace.Tracer
now func() time.Time
}
@@ -116,6 +124,7 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
games: games,
pub: notify.Nop{},
metrics: defaultSocialMetrics(),
tracer: otel.Tracer(tracerName),
now: func() time.Time { return time.Now().UTC() },
}
}
+19 -3
View File
@@ -514,11 +514,24 @@ disguised robot stays indistinguishable from a person.
has a **Messages** section that lists posted messages (nudges excluded)
newest-first with the sender's resolved name, **source** (guest / robot / oldest
identity kind), IP and game, searchable by sender name / external-id glob masks and
pinnable to one game or sender (linked from the game and user cards).
pinnable to one game or sender (linked from the game and user cards). It also offers an
**unread-only filter** and a read/unread column, and each message has a detail card with
the **per-seat read breakdown** (sender / read / unread).
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery runs through the gateway and the platform
side-service.
- **Read receipts**: each `chat_messages` row carries an `unread_seats` bitmask — a set
bit per recipient seat that has **not** yet read it (the sender's own bit is never set).
A text message seeds the bits of every seated recipient; a nudge seeds only the awaited
player's. A seat's bit clears when that player **opens the move history or the chat**
(`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
history open is not a constant backend call), and a **nudge additionally clears when its
recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is
inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
`unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
unread filter and the unread gauge all use. On each clear the publish-to-read latency is
recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
block toggles — all editable through `account.UpdateProfile`, which validates them:
@@ -551,7 +564,8 @@ disguised robot stays indistinguishable from a person.
`game_moves` (the move journal), `complaints` and `account_stats`, and the
social/lobby tables `friendships` (the request/accept graph, its status admitting
`declined`), `blocks`
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
(per-user blocks), `chat_messages` (per-game chat and nudges, carrying the per-message
`unread_seats` read bitmask), `email_confirmations`
(pending confirm-codes), `game_invitations` / `game_invitation_invitees`
(friend-game invitations), `friend_codes` (one-time add-a-friend codes),
`game_drafts` (a player's in-progress rack order + board composition per
@@ -743,7 +757,9 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
whose synthetic timing dominates the tail, so per-human analysis lives in the admin
console, below); counters `games_started_total`, `games_abandoned_total` (a
turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
`robot_games_finished_total`; an observable gauge `game_cache_active`; the gateway
`robot_games_finished_total`; a histogram `chat_read_duration` (chat publish-to-read
latency by `kind`); observable gauges `game_cache_active` and `chat_unread_messages`
(chat entries with `unread_seats <> 0`); the gateway
`edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
(scrabble_en/scrabble_ru/erudit_ru).
+5 -2
View File
@@ -191,8 +191,11 @@ is awaited at most once per hour (the
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
via the platform) **name the nudger** ("<opponent>: waiting for your move").
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat
message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened.
tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat
entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game
in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
also clears the moment its recipient **takes their move**.
### Profile & settings
Edit the display name (letters joined by a single space / "." / "_" separator, with an
+5 -2
View File
@@ -196,8 +196,11 @@ nudge) приходят от бота **этой партии** — по язы
и внеприложенческий push (доставляется через платформу) **называют отправителя**
(«<соперник>: жду вашего хода»).
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата —
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
его получатель **делает ход**.
### Профиль и настройки
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
+1 -1
View File
@@ -52,7 +52,7 @@ out-of-app push to that connector for recipients with no live in-app stream
The message-type catalog: `auth.telegram`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post` and the play-loop ops;
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
live events
`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found` (the game events —
and `game_over`/`notify` — carry the state delta the client applies without a `game.state`
+8
View File
@@ -134,6 +134,7 @@ type GameResp struct {
EndReason string `json:"end_reason"`
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []SeatResp `json:"seats"`
UnreadChat bool `json:"unread_chat"`
}
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
@@ -468,6 +469,13 @@ func (c *Client) Nudge(ctx context.Context, userID, gameID string) (ChatResp, er
return out, err
}
// MarkChatRead acknowledges that the caller has read the game's chat (sent when they open
// the move history or chat), so the backend clears their unread bits and records the
// publish-to-read latency.
func (c *Client) MarkChatRead(ctx context.Context, userID, gameID string) error {
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/chat/read"), userID, "", struct{}{}, nil)
}
// GamesList returns the caller's active and finished games.
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
var out GameListResp
+1
View File
@@ -438,6 +438,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
EndReason: g.EndReason,
Seats: seats,
LastActivityUnix: g.LastActivityUnix,
UnreadChat: g.UnreadChat,
}
}
+14
View File
@@ -40,6 +40,7 @@ const (
MsgGameHistory = "game.history"
MsgChatList = "chat.list"
MsgChatNudge = "chat.nudge"
MsgChatRead = "chat.read"
MsgDraftGet = "draft.get"
MsgDraftSave = "draft.save"
MsgGameHide = "game.hide"
@@ -121,6 +122,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
r.ops[MsgChatRead] = Op{Handler: markChatReadHandler(backend), Auth: true}
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
@@ -451,6 +453,18 @@ func nudgeHandler(backend *backendclient.Client) Handler {
}
}
// markChatReadHandler acknowledges the caller has read the game's chat. It reuses
// GameActionRequest for the game id and echoes an Ack.
func markChatReadHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
if err := backend.MarkChatRead(ctx, req.UserID, string(in.GameId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// getDraftHandler returns the player's saved composition. It reuses
// GameActionRequest for the game id and wraps the backend's raw JSON in a DraftView.
func getDraftHandler(backend *backendclient.Client) Handler {
+37 -1
View File
@@ -213,6 +213,39 @@ func TestHideGameForwardsToBackend(t *testing.T) {
}
}
// TestMarkChatReadForwardsToBackend checks chat.read reuses GameActionRequest, POSTs to the
// game's /chat/read endpoint with the caller's id, and echoes an Ack.
func TestMarkChatReadForwardsToBackend(t *testing.T) {
var hit bool
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
hit = true
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/user/games/g-1/chat/read" {
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
}
if got := r.Header.Get("X-User-ID"); got != "u-1" {
t.Errorf("X-User-ID = %q, want u-1", got)
}
_, _ = w.Write([]byte(`{"ok":true}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgChatRead)
if !ok {
t.Fatal("chat.read not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")})
if err != nil {
t.Fatalf("handler: %v", err)
}
if !hit {
t.Error("backend not called")
}
if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() {
t.Error("ack not ok")
}
}
func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-9" {
@@ -221,7 +254,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`))
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}],"at_game_limit":true}`))
})
defer cleanup()
@@ -243,6 +276,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
if g.LastActivityUnix() != 1717000000 {
t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix())
}
if !g.UnreadChat() {
t.Error("unread_chat = false, want true (the backend flagged unread for the viewer)")
}
var seat fb.SeatView
g.Seats(&seat, 1)
if string(seat.DisplayName()) != "Ann" {
+3
View File
@@ -73,6 +73,9 @@ table GameView {
// vs_ai marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are disabled in the client. A robot-filled random game keeps vs_ai=false.
vs_ai:bool;
// unread_chat is a per-viewer flag: the requesting player has at least one unread chat
// entry (message or nudge) in this game. It drives the lobby and in-game unread badge.
unread_chat:bool;
}
// MoveRecord is one decoded move (a committed play, or a hint preview).
+16 -1
View File
@@ -185,8 +185,20 @@ func (rcv *GameView) MutateVsAi(n bool) bool {
return rcv._tab.MutateBoolSlot(28, n)
}
func (rcv *GameView) UnreadChat() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *GameView) MutateUnreadChat(n bool) bool {
return rcv._tab.MutateBoolSlot(30, n)
}
func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(13)
builder.StartObject(14)
}
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
@@ -230,6 +242,9 @@ func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix
func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) {
builder.PrependBoolSlot(12, vsAi, false)
}
func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) {
builder.PrependBoolSlot(13, unreadChat, false)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+3
View File
@@ -44,6 +44,8 @@ type GameView struct {
LastActivityUnix int64
// VsAI marks an honest-AI game (the opponent is shown as 🤖, chat/nudge/add-friend off).
VsAI bool
// UnreadChat is a per-viewer flag: the requesting player has unread chat in this game.
UnreadChat bool
}
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
@@ -161,6 +163,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.GameViewAddSeats(b, seats)
fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix)
fb.GameViewAddVsAi(b, g.VsAI)
fb.GameViewAddUnreadChat(b, g.UnreadChat)
return fb.GameViewEnd(b)
}
+2 -2
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, clearChatUnread } from '../lib/app.svelte';
import { app, handleError, markChatRead } from '../lib/app.svelte';
import { canSendChat, sentThisTurn } from '../lib/chatlimit';
import type { ChatMessage, StateView } from '../lib/model';
@@ -50,7 +50,7 @@
async function refresh() {
try {
messages = await gateway.chatList(id);
clearChatUnread(id);
markChatRead(id);
} catch {
/* best-effort */
}
+46 -20
View File
@@ -8,7 +8,7 @@
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
@@ -71,6 +71,19 @@
// landscape, where it is docked open in the left panel. Gates the per-seat add-friend
// affordance and its confirm reset so both work regardless of layout.
const historyShown = $derived(historyOpen || landscape);
// A nonce bumped to replay the 💬 fade-blink each time the history is shown with unread chat.
let chatBlink = $state(0);
// Opening the move history counts as reading the chat (even without entering it): when the
// history is shown with unread present, play the 💬 fade-blink (a two-cycle nudge) and mark the
// chat read. In landscape the history is docked open, so a message arriving while it is visible
// blinks and is read at once. markChatRead clears the flag (and acks the backend), so this
// settles after one pass and re-fires only when a new message raises unread again.
$effect(() => {
if (historyShown && app.chatUnread[id]) {
chatBlink++;
markChatRead(id);
}
});
const variant = $derived(view?.game.variant ?? 'scrabble_en');
const board = $derived(replay(moves));
@@ -154,6 +167,8 @@
gateway.draftGet(id).catch(() => ''),
]);
view = st;
// Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat);
moves = hist.moves;
setCachedGame(id, st, hist.moves, draft);
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
@@ -601,6 +616,9 @@
// post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) {
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
// moving is already cleared server-side, so reconcile the unread flag from it.
seedChatUnread(id, r.game.unreadChat);
moves = [...moves, r.move];
// A committed move clears the actor's draft on the server, so clear the cached draft too;
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
@@ -937,7 +955,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{seatName(s)}</div>
@@ -970,7 +988,7 @@
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button>
</div>
<div class="hgridwrap">
@@ -1378,26 +1396,34 @@
.fico {
line-height: 1;
}
/* The unread-chat count: on the score bar's corner and on the history's 💬 icon. */
.cbadge {
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
when the history is opened with unread present, rather than carrying a count badge. */
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.sbadge-dot {
position: absolute;
font-size: 0.68rem;
font-weight: 700;
background: var(--accent);
color: var(--accent-text);
border-radius: 999px;
min-width: 15px;
padding: 0 3px;
line-height: 1.4;
text-align: center;
top: 4px;
right: 6px;
}
.sbadge {
top: 2px;
right: 4px;
.chat-ico {
display: inline-block;
line-height: 1;
}
.hicon .cbadge {
top: -1px;
right: -1px;
.chat-ico.blink {
animation: chat-blink 1s ease-in-out 2;
}
@keyframes chat-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.loading {
text-align: center;
+12 -2
View File
@@ -103,8 +103,13 @@ vsAi():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
unreadChat():boolean {
const offset = this.bb!.__offset(this.bb_pos, 30);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(13);
builder.startObject(14);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -171,12 +176,16 @@ static addVsAi(builder:flatbuffers.Builder, vsAi:boolean) {
builder.addFieldInt8(12, +vsAi, +false);
}
static addUnreadChat(builder:flatbuffers.Builder, unreadChat:boolean) {
builder.addFieldInt8(13, +unreadChat, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean):flatbuffers.Offset {
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
@@ -191,6 +200,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addSeats(builder, seatsOffset);
GameView.addLastActivityUnix(builder, lastActivityUnix);
GameView.addVsAi(builder, vsAi);
GameView.addUnreadChat(builder, unreadChat);
return GameView.endGameView(builder);
}
}
+36 -11
View File
@@ -61,8 +61,10 @@ export const app = $state<{
localeLocked: boolean;
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number;
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>;
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
* game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
* raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
chatUnread: Record<string, boolean>;
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
@@ -149,9 +151,29 @@ export function dismissStaleInvite(): void {
app.staleInvite = false;
}
/** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
/**
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
* stream raises unread (bumpChatUnread) rather than seeding it from a GameView.
*/
export function seedChatUnread(gameId: string, unread: boolean): void {
if ((app.chatUnread[gameId] ?? false) !== unread) {
app.chatUnread = { ...app.chatUnread, [gameId]: unread };
}
}
/**
* markChatRead clears a game's unread badge and tells the backend the player has read the chat —
* called when the move history or the chat opens. It hits the backend only when something is
* actually unread, so opening the history does not call the backend on every tap. The clear is
* optimistic; a failed ack restores the badge so a later open retries.
*/
export function markChatRead(gameId: string): void {
if (!app.chatUnread[gameId]) return;
app.chatUnread = { ...app.chatUnread, [gameId]: false };
void gateway.markChatRead(gameId).catch(() => {
app.chatUnread = { ...app.chatUnread, [gameId]: true };
});
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
@@ -220,15 +242,18 @@ function openStream(): void {
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
router.route.params.id === e.message.gameId;
if (!inComms) {
if (e.message.kind !== 'nudge') {
const gid = e.message.gameId;
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
}
app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true };
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
}
} else if (e.kind === 'nudge') {
// Name the nudger (their per-game seat name, carried on the event), so the toast reads
// "<opponent>: …" like the your-turn toast; fall back to the plain phrase when absent.
// A nudge only reaches the awaited player; raise their unread badge for the game (unless
// they are already in its chat), then toast — naming the nudger (their per-game seat name,
// carried on the event), so it reads "<opponent>: …" like the your-turn toast; fall back to
// the plain phrase when absent.
const inComms =
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
router.route.params.id === e.gameId;
if (!inComms) app.chatUnread = { ...app.chatUnread, [e.gameId]: true };
showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
// Name the player who moved just before this one (the previous seat in turn order), so the
+3
View File
@@ -102,6 +102,9 @@ export interface GatewayClient {
chatPost(gameId: string, body: string): Promise<ChatMessage>;
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
/** Acknowledge the caller has read the game's chat (sent when they open the move history
* or chat), so the backend clears their unread bits and records the read latency. */
markChatRead(gameId: string): Promise<void>;
// --- feedback ---
/** feedbackSubmit sends a feedback message with an optional single attachment. */
+2
View File
@@ -237,6 +237,7 @@ describe('codec', () => {
fb.GameView.addSeats(b, seats);
fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
fb.GameView.addVsAi(b, true);
fb.GameView.addUnreadChat(b, true);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
@@ -251,6 +252,7 @@ describe('codec', () => {
expect(gl.games[0].seats[0].score).toBe(13);
expect(gl.games[0].lastActivityUnix).toBe(1717000000);
expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true);
expect(gl.atGameLimit).toBe(true);
});
+2
View File
@@ -248,6 +248,7 @@ function decodeGameView(g: fb.GameView): GameView {
endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()),
vsAi: g.vsAi(),
unreadChat: g.unreadChat(),
seats,
};
}
@@ -813,6 +814,7 @@ function emptyGame(): GameView {
endReason: '',
lastActivityUnix: 0,
vsAi: false,
unreadChat: false,
seats: [],
};
}
+1
View File
@@ -8,6 +8,7 @@ function gameView(id: string): GameView {
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'active',
players: 2,
toMove: 0,
+1
View File
@@ -9,6 +9,7 @@ function gameView(moveCount: number, over = false): GameView {
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: over ? 'finished' : 'active',
players: 2,
toMove: 1,
+1
View File
@@ -25,6 +25,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status,
players: 2,
toMove,
+1
View File
@@ -18,6 +18,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status,
players: 2,
toMove,
+6
View File
@@ -180,6 +180,7 @@ export class MockGateway implements GatewayClient {
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: true,
unreadChat: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
@@ -211,6 +212,7 @@ export class MockGateway implements GatewayClient {
endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000),
vsAi: false,
unreadChat: false,
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
@@ -487,6 +489,10 @@ export class MockGateway implements GatewayClient {
this.feedbackReplyUnread = true;
this.emit({ kind: 'notify', sub: 'admin_reply' });
}
// The mock holds no server-side unread state; the read ack is a no-op (the client clears its
// own unread flag optimistically).
async markChatRead(): Promise<void> {}
async nudge(gameId: string): Promise<ChatMessage> {
const g = this.game(gameId);
const msg: ChatMessage = {
+3
View File
@@ -140,6 +140,7 @@ function activeGame(): MockGame {
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'active',
players: 2,
toMove: 0,
@@ -176,6 +177,7 @@ function finishedG2(): MockGame {
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'finished',
players: 2,
toMove: 0,
@@ -213,6 +215,7 @@ function finishedG3(): MockGame {
variant: 'scrabble_ru',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status: 'finished',
players: 2,
toMove: 0,
+4
View File
@@ -47,6 +47,10 @@ export interface GameView {
lastActivityUnix: number;
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
vsAi: boolean;
/** Per-viewer flag: the requesting player has at least one unread chat entry (message or
* nudge) in this game. Set on the authoritative REST views (lobby list, game state,
* move result); the live-event GameView leaves it false (events bump unread instead). */
unreadChat: boolean;
seats: Seat[];
}
+1
View File
@@ -20,6 +20,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status,
players: 2,
toMove: 0,
+1
View File
@@ -17,6 +17,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
status,
players: seats.length,
toMove,
+3
View File
@@ -143,6 +143,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async nudge(id) {
return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id)));
},
async markChatRead(id) {
await exec('chat.read', codec.encodeGameAction(id));
},
async feedbackSubmit(body, attachment, attachmentName, channel) {
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
},
+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);