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);