From aaac816dc251e7d089c2fe5c19fca22f92a1c1a7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 11:12:38 +0200 Subject: [PATCH 1/3] feat(chat): unread read-receipts with lobby/game dot and history-open ack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PRERELEASE.md | 1 + backend/README.md | 6 +- backend/cmd/backend/main.go | 2 + backend/internal/adminconsole/render_test.go | 3 +- .../templates/pages/chatmessage.gohtml | 28 +++ .../templates/pages/messages.gohtml | 8 +- backend/internal/adminconsole/views.go | 30 ++- backend/internal/game/service.go | 23 ++- backend/internal/inttest/chatread_test.go | 168 +++++++++++++++++ .../jet/backend/model/chat_messages.go | 15 +- .../jet/backend/table/chat_messages.go | 51 +++--- .../postgres/migrations/00008_chat_unread.sql | 20 ++ backend/internal/server/dto.go | 4 + backend/internal/server/handlers.go | 1 + .../internal/server/handlers_admin_console.go | 63 +++++-- backend/internal/server/handlers_game.go | 41 +++++ backend/internal/server/handlers_user.go | 1 + backend/internal/social/adminchat.go | 99 +++++++++- backend/internal/social/chat.go | 25 ++- backend/internal/social/chatread.go | 173 ++++++++++++++++++ backend/internal/social/metrics.go | 34 +++- backend/internal/social/social.go | 9 + docs/ARCHITECTURE.md | 22 ++- docs/FUNCTIONAL.md | 7 +- docs/FUNCTIONAL_ru.md | 7 +- gateway/README.md | 2 +- gateway/internal/backendclient/api.go | 8 + gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/transcode.go | 14 ++ gateway/internal/transcode/transcode_test.go | 38 +++- pkg/fbs/scrabble.fbs | 3 + pkg/fbs/scrabblefb/GameView.go | 17 +- pkg/wire/build.go | 3 + ui/src/game/ChatScreen.svelte | 4 +- ui/src/game/Game.svelte | 66 +++++-- ui/src/gen/fbs/scrabblefb/game-view.ts | 14 +- ui/src/lib/app.svelte.ts | 47 +++-- ui/src/lib/client.ts | 3 + ui/src/lib/codec.test.ts | 2 + ui/src/lib/codec.ts | 2 + ui/src/lib/gamecache.test.ts | 1 + ui/src/lib/gamedelta.test.ts | 1 + ui/src/lib/lobbycache.test.ts | 1 + ui/src/lib/lobbysort.test.ts | 1 + ui/src/lib/mock/client.ts | 6 + ui/src/lib/mock/data.ts | 3 + ui/src/lib/model.ts | 4 + ui/src/lib/preload.test.ts | 1 + ui/src/lib/result.test.ts | 1 + ui/src/lib/transport.ts | 3 + ui/src/screens/Lobby.svelte | 24 ++- 51 files changed, 1000 insertions(+), 111 deletions(-) create mode 100644 backend/internal/adminconsole/templates/pages/chatmessage.gohtml create mode 100644 backend/internal/inttest/chatread_test.go create mode 100644 backend/internal/postgres/migrations/00008_chat_unread.sql create mode 100644 backend/internal/social/chatread.go diff --git a/PRERELEASE.md b/PRERELEASE.md index 5edf58b..709c373 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -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) diff --git a/backend/README.md b/backend/README.md index a3f9b70..400c56f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 3–4 player game a resignation or timeout drops that seat and the rest play on diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a925348..05cd6c1 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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) diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index f7bee46..3d2952a 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -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"}, diff --git a/backend/internal/adminconsole/templates/pages/chatmessage.gohtml b/backend/internal/adminconsole/templates/pages/chatmessage.gohtml new file mode 100644 index 0000000..e74e71c --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/chatmessage.gohtml @@ -0,0 +1,28 @@ +{{define "content" -}} +{{with .Data}} +

Message

+ +

Summary

+ +
+

Read by seat

+ + + +{{range .Seats}} + +{{else}} + +{{end}} + +
SeatPlayerStatus
{{.Seat}}{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AccountID}}{{end}}{{if eq .Role "read"}}read{{else if eq .Role "unread"}}unread{{else}}sender{{end}}
no seats
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/messages.gohtml b/backend/internal/adminconsole/templates/pages/messages.gohtml index 917079b..e1cfdda 100644 --- a/backend/internal/adminconsole/templates/pages/messages.gohtml +++ b/backend/internal/adminconsole/templates/pages/messages.gohtml @@ -6,6 +6,7 @@ {{if .UserID}}{{end}} + Export CSV ↓ @@ -13,19 +14,20 @@

Filtered{{if .GameID}} to game {{.GameID}}{{end}}{{if .UserID}} from sender{{end}} · clear

{{end}} - + {{range .Items}} - + + {{else}} - + {{end}}
TimeSourceSenderIPMessageGame
TimeSourceSenderIPMessageReadGame
{{.CreatedAt}}{{.CreatedAt}} {{.Source}} {{.SenderName}} {{.IP}} {{.Body}}{{if .Unread}}unread{{else}}read{{end}} game
no messages
no messages
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 12c3fba..9b822d0 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -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. diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index f91e56b..3053a25 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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)) diff --git a/backend/internal/inttest/chatread_test.go b/backend/internal/inttest/chatread_test.go new file mode 100644 index 0000000..8d32d9c --- /dev/null +++ b/backend/internal/inttest/chatread_test.go @@ -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) + } + } +} diff --git a/backend/internal/postgres/jet/backend/model/chat_messages.go b/backend/internal/postgres/jet/backend/model/chat_messages.go index b909695..4e1951c 100644 --- a/backend/internal/postgres/jet/backend/model/chat_messages.go +++ b/backend/internal/postgres/jet/backend/model/chat_messages.go @@ -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 } diff --git a/backend/internal/postgres/jet/backend/table/chat_messages.go b/backend/internal/postgres/jet/backend/table/chat_messages.go index 97ab351..970bc0d 100644 --- a/backend/internal/postgres/jet/backend/table/chat_messages.go +++ b/backend/internal/postgres/jet/backend/table/chat_messages.go @@ -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, diff --git a/backend/internal/postgres/migrations/00008_chat_unread.sql b/backend/internal/postgres/migrations/00008_chat_unread.sql new file mode 100644 index 0000000..613dea6 --- /dev/null +++ b/backend/internal/postgres/migrations/00008_chat_unread.sql @@ -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; diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 7f1aa2f..22222e7 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -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 diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 1b2d86f..8b0099c 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 3ad776f..46b7e01 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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() diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index 71c4f7e..7aaec0f 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -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 + } +} diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 0e431f3..e15e041 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -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) } diff --git a/backend/internal/social/adminchat.go b/backend/internal/social/adminchat.go index e35d7a3..76b15e6 100644 --- a/backend/internal/social/adminchat.go +++ b/backend/internal/social/adminchat.go @@ -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)< 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 +} diff --git a/backend/internal/social/metrics.go b/backend/internal/social/metrics.go index 9971148..4f187c4 100644 --- a/backend/internal/social/metrics.go +++ b/backend/internal/social/metrics.go @@ -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))) +} diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 9f5f9eb..f571f30 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -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() }, } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e560887..c4f2ed7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1d67d9d..be38e7d 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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** (": 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 53239a4..0f618a7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -196,8 +196,11 @@ nudge) приходят от бота **этой партии** — по язы и внеприложенческий push (доставляется через платформу) **называют отправителя** («<соперник>: жду вашего хода»). Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, -открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует -**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата. +открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата — +сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в +**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже +без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда +его получатель **делает ход**. ### Профиль и настройки Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / diff --git a/gateway/README.md b/gateway/README.md index f2202b2..3a4ff56 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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` diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4c931d4..7717f20 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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 diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 40fdcca..3ab7f12 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -438,6 +438,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { EndReason: g.EndReason, Seats: seats, LastActivityUnix: g.LastActivityUnix, + UnreadChat: g.UnreadChat, } } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 978bd7f..b6c4f03 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -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 { diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index f474b9f..9b9e20d 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -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" { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 96226f9..bd9e440 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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). diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 8139daf..fd91d9d 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -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() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 146cab7..1a47b55 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -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) } diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 7855554..326d624 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -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 */ } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 6f4df4d..a11dd47 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 @@
- {#if (app.chatUnread[id] ?? 0) > 0}{app.chatUnread[id]}{/if} + {#if app.chatUnread[id]}{/if} {#each view.game.seats as s (s.seat)}
{seatName(s)}
@@ -970,7 +988,7 @@ {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if}
@@ -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; diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index 31ba398..cc80841 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -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); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6f3eccd..0dc571a 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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; + /** 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; /** 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 - // ": …" 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 ": …" 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 diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index cca4252..8013596 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -102,6 +102,9 @@ export interface GatewayClient { chatPost(gameId: string, body: string): Promise; chatList(gameId: string): Promise; nudge(gameId: string): Promise; + /** 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; // --- feedback --- /** feedbackSubmit sends a feedback message with an optional single attachment. */ diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 6eda7b8..e05b7b9 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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); }); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 9628b10..3184f92 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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: [], }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 375dfff..d3b0715 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -8,6 +8,7 @@ function gameView(id: string): GameView { variant: 'scrabble_en', dictVersion: 'v1', vsAi: false, + unreadChat: false, status: 'active', players: 2, toMove: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index b66c371..6881605 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -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, diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index 439d623..b1960c0 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -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, diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index f9066db..75763aa 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -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, diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 080aed3..41c5a6d 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 {} + async nudge(gameId: string): Promise { const g = this.game(gameId); const msg: ChatMessage = { diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index dadd4a0..3a3bac4 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -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, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index d17f2f5..ff7022a 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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[]; } diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index cb1cf11..fbcf9e2 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -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, diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 09d9b08..e0a00f9 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -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, diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 0cdc25f..e486391 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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)); }, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 0f2bb94..c6c175c 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -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)} > - {opponents(g) || '—'} + + {opponents(g) || '—'} + {#if app.chatUnread[g.id]}{/if} + {scoreline(g)} @@ -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); -- 2.52.0 From a7f3df93468e07eaefcaa6b9307127afee00aac2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 11:14:25 +0200 Subject: [PATCH 2/3] fix(chat): don't restore unread on a failed read-ack (would loop the in-game effect; REST re-seed self-heals) --- ui/src/lib/app.svelte.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 0dc571a..c1e529f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -166,14 +166,15 @@ export function seedChatUnread(gameId: string, unread: boolean): void { * 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. + * optimistic and the ack is best-effort: a failed ack leaves the server bit set, which the next + * authoritative REST view (lobby list / game state) re-seeds, so the badge self-heals — it is + * deliberately not restored here, since the in-game effect re-runs while the history is shown and + * a restore would loop on a persistently failing ack. */ 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 }; - }); + void gateway.markChatRead(gameId).catch(() => {}); } /** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity -- 2.52.0 From 20f2a5a011e0372b8c89158cbfaf318a9d84e385 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 11:46:59 +0200 Subject: [PATCH 3/3] feat(chat): a message to a disguised robot opponent is born read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pooled robot substituted into an ordinary (non-AI) game never opens the chat, so a text message to it would linger unread forever — skewing the unread count and the publish-to-read metric. Clear its recipient bit at PostMessage time (robotRecipients via account.IsRobot), so the message is born read. The human sender never had their own message unread, so this is invisible to them; a nudge to a robot already self-clears when the robot answers by moving. --- backend/internal/inttest/chatread_test.go | 41 +++++++++++++++++++++++ backend/internal/social/chat.go | 41 ++++++++++++++++++++--- docs/ARCHITECTURE.md | 4 ++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/backend/internal/inttest/chatread_test.go b/backend/internal/inttest/chatread_test.go index 8d32d9c..d8fa7ad 100644 --- a/backend/internal/inttest/chatread_test.go +++ b/backend/internal/inttest/chatread_test.go @@ -5,9 +5,13 @@ package inttest import ( "context" "testing" + "time" "github.com/google/uuid" + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" "scrabble/backend/internal/social" ) @@ -116,6 +120,43 @@ func TestNudgeClearedByMove(t *testing.T) { } } +// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled +// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the +// chat, so the message must not linger unread (skewing the count and the read metric). +func TestChatToRobotIsBornRead(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + human := provisionAccount(t) + robot, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-chat-"+uuid.NewString(), "Robbie") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + g, err := newGameService().Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, + TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + // The human (seat 0, to move) chats the disguised robot; the message is born read. + msg, err := svc.PostMessage(ctx, g.ID, human, "good luck", "") + if err != nil { + t.Fatalf("post: %v", err) + } + if items, _ := svc.AdminListMessages(ctx, social.AdminMessageFilter{GameID: g.ID, UnreadOnly: true}, 50, 0); len(items) != 0 { + t.Errorf("a message to a robot should be born read, but the unread filter listed %d", len(items)) + } + card, err := svc.AdminMessageDetail(ctx, msg.ID) + if err != nil { + t.Fatalf("detail: %v", err) + } + for _, s := range card.Seats { + if s.AccountID == robot.ID && s.Role != "read" { + t.Errorf("robot seat role = %q, want read", s.Role) + } + } +} + // TestAdminChatUnreadFilterAndDetail checks the admin unread-only filter and the per-seat read // breakdown of the message card. func TestAdminChatUnreadFilterAndDetail(t *testing.T) { diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 75400ec..9d07b88 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -100,7 +100,12 @@ 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), recipientMask(seats, senderID)) + // A disguised robot opponent never reads chat, so its recipient bit is born clear. + autoRead, err := svc.robotRecipients(ctx, seats, senderID) + if err != nil { + return Message{}, err + } + msg, err := svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP), recipientMask(seats, senderID, autoRead)) if err != nil { return Message{}, err } @@ -259,12 +264,14 @@ func parseIP(raw string) *string { } // 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 { +// seated recipient (by seat index) other than the sender, except recipients in autoRead — +// a disguised robot opponent, whose message is born read since it never opens the chat. +// 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, autoRead map[uuid.UUID]bool) int16 { var mask int16 for i, id := range seats { - if id == uuid.Nil || id == senderID { + if id == uuid.Nil || id == senderID || autoRead[id] { continue } mask |= int16(1) << uint(i) @@ -272,6 +279,30 @@ func recipientMask(seats []uuid.UUID, senderID uuid.UUID) int16 { return mask } +// robotRecipients returns the seated recipients (every seat but the sender) that are robots. +// A disguised robot opponent never reads chat, so a message to it is born read (its unread +// bit is never set) — it would otherwise linger unread forever, skewing the unread count and +// the publish-to-read metric. +func (svc *Service) robotRecipients(ctx context.Context, seats []uuid.UUID, senderID uuid.UUID) (map[uuid.UUID]bool, error) { + var robots map[uuid.UUID]bool + for _, id := range seats { + if id == uuid.Nil || id == senderID { + continue + } + isRobot, err := svc.accounts.IsRobot(ctx, id) + if err != nil { + return nil, err + } + if isRobot { + if robots == nil { + robots = make(map[uuid.UUID]bool) + } + robots[id] = true + } + } + return robots, nil +} + // 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() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4f2ed7..fa0f803 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -524,7 +524,9 @@ disguised robot stays indistinguishable from a person. - **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** + player's. A **disguised robot opponent's bit is never set** — it never opens the chat, so + a message to it is **born read** (it would otherwise linger unread forever); a nudge to a + robot instead clears when the robot answers by moving, as for a human. 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 -- 2.52.0