From 6e77de4c1e6ce97023ab9a2a2414c6fce29da43c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 16:50:48 +0200 Subject: [PATCH] feat: sparser robot nudges, typed unread badge, lobby unread bump Three owner-requested polish changes: - robot: replace the lengthening 60-90 min -> 6 h proactive-nudge ramp with a flat uniform 9-12 h wait before every nudge; the existing sleep-window gate still skips and defers a nudge that would land in the robot's night. - ui: colour the lobby/in-game unread dot by type -- the regular danger colour when a chat message is unread, a softer amber (--warn) when only nudges are. Adds a per-viewer unread_messages flag (chat_messages.kind='message') across the backend DTO, FlatBuffers wire, gateway transcode and the UI store. - ui: float games with any unread notification to the top of the lobby's your-turn and opponent-turn sections (finished keeps its order), reusing the existing unread_chat flag. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru) updated. No DB migration; the new wire field is backward-compatible. --- backend/internal/inttest/chatread_test.go | 22 ++++++++- backend/internal/inttest/robot_test.go | 20 ++++---- backend/internal/robot/driver.go | 12 ++--- backend/internal/robot/strategy.go | 34 +++++-------- backend/internal/robot/strategy_test.go | 20 +++++--- backend/internal/server/dto.go | 4 ++ backend/internal/server/handlers_game.go | 16 ++++-- backend/internal/social/chatread.go | 52 ++++++++++++++++++++ docs/ARCHITECTURE.md | 18 +++++-- docs/FUNCTIONAL.md | 13 +++-- docs/FUNCTIONAL_ru.md | 15 +++--- gateway/internal/backendclient/api.go | 1 + gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/transcode_test.go | 5 +- pkg/fbs/scrabble.fbs | 4 ++ pkg/fbs/scrabblefb/GameView.go | 17 ++++++- pkg/wire/build.go | 4 ++ ui/src/game/Game.svelte | 14 ++++-- ui/src/gen/fbs/scrabblefb/game-view.ts | 14 +++++- ui/src/lib/app.svelte.ts | 23 +++++++-- ui/src/lib/codec.ts | 2 + ui/src/lib/gamecache.test.ts | 1 + ui/src/lib/gamedelta.test.ts | 1 + ui/src/lib/lobbycache.test.ts | 1 + ui/src/lib/lobbysort.test.ts | 24 +++++++++ ui/src/lib/lobbysort.ts | 19 ++++--- ui/src/lib/mock/client.ts | 2 + ui/src/lib/mock/data.ts | 3 ++ ui/src/lib/model.ts | 4 ++ ui/src/lib/preload.test.ts | 1 + ui/src/lib/result.test.ts | 1 + ui/src/lib/unread.test.ts | 18 +++++++ ui/src/lib/unread.ts | 17 +++++++ ui/src/screens/Lobby.svelte | 14 ++++-- 34 files changed, 331 insertions(+), 86 deletions(-) create mode 100644 ui/src/lib/unread.test.ts create mode 100644 ui/src/lib/unread.ts diff --git a/backend/internal/inttest/chatread_test.go b/backend/internal/inttest/chatread_test.go index d8fa7ad..3b69423 100644 --- a/backend/internal/inttest/chatread_test.go +++ b/backend/internal/inttest/chatread_test.go @@ -16,7 +16,8 @@ import ( ) // 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. +// sender), surfaces through HasUnread / UnreadGames and the message-level HasUnreadMessage / +// UnreadMessageGames (it is a real message, not a nudge), and that MarkRead clears the reader's bit. func TestChatUnreadAndMarkRead(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -37,6 +38,15 @@ func TestChatUnreadAndMarkRead(t *testing.T) { } else if !set[gameID] { t.Error("UnreadGames should include the game for the recipient") } + // A text message also raises the message-level flags (it is not just a nudge). + if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[1]); !msg { + t.Error("recipient should have the message flagged as an unread message") + } + if set, err := svc.UnreadMessageGames(ctx, seats[1]); err != nil { + t.Fatalf("unread message games: %v", err) + } else if !set[gameID] { + t.Error("UnreadMessageGames should include the game for a text message") + } // Reading it clears the recipient's bit and reports one entry marked. if n, err := svc.MarkRead(ctx, gameID, seats[1]); err != nil { @@ -55,7 +65,8 @@ func TestChatUnreadAndMarkRead(t *testing.T) { // 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. +// targeting invariant, now observable through the unread bitmask. It also checks a nudge raises +// HasUnread but not the message-level flags, so the badge can colour it apart from a real message. func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) { ctx := context.Background() svc := newSocialService() @@ -68,6 +79,13 @@ func TestNudgeUnreadTargetsOnlyToMove(t *testing.T) { if unread, _ := svc.HasUnread(ctx, gameID, seats[0]); !unread { t.Error("the awaited seat should have the nudge unread") } + // A nudge is not a message: it must not raise the message-level flags. + if msg, _ := svc.HasUnreadMessage(ctx, gameID, seats[0]); msg { + t.Error("a nudge must not be flagged as an unread message") + } + if set, _ := svc.UnreadMessageGames(ctx, seats[0]); set[gameID] { + t.Error("UnreadMessageGames must not include a game whose only unread entry is a nudge") + } if unread, _ := svc.HasUnread(ctx, gameID, seats[2]); unread { t.Error("a non-awaited waiting seat must not receive the nudge") } diff --git a/backend/internal/inttest/robot_test.go b/backend/internal/inttest/robot_test.go index 5e04982..0a5fda0 100644 --- a/backend/internal/inttest/robot_test.go +++ b/backend/internal/inttest/robot_test.go @@ -181,8 +181,8 @@ func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { } } -// TestRobotProactiveNudge checks the robot's lengthening proactive-nudge schedule on the -// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed. +// TestRobotProactiveNudge checks the robot's flat proactive-nudge schedule on the human's +// turn: nothing before the 9 h floor of the 9-12 h gap, exactly one once past its 12 h ceiling. func TestRobotProactiveNudge(t *testing.T) { ctx := context.Background() svc := newGameService() @@ -206,18 +206,20 @@ func TestRobotProactiveNudge(t *testing.T) { t.Fatalf("create: %v", err) } - // A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No - // nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling. - start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + // A noon turn start. The drive instants below sit at UTC hours 20:00 and (next day) 12:00, + // both in [10:00, 20:00] so the robot is awake for every ±3h sleep drift — the gap, not the + // sleep window, decides the outcome. No nudge at 8 h idle (before the 9 h floor); exactly one + // at 24 h idle (well past the 12 h ceiling, however the seed drew within 9-12 h). + start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) setTurnStarted(t, g.ID, start) - robots.Drive(ctx, start.Add(30*time.Minute)) + robots.Drive(ctx, start.Add(8*time.Hour)) if n := countNudges(t, g.ID, robotID); n != 0 { - t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n) + t.Errorf("robot nudges = %d at 8h idle, want 0 (before the 9h floor)", n) } - robots.Drive(ctx, start.Add(2*time.Hour)) + robots.Drive(ctx, start.Add(24*time.Hour)) if n := countNudges(t, g.ID, robotID); n != 1 { - t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", n) + t.Errorf("robot nudges = %d at 24h idle, want 1 (past the 12h ceiling)", n) } } diff --git a/backend/internal/robot/driver.go b/backend/internal/robot/driver.go index aa2b33b..b9c4825 100644 --- a/backend/internal/robot/driver.go +++ b/backend/internal/robot/driver.go @@ -152,12 +152,12 @@ func (s *Service) maybeMove(ctx context.Context, rt game.RobotTurn, oppID uuid.U return s.act(ctx, rt, now) } -// maybeNudge sends a proactive nudge on a lengthening, randomized schedule (proactiveNudgeGap): -// the first lands ~60-90 min into the human's turn, and each one waits longer than the last, so a -// long idle turn gets a handful of increasingly-spaced reminders rather than an hourly stream. The -// gap is measured from the previous nudge (or the turn start for the first). The social service -// still enforces the once-per-game floor and rejects a nudge on the robot's own turn, so any such -// rejection is benign. +// maybeNudge sends a proactive nudge on a sparse, randomized schedule (proactiveNudgeGap): every +// nudge waits a uniform random 9-12 h, so a long idle turn gets only a handful of widely-spaced +// reminders rather than an hourly stream. The gap is measured from the previous nudge (or the turn +// start for the first). The caller's asleep gate already skips this during the robot's sleep +// window, so a due nudge fires at the first scan after wake. The social service still enforces the +// once-per-game floor and rejects a nudge on the robot's own turn, so any such rejection is benign. func (s *Service) maybeNudge(ctx context.Context, rt game.RobotTurn, now time.Time) error { ref := rt.TurnStartedAt if last, ok, err := s.social.LastNudgeAt(ctx, rt.GameID, rt.RobotID); err != nil { diff --git a/backend/internal/robot/strategy.go b/backend/internal/robot/strategy.go index 07afa93..1afd8dc 100644 --- a/backend/internal/robot/strategy.go +++ b/backend/internal/robot/strategy.go @@ -79,16 +79,14 @@ const ( // sleep window relative to the opponent's timezone, in hours. sleepDriftHours = 3 - // The robot proactively nudges the idle human on a lengthening, randomized schedule rather - // than an hourly stream: the first nudge lands ~60-90 min into the turn, and each subsequent - // gap grows toward 1-6 h the longer the wait drags on, so a long idle turn gets only a handful - // of increasingly-spaced reminders. The gap is a uniform sample in [nudgeGapFloorMinutes, - // ceil] minutes, where ceil ramps from nudgeGapFirstCeilMinutes to nudgeGapCeilMinutes over - // nudgeGapRamp of idle. - nudgeGapFloorMinutes = 60.0 - nudgeGapFirstCeilMinutes = 90.0 - nudgeGapCeilMinutes = 360.0 - nudgeGapRamp = 12 * time.Hour + // The robot proactively nudges the idle human on a sparse, randomized schedule rather than an + // hourly stream: every nudge waits a uniform random 9-12 h after its reference point (the turn + // start for the first nudge, the previous nudge thereafter), so even a long-neglected turn + // collects only a few widely-spaced reminders. The 3 h window width is the random spread; the + // gap does not lengthen with idle time. The driver still skips a nudge that would land in the + // robot's sleep window, deferring it to the first scan after wake. + nudgeGapLoHours = 9.0 + nudgeGapHiHours = 12.0 ) // defaultBand is the target resulting score margin after the robot's move: when @@ -263,19 +261,13 @@ func nudgeReplyDelay(seed int64, moveCount int) time.Duration { // proactiveNudgeGap is the randomized wait before the next proactive nudge, given how long the // human had already been idle at the previous nudge (refIdle; 0 for the first nudge of the turn). -// It is a uniform sample in [nudgeGapFloorMinutes, ceil] minutes, where ceil ramps from -// nudgeGapFirstCeilMinutes (a ~60-90 min first gap) up to nudgeGapCeilMinutes (a 1-6 h gap) as -// refIdle reaches nudgeGapRamp — so the reminders space out the longer the turn is neglected. It -// is deterministic per (seed, refIdle), so the driver computes the same due time on every scan. +// It is a uniform sample in [nudgeGapLoHours, nudgeGapHiHours] hours, deterministic per +// (seed, refIdle) so the driver computes the same due time on every scan. refIdle only salts the +// draw, so each successive nudge of a still-idle turn waits a fresh 9-12 h rather than lengthening. func proactiveNudgeGap(refIdle time.Duration, seed int64) time.Duration { - f := float64(refIdle) / float64(nudgeGapRamp) - if f > 1 { - f = 1 - } - ceil := nudgeGapFirstCeilMinutes + (nudgeGapCeilMinutes-nudgeGapFirstCeilMinutes)*f u := unitFloat(mix(seed, "pnudge", int(refIdle/(30*time.Minute)))) - mins := nudgeGapFloorMinutes + (ceil-nudgeGapFloorMinutes)*u - return time.Duration(mins * float64(time.Minute)) + hours := nudgeGapLoHours + (nudgeGapHiHours-nudgeGapLoHours)*u + return time.Duration(hours * float64(time.Hour)) } // clampMinutes converts a minute count to a duration, clamping it to the hard delay diff --git a/backend/internal/robot/strategy_test.go b/backend/internal/robot/strategy_test.go index f3f5f85..98cb4d9 100644 --- a/backend/internal/robot/strategy_test.go +++ b/backend/internal/robot/strategy_test.go @@ -315,13 +315,13 @@ func TestDeviatesDistribution(t *testing.T) { // as the idle grows (the median at 12 h idle exceeds the median at the start). func TestProactiveNudgeGap(t *testing.T) { for seed := int64(1); seed <= 1000; seed++ { - if first := proactiveNudgeGap(0, seed); first < 60*time.Minute || first > 90*time.Minute { - t.Fatalf("first gap %s out of [60m,90m] for seed %d", first, seed) + if first := proactiveNudgeGap(0, seed); first < 9*time.Hour || first > 12*time.Hour { + t.Fatalf("first gap %s out of [9h,12h] for seed %d", first, seed) } for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} { g := proactiveNudgeGap(idle, seed) - if g < 60*time.Minute || g > 6*time.Hour { - t.Fatalf("gap %s out of [60m,6h] for seed %d idle %s", g, seed, idle) + if g < 9*time.Hour || g > 12*time.Hour { + t.Fatalf("gap %s out of [9h,12h] for seed %d idle %s", g, seed, idle) } if proactiveNudgeGap(idle, seed) != g { t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle) @@ -337,8 +337,16 @@ func TestProactiveNudgeGap(t *testing.T) { sort.Float64s(xs) return xs[n/2] } - if early, late := median(0), median(12*time.Hour); early >= late { - t.Errorf("median gap should grow with idle: idle0=%.0f idle12h=%.0f", early, late) + // The window is flat: the gap distribution does not lengthen with idle time, so the median + // stays near the band centre (10.5 h) and barely moves between a fresh turn and a long-idle one. + early, late := median(0), median(12*time.Hour) + for _, m := range []float64{early, late} { + if m < 9*60 || m > 12*60 { + t.Fatalf("median gap %.0f min out of [540,720]", m) + } + } + if diff := math.Abs(early - late); diff > 30 { + t.Errorf("median gap should not shift with idle (flat window): idle0=%.0f idle12h=%.0f", early, late) } } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index f02828b..68ba1e7 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -112,6 +112,10 @@ type gameDTO struct { // 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"` + // UnreadMessages is a per-viewer flag: at least one of the unread entries is a real chat + // message (not just a nudge). It colours the badge — set with UnreadChat it is the regular + // badge, UnreadChat alone is the softer nudge-only badge. gameDTOFromGame leaves it false. + UnreadMessages bool `json:"unread_messages"` } // moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index be5e539..13d6df7 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -443,16 +443,20 @@ 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 + // Two queries seed the lobby's per-card unread badge for every listed game: which games have + // any unread entry, and which of those have a real message (so the badge can colour + // message-bearing games apart from nudge-only ones). + var unread, unreadMsg map[uuid.UUID]bool if s.social != nil { unread, _ = s.social.UnreadGames(c.Request.Context(), uid) + unreadMsg, _ = s.social.UnreadMessageGames(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] + dto.UnreadMessages = unreadMsg[g.ID] out = append(out, dto) } c.JSON(http.StatusOK, gameListDTO{Games: out, AtGameLimit: atLimit}) @@ -536,8 +540,9 @@ func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) { 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 +// setUnreadChat fills the per-game unread-chat flags for viewer uid on one game's DTO, when the +// social domain is present: UnreadChat for any unread entry and UnreadMessages when one of them is +// a real message (so the badge can be coloured). 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 { @@ -550,4 +555,7 @@ func (s *Server) setUnreadChat(ctx context.Context, g *gameDTO, uid uuid.UUID) { if unread, err := s.social.HasUnread(ctx, gid, uid); err == nil { g.UnreadChat = unread } + if msg, err := s.social.HasUnreadMessage(ctx, gid, uid); err == nil { + g.UnreadMessages = msg + } } diff --git a/backend/internal/social/chatread.go b/backend/internal/social/chatread.go index 08571c2..5b1b495 100644 --- a/backend/internal/social/chatread.go +++ b/backend/internal/social/chatread.go @@ -67,6 +67,21 @@ func (svc *Service) HasUnread(ctx context.Context, gameID, viewerID uuid.UUID) ( return svc.store.hasUnread(ctx, gameID, viewerID) } +// UnreadMessageGames returns the subset of UnreadGames whose unread entries include a real chat +// message (a 'message' kind, not a nudge), for colouring the lobby's per-card unread badge: a game +// present here has an unread message (the regular badge), one only in UnreadGames has nudges alone +// (the soft nudge badge). +func (svc *Service) UnreadMessageGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) { + return svc.store.unreadMessageGames(ctx, viewerID) +} + +// HasUnreadMessage reports whether viewerID has an unread chat message (a 'message' kind, not a +// nudge) in the game, for distinguishing a message badge from a nudge-only one on a single game's +// state and move-result views. +func (svc *Service) HasUnreadMessage(ctx context.Context, gameID, viewerID uuid.UUID) (bool, error) { + return svc.store.hasUnreadMessage(ctx, gameID, viewerID) +} + // readMark is one chat entry that flipped from unread to read, carrying what the // publish-to-read latency metric needs. type readMark struct { @@ -161,6 +176,43 @@ func (s *Store) hasUnread(ctx context.Context, gameID, viewerID uuid.UUID) (bool return ok, nil } +// unreadMessageGames is unreadGames restricted to real chat messages (kind 'message', not a +// nudge), so the caller can tell a message badge from a nudge-only one in one extra query. +func (s *Store) unreadMessageGames(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.kind = 'message' AND 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 message 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 message games: %w", err) + } + out[id] = true + } + return out, rows.Err() +} + +// hasUnreadMessage reports whether viewerID has an unread real chat message (kind 'message', not +// a nudge) in the one game. +func (s *Store) hasUnreadMessage(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.kind = 'message' 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 message: %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) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9595419..aa461fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -446,9 +446,10 @@ disguised robot stays indistinguishable from a person. **sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's rather than running anti-phase; on a daytime nudge it replies near the move's lower - band; it proactively nudges the idle human on a **lengthening, randomized schedule** — the - first ~60-90 min into the turn, each later reminder spaced further out toward 1-6 h — so a long - wait gets a handful of increasingly-spaced nudges rather than an hourly stream. + band; it proactively nudges the idle human on a **sparse, randomized schedule** — every nudge + waits a uniform random **9-12 h** (the first measured from the turn start, each later one from + the previous nudge), so a neglected turn gets only a handful of widely-spaced reminders. A nudge + that would fall inside the sleep window is skipped and fires at the first scan after wake. - **Dead-endgame timing**: once the **two most recent moves are both passes**, the board and the robot's rack are frozen and it is bound to pass again, so the robot drops the long late-game think time and answers on a **shortened schedule scaled to the human's own last (pass) think @@ -570,8 +571,15 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); 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. + unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`** + (`unread_seats <> 0 AND kind = 'message'`), reports whether any unread entry is a real + message rather than only a nudge, so the **dot is coloured**: the regular danger colour + when a message is unread, a softer amber when only nudges are. Both flags share the + REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge + event raises only `unread_chat`, a message event raises both). The lobby additionally + **floats games with any unread entry to the top** of the your-turn and opponent-turn + sections (the finished section keeps its activity order). 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: diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 43d9591..f76a17c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -74,8 +74,9 @@ The lobby lists **my games** and offers a bottom tab bar — new game, statistic **my games** list groups games into three sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and orders them so the games awaiting your move come first, the longest-waiting on top, while -opponent-turn and finished games are most-recent first; it renders as a compact, -line-separated list. While the lobby is open and a listed game **becomes your turn or +opponent-turn and finished games are most-recent first; within the your-turn and +opponent-turn sections a game with any unread notification floats to the top (the finished +section keeps its order). It renders as a compact, line-separated list. While the lobby is open and a listed game **becomes your turn or finishes**, its status icon **blinks twice** to draw the eye (an opponent's-turn change is silent, applied in place). You can **remove a finished game from your own list**: swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it. @@ -161,7 +162,8 @@ times for most moves, the occasional long one, and a night-time pause that track player's own day; once a game is clearly decided and both sides are only passing, it stops dragging it out, answering its forced passes at roughly the player's own pace rather than after a long deliberation. It answers a nudge -within a few minutes and nudges back when the player has been away a long time. It +within a few minutes and, when the player keeps it waiting, nudges back on a sparse +schedule — roughly every 9-12 hours, and never during its night-time pause. It carries a human-like, language-appropriate name — a fresh one each game, drawn from a wide international pool of real names and handles, so the arena feels populated by many different players (a Russian game shows mostly Russian names, never East-Asian scripts; an international @@ -216,8 +218,9 @@ Chat and the word-check tool share one **comms screen** with **💬 chat** / ** tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the dictionary closed) the 💬 entry disappears entirely. 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 +entry — a message **or a nudge** from an opponent — raises a small **dot** next to the game +in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a +softer amber when only nudges are**. 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**. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index d15ee77..1124539 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -75,8 +75,9 @@ nudge) приходят от бота **этой партии** — по язы Список **мои игры** разбит на три секции — *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу -соперника и завершённые — самые свежие сверху; отображается компактным списком с -линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или +соперника и завершённые — самые свежие сверху; внутри секций «твой ход» и «ход соперника» +игра с любым непрочитанным уведомлением всплывает наверх (секция «завершённые» сохраняет +свой порядок). Отображается компактным списком с линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**: проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть @@ -166,8 +167,9 @@ nudge) приходят от бота **этой партии** — по язы человеческим темпом — чаще короткие раздумья, изредка долгие, и ночная пауза, подстроенная под день игрока; когда партия уже явно решена и обе стороны только пасуют, он перестаёт её затягивать — отвечает на вынужденные пасы примерно в темпе самого игрока, -а не после долгого раздумья. На nudge отвечает за несколько минут и -сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, подходящее +а не после долгого раздумья. На nudge отвечает за несколько минут и, когда игрок +держит его в ожидании, сам шлёт nudge редко — примерно раз в 9-12 часов и никогда во время +своей ночной паузы. Носит человекоподобное имя, подходящее языку партии — каждую партию новое, из широкого международного пула реальных имён и никнеймов, так что арена кажется полной разных игроков (в русской партии — в основном русские имена, без восточноазиатских письменностей; в международной — весь спектр); не общается в чате и @@ -222,8 +224,9 @@ nudge) приходят от бота **этой партии** — по язы открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход 💬 исчезает. Непрочитанная запись чата — -сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в -**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже +сообщение **или nudge** от соперника — зажигает маленькую **точку** рядом с партией в +**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую, +когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда его получатель **делает ход**. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 4bff004..f9465e5 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -135,6 +135,7 @@ type GameResp struct { LastActivityUnix int64 `json:"last_activity_unix"` Seats []SeatResp `json:"seats"` UnreadChat bool `json:"unread_chat"` + UnreadMessages bool `json:"unread_messages"` } // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 7b84e57..2b44f2e 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -441,6 +441,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { Seats: seats, LastActivityUnix: g.LastActivityUnix, UnreadChat: g.UnreadChat, + UnreadMessages: g.UnreadMessages, } } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index cdd2482..a2545af 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -254,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,"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}`)) + _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"unread_chat":true,"unread_messages":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() @@ -279,6 +279,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if !g.UnreadChat() { t.Error("unread_chat = false, want true (the backend flagged unread for the viewer)") } + if !g.UnreadMessages() { + t.Error("unread_messages = false, want true (the backend flagged an unread message)") + } 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 53f2917..9fe46cc 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -76,6 +76,10 @@ table GameView { // 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; + // unread_messages is a per-viewer flag: at least one of the unread entries is a real chat + // message (not just a nudge). With unread_chat it colours the badge — both set is the regular + // badge, unread_chat alone is the softer nudge-only badge. + unread_messages: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 fd91d9d..0969946 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -197,8 +197,20 @@ func (rcv *GameView) MutateUnreadChat(n bool) bool { return rcv._tab.MutateBoolSlot(30, n) } +func (rcv *GameView) UnreadMessages() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(32)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *GameView) MutateUnreadMessages(n bool) bool { + return rcv._tab.MutateBoolSlot(32, n) +} + func GameViewStart(builder *flatbuffers.Builder) { - builder.StartObject(14) + builder.StartObject(15) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -245,6 +257,9 @@ func GameViewAddVsAi(builder *flatbuffers.Builder, vsAi bool) { func GameViewAddUnreadChat(builder *flatbuffers.Builder, unreadChat bool) { builder.PrependBoolSlot(13, unreadChat, false) } +func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool) { + builder.PrependBoolSlot(14, unreadMessages, false) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 56dba8e..ca58f76 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -46,6 +46,9 @@ type GameView struct { VsAI bool // UnreadChat is a per-viewer flag: the requesting player has unread chat in this game. UnreadChat bool + // UnreadMessages is a per-viewer flag: at least one unread entry is a real message (not just + // a nudge), so the badge can be coloured apart from the nudge-only case. + UnreadMessages bool } // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank @@ -165,6 +168,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) fb.GameViewAddVsAi(b, g.VsAI) fb.GameViewAddUnreadChat(b, g.UnreadChat) + fb.GameViewAddUnreadMessages(b, g.UnreadMessages) return fb.GameViewEnd(b) } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5e5e8d0..6894761 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -14,6 +14,7 @@ import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import { lastMoveCells, replay } from '../lib/board'; + import { badgeKind } from '../lib/unread'; import { historyGrid } from '../lib/history'; import { centre, premiumGrid } from '../lib/premiums'; import { variantNameKey } from '../lib/variants'; @@ -183,7 +184,7 @@ view = st; syncWallet(st.walletBalance); // Seed the unread flag from the authoritative state (the live stream only raises it). - seedChatUnread(id, st.game.unreadChat); + seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages); moves = hist.moves; setCachedGame(id, st, hist.moves, draft); // Mirror the fresh status into the lobby snapshot so returning there shows it without a @@ -670,7 +671,7 @@ }; // 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); + seedChatUnread(id, r.game.unreadChat, r.game.unreadMessages); 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. @@ -1091,10 +1092,11 @@ above so the markup and logic stay single-sourced (see the {#if landscape} split). --> {#snippet scoreboardBlock()} {#if view} + {@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
- {#if app.chatUnread[id]}{/if} + {#if badge}{/if} {#each view.game.seats as s (s.seat)}
{seatName(s)}
@@ -1582,13 +1584,17 @@ color: var(--danger); } /* 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. */ + when the history is opened with unread present, rather than carrying a count badge. Red for an + unread message, a softer amber when only nudges are unread. */ .unread-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); } + .unread-dot.nudge { + background: var(--warn); + } .sbadge-dot { position: absolute; top: 4px; diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index cc80841..35ec956 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -108,8 +108,13 @@ unreadChat():boolean { return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; } +unreadMessages():boolean { + const offset = this.bb!.__offset(this.bb_pos, 32); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startGameView(builder:flatbuffers.Builder) { - builder.startObject(14); + builder.startObject(15); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -180,12 +185,16 @@ static addUnreadChat(builder:flatbuffers.Builder, unreadChat:boolean) { builder.addFieldInt8(13, +unreadChat, +false); } +static addUnreadMessages(builder:flatbuffers.Builder, unreadMessages:boolean) { + builder.addFieldInt8(14, +unreadMessages, +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, unreadChat: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, unreadMessages:boolean):flatbuffers.Offset { GameView.startGameView(builder); GameView.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -201,6 +210,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addLastActivityUnix(builder, lastActivityUnix); GameView.addVsAi(builder, vsAi); GameView.addUnreadChat(builder, unreadChat); + GameView.addUnreadMessages(builder, unreadMessages); return GameView.endGameView(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 20e8418..e91f4c5 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -63,6 +63,10 @@ export const app = $state<{ * 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; + /** Per-game flag: at least one unread entry is a real message (not just a nudge), so the dot + * can be coloured apart from the nudge-only case. Same lifecycle as chatUnread; nudge events + * raise only chatUnread, message events raise both. */ + messageUnread: Record; /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined * with friend requests) and the Settings → Info badge. */ feedbackReplyUnread: boolean; @@ -93,6 +97,7 @@ export const app = $state<{ localeLocked: false, notifications: 0, chatUnread: {}, + messageUnread: {}, feedbackReplyUnread: false, staleInvite: false, welcomeRedeem: false, @@ -170,14 +175,18 @@ export function dismissWelcomeRedeem(): void { } /** - * 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. + * seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby + * list, a game's state, or a move result): unread is any unread entry, message whether one of them + * is a real message (the badge colour). The live-event GameView omits both, so the live stream + * raises them on receive rather than seeding from a GameView. */ -export function seedChatUnread(gameId: string, unread: boolean): void { +export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void { if ((app.chatUnread[gameId] ?? false) !== unread) { app.chatUnread = { ...app.chatUnread, [gameId]: unread }; } + if ((app.messageUnread[gameId] ?? false) !== message) { + app.messageUnread = { ...app.messageUnread, [gameId]: message }; + } } /** @@ -192,6 +201,7 @@ export function seedChatUnread(gameId: string, unread: boolean): void { export function markChatRead(gameId: string): void { if (!app.chatUnread[gameId]) return; app.chatUnread = { ...app.chatUnread, [gameId]: false }; + if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false }; void gateway.markChatRead(gameId).catch(() => {}); } @@ -262,6 +272,11 @@ function openStream(): void { router.route.params.id === e.message.gameId; if (!inComms) { app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true }; + // Only a real message colours the badge; a nudge delivered as a chat_message must not + // raise the message flag (the separate nudge event below also leaves it untouched). + if (e.message.kind !== 'nudge') { + app.messageUnread = { ...app.messageUnread, [e.message.gameId]: true }; + } showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info'); } } else if (e.kind === 'nudge') { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 7f23cdb..aeb995e 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -253,6 +253,7 @@ function decodeGameView(g: fb.GameView): GameView { lastActivityUnix: Number(g.lastActivityUnix()), vsAi: g.vsAi(), unreadChat: g.unreadChat(), + unreadMessages: g.unreadMessages(), seats, }; } @@ -849,6 +850,7 @@ function emptyGame(): GameView { lastActivityUnix: 0, vsAi: false, unreadChat: false, + unreadMessages: false, seats: [], }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index ef3344f..6b8e4fd 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -9,6 +9,7 @@ function gameView(id: string): GameView { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status: 'active', players: 2, toMove: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 2f25759..67fcaf0 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -10,6 +10,7 @@ function gameView(moveCount: number, over = false): GameView { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: 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 b1960c0..0e8f4b9 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -26,6 +26,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0) dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status, players: 2, toMove, diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 75763aa..f4e3007 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -19,6 +19,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status, players: 2, toMove, @@ -40,6 +41,7 @@ describe('groupGames', () => { game('c', 'finished', 0, 100), ], ME, + {}, ); expect(g.yourTurn.map((x) => x.id)).toEqual(['a']); expect(g.theirTurn.map((x) => x.id)).toEqual(['b']); @@ -57,12 +59,33 @@ describe('groupGames', () => { game('f_old', 'finished', 0, 100), ], ME, + {}, ); expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']); expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']); expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']); }); + it('bumps unread games first in the active sections, leaving finished by activity', () => { + const g = groupGames( + [ + game('y_read', 'active', 0, 50), // my turn, oldest, read + game('y_unread', 'active', 0, 200), // my turn, newest, unread -> bumped above y_read + game('t_read', 'active', 1, 200), // their turn, newest, read + game('t_unread', 'active', 1, 50), // their turn, oldest, unread -> bumped above t_read + game('f_unread', 'finished', 0, 100), + game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first + ], + ME, + { y_unread: true, t_unread: true, f_unread: true }, + ); + // Unread is the primary key, so it overrides the within-section activity order. + expect(g.yourTurn.map((x) => x.id)).toEqual(['y_unread', 'y_read']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['t_unread', 't_read']); + // Finished ignores unread: still newest-first by activity. + expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_unread']); + }); + it('isMyTurn is false for a finished game even at my seat', () => { expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false); expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); @@ -76,6 +99,7 @@ describe('groupGames', () => { game('open_wait', 'open', 1, 100), // the empty seat's turn ], ME, + {}, ); expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']); expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index e98ec82..6cc4906 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -43,11 +43,16 @@ export interface LobbyGroups { } /** - * groupGames partitions games for myId into the three lobby sections and orders each: the - * your-turn games by ascending last activity (the longest-waiting first), the opponent-turn - * and finished games by descending last activity (the most recent first). + * groupGames partitions games for myId into the three lobby sections and orders each. In the two + * active sections a game with an unread notification (unread[g.id], any chat entry or nudge) sorts + * first, then by last activity: your-turn ascending (the longest-waiting first), opponent-turn + * descending (the most recent first). Finished games ignore unread and stay descending by activity. */ -export function groupGames(games: GameView[], myId: string): LobbyGroups { +export function groupGames( + games: GameView[], + myId: string, + unread: Record, +): LobbyGroups { const yourTurn: GameView[] = []; const theirTurn: GameView[] = []; const finished: GameView[] = []; @@ -56,8 +61,10 @@ export function groupGames(games: GameView[], myId: string): LobbyGroups { else if (isMyTurn(g, myId)) yourTurn.push(g); else theirTurn.push(g); } - yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix); - theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); + // Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break. + const rank = (g: GameView) => (unread[g.id] ? 1 : 0); + yourTurn.sort((a, b) => rank(b) - rank(a) || a.lastActivityUnix - b.lastActivityUnix); + theirTurn.sort((a, b) => rank(b) - rank(a) || b.lastActivityUnix - a.lastActivityUnix); finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); return { yourTurn, theirTurn, finished }; } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index ed24928..ee2fd8e 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -182,6 +182,7 @@ export class MockGateway implements GatewayClient { lastActivityUnix: Math.floor(Date.now() / 1000), vsAi: true, unreadChat: false, + unreadMessages: 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 }, @@ -214,6 +215,7 @@ export class MockGateway implements GatewayClient { lastActivityUnix: Math.floor(Date.now() / 1000), vsAi: false, unreadChat: false, + unreadMessages: false, seats: [ { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false }, diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index c4e7c20..718b74f 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -181,6 +181,7 @@ function activeGame(): MockGame { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status: 'active', players: 2, toMove: 0, @@ -218,6 +219,7 @@ function finishedG2(): MockGame { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status: 'finished', players: 2, toMove: 0, @@ -256,6 +258,7 @@ function finishedG3(): MockGame { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status: 'finished', players: 2, toMove: 0, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 402bd43..b5483ec 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -51,6 +51,10 @@ export interface GameView { * 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; + /** Per-viewer flag: at least one unread entry is a real message (not just a nudge), so the + * badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as + * unreadChat; the live-event GameView leaves it false. */ + unreadMessages: boolean; seats: Seat[]; } diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 4aa53cc..9a53d18 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -21,6 +21,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status, players: 2, toMove: 0, diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index e0a00f9..52c6147 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -18,6 +18,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { dictVersion: 'v1', vsAi: false, unreadChat: false, + unreadMessages: false, status, players: seats.length, toMove, diff --git a/ui/src/lib/unread.test.ts b/ui/src/lib/unread.test.ts new file mode 100644 index 0000000..040d37f --- /dev/null +++ b/ui/src/lib/unread.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { badgeKind } from './unread'; + +describe('badgeKind', () => { + it('shows nothing when nothing is unread', () => { + expect(badgeKind(false, false)).toBeNull(); + // message without unread is inconsistent input; the unread guard still wins. + expect(badgeKind(false, true)).toBeNull(); + }); + + it('is a nudge badge when only nudges are unread', () => { + expect(badgeKind(true, false)).toBe('nudge'); + }); + + it('is a message badge when a real message is unread', () => { + expect(badgeKind(true, true)).toBe('message'); + }); +}); diff --git a/ui/src/lib/unread.ts b/ui/src/lib/unread.ts new file mode 100644 index 0000000..9f2685b --- /dev/null +++ b/ui/src/lib/unread.ts @@ -0,0 +1,17 @@ +// Pure resolution of the per-game unread dot's kind, kept out of the .svelte components so it can +// be unit-tested. A game's dot is shown when anything is unread; its colour distinguishes a real +// chat message (the regular danger colour) from nudge-only unread (a softer nudge colour). + +/** BadgeKind is the unread dot to show for a game, or null when nothing is unread. */ +export type BadgeKind = 'message' | 'nudge' | null; + +/** + * badgeKind picks the unread dot for a game from its two per-viewer flags: null when nothing is + * unread, 'message' when an unread real chat message is present (the regular colour), else 'nudge' + * when only nudges are unread (the softer colour). message implies unread, so a stray message=true + * with unread=false still shows nothing. + */ +export function badgeKind(unread: boolean, message: boolean): BadgeKind { + if (!unread) return null; + return message ? 'message' : 'nudge'; +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 7ee2060..212cd70 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -9,6 +9,7 @@ import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; + import { badgeKind } from '../lib/unread'; import { getLobby, setLobby } from '../lib/lobbycache'; import { preloadGames } from '../lib/preload'; import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; @@ -35,7 +36,7 @@ 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); + for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages); atGameLimit = list.atGameLimit; if (!guest) { [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); @@ -75,7 +76,7 @@ }); const myId = $derived(app.session?.userId ?? ''); - const groups = $derived(groupGames(games, myId)); + const groups = $derived(groupGames(games, myId, app.chatUnread)); // Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished" // while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is @@ -253,6 +254,7 @@

{t(group.h as 'lobby.yourTurn')}

{#each group.list as g (g.id)} + {@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
{#if group.finished} @@ -267,7 +269,7 @@ {opponents(g) || '—'} - {#if app.chatUnread[g.id]}{/if} + {#if badge}{/if} {scoreline(g)} @@ -458,7 +460,8 @@ overflow: hidden; text-overflow: ellipsis; } - /* A small red dot beside the opponent name: this game has an unread chat entry. */ + /* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread + message, a softer amber when only nudges are unread. */ .unread-dot { flex: 0 0 auto; width: 8px; @@ -466,6 +469,9 @@ border-radius: 50%; background: var(--danger); } + .unread-dot.nudge { + background: var(--warn); + } .sub { font-size: 0.85rem; color: var(--text-muted);