Merge pull request 'feat(nudge): name the sender; blink lobby cards; re-animate toasts' (#75) from feature/nudge-name-lobby-blink into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 49s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 58s

This commit was merged in pull request #75.
This commit is contained in:
2026-06-16 19:33:57 +00:00
26 changed files with 336 additions and 37 deletions
+6
View File
@@ -1589,3 +1589,9 @@ cannot submit; three-way admin filter.
`account_stats` or a games query), falling back to their interface language `account_stats` or a games query), falling back to their interface language
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
wrong. wrong.
- **TODO-7 — animate a lobby card's move between sections (owner's idea, 2026-06).**
When a game changes bucket — *your turn**finished*, *opponent's turn**your turn*, etc. —
its card simply re-renders in the new section; only the status emoji **blinks twice** (added
alongside the named-nudge + lobby-blink work). A FLIP / slide animation that visibly relocates
the card to its new section would be a nicer touch. Deferred — purely cosmetic, nothing depends
on it.
+17
View File
@@ -1144,6 +1144,23 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
return seats, g.ToMove, g.Status, nil return seats, g.ToMove, g.Status, nil
} }
// SeatName returns the display name shown for the account seated in the game — the seat's
// captured snapshot, falling back to the account's current name (see seatDisplayName) — or ""
// when the account holds no seat. It lets the social package name a nudge's sender by the same
// per-game identity the rest of the game uses, without exposing the seats.
func (svc *Service) SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return "", err
}
for _, s := range g.Seats {
if s.AccountID == accountID {
return svc.seatDisplayName(ctx, s), nil
}
}
return "", nil
}
// SharedGame reports whether accounts a and b are seated together in any game // SharedGame reports whether accounts a and b are seated together in any game
// (active or finished). It backs the social package's "befriend an opponent" // (active or finished). It backs the social package's "befriend an opponent"
// request gate without exposing the games tables; a self-pair is never shared. // request gate without exposing the games tables; a self-pair is never shared.
+27
View File
@@ -382,6 +382,33 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
} }
} }
// TestSeatNameResolvesDisplayName checks game.Service.SeatName surfaces a seated account's
// display name — the identity social.Nudge voices the nudge with — and returns "" for an account
// that holds no seat (the render then falls back to the plain phrase).
func TestSeatNameResolvesDisplayName(t *testing.T) {
ctx := context.Background()
gsvc := newGameService()
// ProvisionRobot is the one provisioning path that stamps a known display name (a plain
// provisioned account has none), so the seat resolves to a name we can assert.
named, err := account.NewStore(testDB).ProvisionRobot(ctx, "tg-"+uuid.NewString(), "Ann")
if err != nil {
t.Fatalf("provision named account: %v", err)
}
g, err := gsvc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{provisionAccount(t), named.ID},
TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
if got, err := gsvc.SeatName(ctx, g.ID, named.ID); err != nil || got != "Ann" {
t.Errorf("seated SeatName = (%q, %v), want (\"Ann\", nil)", got, err)
}
if got, err := gsvc.SeatName(ctx, g.ID, provisionAccount(t)); err != nil || got != "" {
t.Errorf("non-seated SeatName = (%q, %v), want (\"\", nil)", got, err)
}
}
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has // TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
// acted (moved or chatted) since their last nudge, even within the hour. // acted (moved or chatted) since their last nudge, even within the hour.
func TestNudgeCooldownResetsOnAction(t *testing.T) { func TestNudgeCooldownResetsOnAction(t *testing.T) {
+6 -2
View File
@@ -96,14 +96,18 @@ func ChatMessage(userID, gameID, senderID uuid.UUID, id, kind, body string, crea
return Intent{UserID: userID, Kind: KindChatMessage, Payload: b.FinishedBytes(), EventID: eventID()} return Intent{UserID: userID, Kind: KindChatMessage, Payload: b.FinishedBytes(), EventID: eventID()}
} }
// Nudge tells userID that fromUserID nudged them in game gameID. // Nudge tells userID that fromUserID nudged them in game gameID. senderName is fromUserID's
func Nudge(userID, gameID, fromUserID uuid.UUID) Intent { // seat display name, used to voice the toast and the out-of-app push ("<name>: …"); an empty
// name leaves the render to fall back to the plain phrase.
func Nudge(userID, gameID, fromUserID uuid.UUID, senderName string) Intent {
b := flatbuffers.NewBuilder(64) b := flatbuffers.NewBuilder(64)
gid := b.CreateString(gameID.String()) gid := b.CreateString(gameID.String())
from := b.CreateString(fromUserID.String()) from := b.CreateString(fromUserID.String())
name := b.CreateString(senderName)
fb.NudgeEventStart(b) fb.NudgeEventStart(b)
fb.NudgeEventAddGameId(b, gid) fb.NudgeEventAddGameId(b, gid)
fb.NudgeEventAddFromUserId(b, from) fb.NudgeEventAddFromUserId(b, from)
fb.NudgeEventAddSenderName(b, name)
b.Finish(fb.NudgeEventEnd(b)) b.Finish(fb.NudgeEventEnd(b))
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()} return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
} }
+12
View File
@@ -83,6 +83,18 @@ func TestYourTurnPayloadRoundTrips(t *testing.T) {
} }
} }
func TestNudgePayloadRoundTrips(t *testing.T) {
uid, gid, from := uuid.New(), uuid.New(), uuid.New()
in := notify.Nudge(uid, gid, from, "Ann")
if in.UserID != uid || in.Kind != notify.KindNudge || in.EventID == "" {
t.Fatalf("intent metadata wrong: %+v", in)
}
ev := fb.GetRootAsNudgeEvent(in.Payload, 0)
if string(ev.GameId()) != gid.String() || string(ev.FromUserId()) != from.String() || string(ev.SenderName()) != "Ann" {
t.Fatalf("nudge fields wrong: game=%q from=%q name=%q", ev.GameId(), ev.FromUserId(), ev.SenderName())
}
}
func TestGameOverPayloadRoundTrips(t *testing.T) { func TestGameOverPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New() uid, gid := uuid.New(), uuid.New()
summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}} summary := notify.GameSummary{ID: gid.String(), Status: "finished", MoveCount: 18, Seats: []notify.SeatStanding{{Seat: 0, Score: 120, IsWinner: true}}}
+4 -1
View File
@@ -154,7 +154,10 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
} }
svc.metrics.recordChat(ctx, kindNudge) svc.metrics.recordChat(ctx, kindNudge)
if toMove >= 0 && toMove < len(seats) { if toMove >= 0 && toMove < len(seats) {
nudge := notify.Nudge(seats[toMove], gameID, senderID) // Name the sender by their per-game seat snapshot, so the toast and the out-of-app push
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
nudge := notify.Nudge(seats[toMove], gameID, senderID, senderName)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil { if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one nudge.Language = lang // route by the game's bot, not the recipient's last-login one
} }
+4
View File
@@ -25,6 +25,10 @@ import (
// private state. // private state.
type GameReader interface { type GameReader interface {
Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error) Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error)
// SeatName is the display name of the account seated in the game (its per-game snapshot,
// falling back to the account's current name), or "" when it holds no seat; it names a
// nudge's sender in the toast and the out-of-app push by the same identity the game uses.
SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error)
// SharedGame reports whether two accounts are seated together in any game // SharedGame reports whether two accounts are seated together in any game
// (active or finished); it gates the "befriend an opponent" request path. // (active or finished); it gates the "befriend an opponent" request path.
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
+5 -2
View File
@@ -75,7 +75,9 @@ The lobby lists **my games** and offers a bottom tab bar — new game, statistic
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and 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 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, opponent-turn and finished games are most-recent first; it renders as a compact,
line-separated list. You can **remove a finished game from your own list**: 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. swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
The removal is per-account and permanent — the game disappears only from your list and stays The removal is per-account and permanent — the game disappears only from your list and stays
in the other players' lists, and there is no undo. The game types offered on **New Game** are in the other players' lists, and there is no undo. The game types offered on **New Game** are
@@ -177,7 +179,8 @@ existing friendship). Per-game chat is for quick reactions: messages are short
even disguised. You may send **one message per turn, on your own turn**; once it is sent even disguised. You may send **one message per turn, on your own turn**; once it is sent
the field gives way to a short caption until your next turn. Nudge the player whose turn the field gives way to a short caption until your next turn. Nudge the player whose turn
is awaited at most once per hour (the is awaited at most once per hour (the
nudge is part of the game chat); the out-of-app push is delivered via the platform. nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
via the platform) **name the nudger** ("<opponent>: waiting for your move").
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat
message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened. message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened.
+6 -3
View File
@@ -76,7 +76,9 @@ nudge) приходят от бота **этой партии** — по язы
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями. Завершённую партию можно **убрать из своего списка**: линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или
завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия **❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
@@ -180,8 +182,9 @@ nudge) приходят от бота **этой партии** — по язы
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
хода. Nudge ожидаемого хода. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий соперника — не чаще раза в час (nudge — часть игрового чата); и всплывашка в приложении,
push доставляется через платформу. и внеприложенческий push (доставляется через платформу) **называют отправителя**
(«<соперник>: жду вашего хода»).
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата. **бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
+6 -1
View File
@@ -233,7 +233,12 @@ and a long message does not scroll.
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 / on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. When a listed
game **becomes your turn or finishes** while the lobby is open, that status emoji **blinks
twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the
opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping
events animate in isolation, and the newest bottom toast cancels the previous one and replays
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence).
## Social, account & history surfaces ## Social, account & history surfaces
+4 -1
View File
@@ -606,10 +606,13 @@ table OpponentMovedEvent {
bag_len:int; bag_len:int;
} }
// NudgeEvent signals that a player nudged the recipient. // NudgeEvent signals that a player nudged the recipient. sender_name is the nudger's seat
// display-name snapshot, so the toast and the out-of-app push can name them (added trailing —
// an older reader still reads just game_id + from_user_id, and falls back to the plain phrase).
table NudgeEvent { table NudgeEvent {
game_id:string; game_id:string;
from_user_id:string; from_user_id:string;
sender_name:string;
} }
// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the // MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the
+12 -1
View File
@@ -57,8 +57,16 @@ func (rcv *NudgeEvent) FromUserId() []byte {
return nil return nil
} }
func (rcv *NudgeEvent) SenderName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func NudgeEventStart(builder *flatbuffers.Builder) { func NudgeEventStart(builder *flatbuffers.Builder) {
builder.StartObject(2) builder.StartObject(3)
} }
func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
@@ -66,6 +74,9 @@ func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffse
func NudgeEventAddFromUserId(builder *flatbuffers.Builder, fromUserId flatbuffers.UOffsetT) { func NudgeEventAddFromUserId(builder *flatbuffers.Builder, fromUserId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(fromUserId), 0) builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(fromUserId), 0)
} }
func NudgeEventAddSenderName(builder *flatbuffers.Builder, senderName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(senderName), 0)
}
func NudgeEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func NudgeEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+17 -4
View File
@@ -37,7 +37,7 @@ func Render(kind string, payload []byte, lang string) (Message, bool) {
return Message{Text: gameOverText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true return Message{Text: gameOverText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
case "nudge": case "nudge":
ev := scrabblefb.GetRootAsNudgeEvent(payload, 0) ev := scrabblefb.GetRootAsNudgeEvent(payload, 0)
return Message{Text: p.nudge, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true return Message{Text: nudgeText(ev, p), ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
case "match_found": case "match_found":
ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0) ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0)
return Message{Text: p.matchFound, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true return Message{Text: p.matchFound, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
@@ -76,6 +76,16 @@ func yourTurnText(ev *scrabblefb.YourTurnEvent, p phrases) string {
} }
} }
// nudgeText renders the nudge body, voiced as the sender who is waiting ("{name}: Waiting for
// your move 🤭"). It falls back to the plain phrase when the sender name is missing (an older
// backend, or an unresolved name).
func nudgeText(ev *scrabblefb.NudgeEvent, p phrases) string {
if name := string(ev.SenderName()); name != "" {
return fmt.Sprintf(p.nudgeBy, name)
}
return p.nudge
}
// gameOverText renders the "game over" body from the recipient's own perspective. // gameOverText renders the "game over" body from the recipient's own perspective.
func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string { func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string {
score := string(ev.ScoreLine()) score := string(ev.ScoreLine())
@@ -89,9 +99,9 @@ func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string {
} }
} }
// phrases is one language's message catalog. The yourTurn*/gameOver* entries are fmt format // phrases is one language's message catalog. The yourTurn*/gameOver*/nudgeBy entries are fmt
// strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved take (name); // format strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved and
// the gameOver* entries take (scoreLine). // nudgeBy take (name); the gameOver* entries take (scoreLine).
type phrases struct { type phrases struct {
yourTurn string yourTurn string
yourTurnPlay string yourTurnPlay string
@@ -102,6 +112,7 @@ type phrases struct {
gameOverLost string gameOverLost string
gameOverDraw string gameOverDraw string
nudge string nudge string
nudgeBy string
matchFound string matchFound string
invitation string invitation string
friendRequest string friendRequest string
@@ -119,6 +130,7 @@ var english = phrases{
gameOverLost: "Game over — you lost. Score %s", gameOverLost: "Game over — you lost. Score %s",
gameOverDraw: "Game over — a draw. Score %s", gameOverDraw: "Game over — a draw. Score %s",
nudge: "You were nudged — it's your turn.", nudge: "You were nudged — it's your turn.",
nudgeBy: "%s: Waiting for your move 🤭",
matchFound: "Your game is ready.", matchFound: "Your game is ready.",
invitation: "You have a new game invitation.", invitation: "You have a new game invitation.",
friendRequest: "You have a new friend request.", friendRequest: "You have a new friend request.",
@@ -136,6 +148,7 @@ var russian = phrases{
gameOverLost: "Игра окончена — вы проиграли. Счёт %s", gameOverLost: "Игра окончена — вы проиграли. Счёт %s",
gameOverDraw: "Игра окончена — ничья. Счёт %s", gameOverDraw: "Игра окончена — ничья. Счёт %s",
nudge: "Вас поторопили — ваш ход.", nudge: "Вас поторопили — ваш ход.",
nudgeBy: "%s: Жду Вашего хода 🤭",
matchFound: "Игра найдена.", matchFound: "Игра найдена.",
invitation: "Вас пригласили в игру.", invitation: "Вас пригласили в игру.",
friendRequest: "Вам пришла заявка в друзья.", friendRequest: "Вам пришла заявка в друзья.",
@@ -29,6 +29,17 @@ func nudgePayload(id string) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
func namedNudgePayload(id, name string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(id)
n := b.CreateString(name)
scrabblefb.NudgeEventStart(b)
scrabblefb.NudgeEventAddGameId(b, gid)
scrabblefb.NudgeEventAddSenderName(b, n)
b.Finish(scrabblefb.NudgeEventEnd(b))
return b.FinishedBytes()
}
func matchFoundPayload(id string) []byte { func matchFoundPayload(id string) []byte {
b := flatbuffers.NewBuilder(0) b := flatbuffers.NewBuilder(0)
gid := b.CreateString(id) gid := b.CreateString(id)
@@ -164,6 +175,23 @@ func TestRenderGameEvents(t *testing.T) {
} }
} }
// TestRenderNudgeNamed checks the nudge body names the sender when present (both languages) and
// falls back to the plain phrase — no stray format directive — when the name is absent.
func TestRenderNudgeNamed(t *testing.T) {
en, ok := Render("nudge", namedNudgePayload(gameID, "Ann"), "en")
if !ok || !strings.Contains(en.Text, "Ann") {
t.Errorf("en named nudge %q should name the sender", en.Text)
}
ru, _ := Render("nudge", namedNudgePayload(gameID, "Аня"), "ru")
if !strings.Contains(ru.Text, "Аня") {
t.Errorf("ru named nudge %q should name the sender", ru.Text)
}
plain, _ := Render("nudge", nudgePayload(gameID), "en")
if plain.Text == "" || strings.Contains(plain.Text, "%s") {
t.Errorf("unnamed nudge %q should be the plain phrase, not a format string", plain.Text)
}
}
func TestRenderNotify(t *testing.T) { func TestRenderNotify(t *testing.T) {
cases := map[string]struct { cases := map[string]struct {
subKind string subKind string
+13 -9
View File
@@ -6,15 +6,19 @@
</script> </script>
{#if app.toast} {#if app.toast}
<div <!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
class="toast {app.toast.kind}" the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
role="status" {#key app.toast.seq}
aria-live="polite" <div
in:fly={{ y: 32, duration: dur }} class="toast {app.toast.kind}"
out:fade={{ duration: dur }} role="status"
> aria-live="polite"
{app.toast.text} in:fly={{ y: 32, duration: dur }}
</div> out:fade={{ duration: dur }}
>
{app.toast.text}
</div>
{/key}
{/if} {/if}
<style> <style>
+14 -2
View File
@@ -34,8 +34,15 @@ fromUserId(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
senderName():string|null
senderName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
senderName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startNudgeEvent(builder:flatbuffers.Builder) { static startNudgeEvent(builder:flatbuffers.Builder) {
builder.startObject(2); builder.startObject(3);
} }
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
@@ -46,15 +53,20 @@ static addFromUserId(builder:flatbuffers.Builder, fromUserIdOffset:flatbuffers.O
builder.addFieldOffset(1, fromUserIdOffset, 0); builder.addFieldOffset(1, fromUserIdOffset, 0);
} }
static addSenderName(builder:flatbuffers.Builder, senderNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, senderNameOffset, 0);
}
static endNudgeEvent(builder:flatbuffers.Builder):flatbuffers.Offset { static endNudgeEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createNudgeEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, fromUserIdOffset:flatbuffers.Offset):flatbuffers.Offset { static createNudgeEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, fromUserIdOffset:flatbuffers.Offset, senderNameOffset:flatbuffers.Offset):flatbuffers.Offset {
NudgeEvent.startNudgeEvent(builder); NudgeEvent.startNudgeEvent(builder);
NudgeEvent.addGameId(builder, gameIdOffset); NudgeEvent.addGameId(builder, gameIdOffset);
NudgeEvent.addFromUserId(builder, fromUserIdOffset); NudgeEvent.addFromUserId(builder, fromUserIdOffset);
NudgeEvent.addSenderName(builder, senderNameOffset);
return NudgeEvent.endNudgeEvent(builder); return NudgeEvent.endNudgeEvent(builder);
} }
} }
+8 -2
View File
@@ -34,6 +34,9 @@ import type { BoardLabelMode } from './boardlabels';
export interface Toast { export interface Toast {
kind: 'error' | 'info'; kind: 'error' | 'info';
text: string; text: string;
/** A monotonic id bumped on every showToast, so the Toast component re-keys and replays its
* entrance — the freshest toast cancels the previous one and starts its own appear cycle. */
seq: number;
} }
export const app = $state<{ export const app = $state<{
@@ -95,6 +98,7 @@ export const app = $state<{
let unsubscribeStream: (() => void) | null = null; let unsubscribeStream: (() => void) | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null; let toastTimer: ReturnType<typeof setTimeout> | null = null;
let toastSeq = 0;
// Background/foreground tracking, to silence the reconnect banner during a normal app // Background/foreground tracking, to silence the reconnect banner during a normal app
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return. // suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
@@ -135,7 +139,7 @@ function goForeground(): void {
} }
export function showToast(text: string, kind: Toast['kind'] = 'info'): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text }; app.toast = { kind, text, seq: ++toastSeq };
if (toastTimer) clearTimeout(toastTimer); if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (app.toast = null), 4000); toastTimer = setTimeout(() => (app.toast = null), 4000);
} }
@@ -223,7 +227,9 @@ function openStream(): void {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info'); showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
} }
} else if (e.kind === 'nudge') { } else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info'); // Name the nudger (their per-game seat name, carried on the event), so the toast reads
// "<opponent>: …" like the your-turn toast; fall back to the plain phrase when absent.
showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') { } else if (e.kind === 'your_turn') {
// Name the player who moved just before this one (the previous seat in turn order), so the // Name the player who moved just before this one (the previous seat in turn order), so the
// toast reads the same in games with any number of players. Fall back to the bare label when // toast reads the same in games with any number of players. Fall back to the bare label when
+18
View File
@@ -54,6 +54,24 @@ describe('codec', () => {
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}'); expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
}); });
it('decodes a nudge event carrying the sender name', () => {
const b = new Builder(64);
const gid = b.createString('g1');
const from = b.createString('u2');
const name = b.createString('Ann');
fb.NudgeEvent.startNudgeEvent(b);
fb.NudgeEvent.addGameId(b, gid);
fb.NudgeEvent.addFromUserId(b, from);
fb.NudgeEvent.addSenderName(b, name);
b.finish(fb.NudgeEvent.endNudgeEvent(b));
expect(decodeEvent('nudge', b.asUint8Array())).toEqual({
kind: 'nudge',
gameId: 'g1',
fromUserId: 'u2',
senderName: 'Ann',
});
});
it('round-trips a feedback submit and decodes state + unread', () => { it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]); const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
+1 -1
View File
@@ -537,7 +537,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) }; return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
case 'nudge': { case 'nudge': {
const e = fb.NudgeEvent.getRootAsNudgeEvent(bb); const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()) }; return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()), senderName: s(e.senderName()) };
} }
case 'match_found': { case 'match_found': {
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb); const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
+5
View File
@@ -18,6 +18,11 @@ describe('i18n catalog', () => {
expect(translate('en', 'game.yourTurnBy', { name: 'Ann' })).toBe('Ann: Your turn!'); expect(translate('en', 'game.yourTurnBy', { name: 'Ann' })).toBe('Ann: Your turn!');
}); });
it('names the nudger in the cross-game nudge toast', () => {
expect(translate('ru', 'chat.nudgeBy', { name: 'Аня' })).toBe('Аня: Жду Вашего хода 🤭');
expect(translate('en', 'chat.nudgeBy', { name: 'Ann' })).toBe('Ann: Waiting for your move 🤭');
});
it('localizes move-history action labels', () => { it('localizes move-history action labels', () => {
expect(translate('ru', 'move.exchange')).toBe('обмен'); expect(translate('ru', 'move.exchange')).toBe('обмен');
expect(translate('ru', 'move.pass')).toBe('пас'); expect(translate('ru', 'move.pass')).toBe('пас');
+2 -1
View File
@@ -114,7 +114,8 @@ export const en = {
'chat.placeholder': 'Quick message…', 'chat.placeholder': 'Quick message…',
'chat.send': 'Send', 'chat.send': 'Send',
'chat.nudge': 'Waiting for your move!', 'chat.nudge': 'Waiting for your move 🤭',
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
'chat.nudgeAction': 'Nudge', 'chat.nudgeAction': 'Nudge',
'chat.awaitingReply': "Waiting for the opponent's reply", 'chat.awaitingReply': "Waiting for the opponent's reply",
'chat.empty': 'No messages yet.', 'chat.empty': 'No messages yet.',
+2 -1
View File
@@ -115,7 +115,8 @@ export const ru: Record<MessageKey, string> = {
'chat.placeholder': 'Короткое сообщение…', 'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить', 'chat.send': 'Отправить',
'chat.nudge': 'Жду вашего хода!', 'chat.nudge': 'Жду Вашего хода 🤭',
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
'chat.nudgeAction': 'Поторопить', 'chat.nudgeAction': 'Поторопить',
'chat.awaitingReply': 'Ждём реакцию соперника', 'chat.awaitingReply': 'Ждём реакцию соперника',
'chat.empty': 'Сообщений пока нет.', 'chat.empty': 'Сообщений пока нет.',
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { groupGames, isMyTurn } from './lobbysort'; import { gamePhase, groupGames, isMyTurn, shouldBlink } from './lobbysort';
import type { GameView, Seat } from './model'; import type { GameView, Seat } from './model';
const ME = 'me'; const ME = 'me';
@@ -82,3 +82,28 @@ describe('groupGames', () => {
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
}); });
}); });
describe('gamePhase', () => {
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
});
});
describe('shouldBlink', () => {
it('blinks on a transition into mine or finished', () => {
expect(shouldBlink('theirs', 'mine')).toBe(true);
expect(shouldBlink('mine', 'finished')).toBe(true);
expect(shouldBlink('theirs', 'finished')).toBe(true);
});
it('stays quiet on a transition into theirs, a no-op, or a first sighting', () => {
expect(shouldBlink('mine', 'theirs')).toBe(false);
expect(shouldBlink('mine', 'mine')).toBe(false);
expect(shouldBlink(undefined, 'mine')).toBe(false);
expect(shouldBlink(undefined, 'finished')).toBe(false);
});
});
+23
View File
@@ -12,6 +12,29 @@ export function isMyTurn(game: GameView, myId: string): boolean {
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
} }
/** LobbyPhase is a game's lobby bucket — which of the three card sections it currently sits in. */
export type LobbyPhase = 'mine' | 'theirs' | 'finished';
/**
* gamePhase reports a game's lobby bucket for myId: 'finished' for any non-active/open status,
* else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and
* drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'.
*/
export function gamePhase(game: GameView, myId: string): LobbyPhase {
if (game.status !== 'active' && game.status !== 'open') return 'finished';
return isMyTurn(game, myId) ? 'mine' : 'theirs';
}
/**
* shouldBlink reports whether a card's bucket change should fire the attention blink: only a
* transition into 'mine' (it became your turn) or 'finished' (the game ended) does — an
* opponent's-turn change is in-place. A first observation (prev === undefined) never blinks, so a
* cold render and games arriving from another screen stay quiet.
*/
export function shouldBlink(prev: LobbyPhase | undefined, next: LobbyPhase): boolean {
return prev !== undefined && prev !== next && (next === 'mine' || next === 'finished');
}
/** LobbyGroups holds the three ordered lobby sections. */ /** LobbyGroups holds the three ordered lobby sections. */
export interface LobbyGroups { export interface LobbyGroups {
yourTurn: GameView[]; yourTurn: GameView[];
+1 -1
View File
@@ -307,7 +307,7 @@ export type PushEvent =
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
| { kind: 'chat_message'; message: ChatMessage } | { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string } | { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string }
| { kind: 'match_found'; gameId: string; state?: StateView } | { kind: 'match_found'; gameId: string; state?: StateView }
| { kind: 'opponent_joined'; gameId: string; state?: StateView } | { kind: 'opponent_joined'; gameId: string; state?: StateView }
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView } | { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
+69 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte'; import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
@@ -10,7 +11,7 @@
import { resultBadge } from '../lib/result'; import { resultBadge } from '../lib/result';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { groupGames } from '../lib/lobbysort'; import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
@@ -54,12 +55,58 @@
void load(); void load();
}); });
$effect(() => { $effect(() => {
if (app.lastEvent) void load(); // Refetch on a real game event only — not the 10 s keep-alive heartbeat. Refetching on every
// heartbeat turned the lobby into a 10 s poll whose REST view of a just-committed opponent move
// could update (and blink) a card seconds before the matching your_turn event/toast arrived
// over the slower live stream; gating it keeps the card, its blink and the toast on one event.
if (app.lastEvent && app.lastEvent.kind !== 'heartbeat') void load();
}); });
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId)); const groups = $derived(groupGames(games, myId));
// 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
// in-place — no blink). All blink state is keyed by game id, so overlapping events stay
// isolated: every card owns its own blink window and re-key counter, with no shared timer to
// race. The nonce re-keys the emoji so a repeat blink on the same card restarts the animation.
const blinkingIds = new SvelteSet<string>();
const blinkNonce = new SvelteMap<string, number>();
const blinkTimers = new Map<string, ReturnType<typeof setTimeout>>();
const prevPhase = new Map<string, LobbyPhase>();
function blink(id: string): void {
if (app.reduceMotion) return; // a fade-blink is exactly what reduce-motion asks us to drop
clearTimeout(blinkTimers.get(id));
blinkNonce.set(id, (blinkNonce.get(id) ?? 0) + 1);
blinkingIds.add(id);
blinkTimers.set(
id,
setTimeout(() => {
blinkingIds.delete(id);
blinkTimers.delete(id);
}, 2000), // two 1 s fade cycles
);
}
$effect(() => {
// Diff each game's bucket against the last seen one and blink on a transition into
// mine/finished. A first observation seeds the baseline without blinking (see shouldBlink),
// so a cold render — or a card arriving from another screen — never blinks.
const seen = new Set<string>();
for (const g of games) {
seen.add(g.id);
const phase = gamePhase(g, myId);
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
prevPhase.set(g.id, phase);
}
for (const id of prevPhase.keys()) if (!seen.has(id)) prevPhase.delete(id);
});
onDestroy(() => {
for (const tmr of blinkTimers.values()) clearTimeout(tmr);
});
function opponents(g: GameView): string { function opponents(g: GameView): string {
// An auto-match game still waiting for an opponent shows the "searching" placeholder. // An auto-match game still waiting for an opponent shows the "searching" placeholder.
if (g.status === 'open') return t('game.searchingForOpponent'); if (g.status === 'open') return t('game.searchingForOpponent');
@@ -209,7 +256,10 @@
<span class="who">{opponents(g) || '—'}</span> <span class="who">{opponents(g) || '—'}</span>
<span class="sub">{scoreline(g)}</span> <span class="sub">{scoreline(g)}</span>
</span> </span>
<span class="emoji">{resultBadge(g, myId).emoji}</span> <!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
{#key blinkNonce.get(g.id) ?? 0}
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
{/key}
</button> </button>
{#if group.finished} {#if group.finished}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button> <button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
@@ -385,6 +435,21 @@
line-height: 1; line-height: 1;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* A twice-over fade (two 1 s cycles) drawing the eye to a card that just became your turn or
finished. Gated in script by app.reduceMotion, so the class is never applied under
reduce-motion. */
.emoji.blink {
animation: lobby-blink 1s ease-in-out 2;
}
@keyframes lobby-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.acts { .acts {
display: flex; display: flex;
gap: 8px; gap: 8px;