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