feat: sparser robot nudges, typed unread badge, lobby unread bump
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m26s

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.
This commit is contained in:
Ilia Denisov
2026-06-19 16:50:48 +02:00
parent c67a5d51f1
commit 6e77de4c1e
34 changed files with 331 additions and 86 deletions
+20 -2
View File
@@ -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")
}
+11 -9
View File
@@ -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)
}
}
+6 -6
View File
@@ -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 {
+13 -21
View File
@@ -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
+14 -6
View File
@@ -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)
}
}
+4
View File
@@ -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
+12 -4
View File
@@ -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
}
}
+52
View File
@@ -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) {
+13 -5
View File
@@ -446,9 +446,10 @@ disguised robot stays indistinguishable from a person.
**sleeps 00:0007: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:
+8 -5
View File
@@ -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**.
+9 -6
View File
@@ -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 также гаснет в момент, когда
его получатель **делает ход**.
+1
View File
@@ -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
+1
View File
@@ -441,6 +441,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
Seats: seats,
LastActivityUnix: g.LastActivityUnix,
UnreadChat: g.UnreadChat,
UnreadMessages: g.UnreadMessages,
}
}
+4 -1
View File
@@ -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" {
+4
View File
@@ -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).
+16 -1
View File
@@ -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()
}
+4
View File
@@ -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)
}
+10 -4
View File
@@ -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)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
@@ -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;
+12 -2
View File
@@ -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);
}
}
+19 -4
View File
@@ -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<string, boolean>;
/** 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<string, boolean>;
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
@@ -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') {
+2
View File
@@ -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: [],
};
}
+1
View File
@@ -9,6 +9,7 @@ function gameView(id: string): GameView {
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
status: 'active',
players: 2,
toMove: 0,
+1
View File
@@ -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,
+1
View File
@@ -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,
+24
View File
@@ -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']);
+13 -6
View File
@@ -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<string, boolean>,
): 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 };
}
+2
View File
@@ -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 },
+3
View File
@@ -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,
+4
View File
@@ -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[];
}
+1
View File
@@ -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,
+1
View File
@@ -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,
+18
View File
@@ -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');
});
});
+17
View File
@@ -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';
}
+10 -4
View File
@@ -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 @@
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
@@ -267,7 +269,7 @@
<span class="info">
<span class="who">
<span class="who-name">{opponents(g) || '—'}</span>
{#if app.chatUnread[g.id]}<span class="unread-dot"></span>{/if}
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span>
<span class="sub">{scoreline(g)}</span>
</span>
@@ -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);