feat(nudge): name the sender; blink lobby cards; re-animate toasts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:
- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);
falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.
Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.
Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.
Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
This commit is contained in:
@@ -1589,3 +1589,9 @@ cannot submit; three-way admin filter.
|
||||
`account_stats` or a games query), falling back to their interface language
|
||||
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
|
||||
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.
|
||||
|
||||
@@ -1144,6 +1144,23 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
|
||||
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
|
||||
// (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.
|
||||
|
||||
@@ -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
|
||||
// acted (moved or chatted) since their last nudge, even within the hour.
|
||||
func TestNudgeCooldownResetsOnAction(t *testing.T) {
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
// Nudge tells userID that fromUserID nudged them in game gameID.
|
||||
func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
|
||||
// Nudge tells userID that fromUserID nudged them in game gameID. senderName is fromUserID's
|
||||
// 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)
|
||||
gid := b.CreateString(gameID.String())
|
||||
from := b.CreateString(fromUserID.String())
|
||||
name := b.CreateString(senderName)
|
||||
fb.NudgeEventStart(b)
|
||||
fb.NudgeEventAddGameId(b, gid)
|
||||
fb.NudgeEventAddFromUserId(b, from)
|
||||
fb.NudgeEventAddSenderName(b, name)
|
||||
b.Finish(fb.NudgeEventEnd(b))
|
||||
return Intent{UserID: userID, Kind: KindNudge, Payload: b.FinishedBytes(), EventID: eventID()}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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}}}
|
||||
|
||||
@@ -154,7 +154,10 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
||||
}
|
||||
svc.metrics.recordChat(ctx, kindNudge)
|
||||
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 {
|
||||
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ import (
|
||||
// private state.
|
||||
type GameReader interface {
|
||||
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
|
||||
// (active or finished); it gates the "befriend an opponent" request path.
|
||||
SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error)
|
||||
|
||||
+5
-2
@@ -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
|
||||
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. 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.
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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**
|
||||
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.
|
||||
|
||||
@@ -76,7 +76,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||
линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
|
||||
линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или
|
||||
завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход
|
||||
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
|
||||
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
|
||||
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
|
||||
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
|
||||
@@ -180,8 +182,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
|
||||
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
|
||||
хода. Nudge ожидаемого
|
||||
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
|
||||
push доставляется через платформу.
|
||||
соперника — не чаще раза в час (nudge — часть игрового чата); и всплывашка в приложении,
|
||||
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
||||
(«<соперник>: жду вашего хода»).
|
||||
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
|
||||
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
|
||||
|
||||
+6
-1
@@ -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
|
||||
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-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
|
||||
|
||||
|
||||
@@ -606,10 +606,13 @@ table OpponentMovedEvent {
|
||||
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 {
|
||||
game_id:string;
|
||||
from_user_id:string;
|
||||
sender_name:string;
|
||||
}
|
||||
|
||||
// MatchFoundEvent signals that an auto-match pairing (or robot substitution) started a game the
|
||||
|
||||
@@ -57,8 +57,16 @@ func (rcv *NudgeEvent) FromUserId() []byte {
|
||||
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) {
|
||||
builder.StartObject(2)
|
||||
builder.StartObject(3)
|
||||
}
|
||||
func NudgeEventAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
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) {
|
||||
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 {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
case "nudge":
|
||||
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":
|
||||
ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0)
|
||||
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.
|
||||
func gameOverText(ev *scrabblefb.GameOverEvent, p phrases) string {
|
||||
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
|
||||
// strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved take (name);
|
||||
// the gameOver* entries take (scoreLine).
|
||||
// phrases is one language's message catalog. The yourTurn*/gameOver*/nudgeBy entries are fmt
|
||||
// format strings: yourTurnPlay takes (name, word, scoreLine); yourTurnExchange/Pass/Moved and
|
||||
// nudgeBy take (name); the gameOver* entries take (scoreLine).
|
||||
type phrases struct {
|
||||
yourTurn string
|
||||
yourTurnPlay string
|
||||
@@ -102,6 +112,7 @@ type phrases struct {
|
||||
gameOverLost string
|
||||
gameOverDraw string
|
||||
nudge string
|
||||
nudgeBy string
|
||||
matchFound string
|
||||
invitation string
|
||||
friendRequest string
|
||||
@@ -119,6 +130,7 @@ var english = phrases{
|
||||
gameOverLost: "Game over — you lost. Score %s",
|
||||
gameOverDraw: "Game over — a draw. Score %s",
|
||||
nudge: "You were nudged — it's your turn.",
|
||||
nudgeBy: "%s: Waiting for your move 🤭",
|
||||
matchFound: "Your game is ready.",
|
||||
invitation: "You have a new game invitation.",
|
||||
friendRequest: "You have a new friend request.",
|
||||
@@ -136,6 +148,7 @@ var russian = phrases{
|
||||
gameOverLost: "Игра окончена — вы проиграли. Счёт %s",
|
||||
gameOverDraw: "Игра окончена — ничья. Счёт %s",
|
||||
nudge: "Вас поторопили — ваш ход.",
|
||||
nudgeBy: "%s: Жду Вашего хода 🤭",
|
||||
matchFound: "Игра найдена.",
|
||||
invitation: "Вас пригласили в игру.",
|
||||
friendRequest: "Вам пришла заявка в друзья.",
|
||||
|
||||
@@ -29,6 +29,17 @@ func nudgePayload(id string) []byte {
|
||||
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 {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
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) {
|
||||
cases := map[string]struct {
|
||||
subKind string
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
</script>
|
||||
|
||||
{#if app.toast}
|
||||
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
|
||||
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
|
||||
{#key app.toast.seq}
|
||||
<div
|
||||
class="toast {app.toast.kind}"
|
||||
role="status"
|
||||
@@ -15,6 +18,7 @@
|
||||
>
|
||||
{app.toast.text}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
@@ -34,8 +34,15 @@ fromUserId(optionalEncoding?:any):string|Uint8Array|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) {
|
||||
builder.startObject(2);
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
static addSenderName(builder:flatbuffers.Builder, senderNameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, senderNameOffset, 0);
|
||||
}
|
||||
|
||||
static endNudgeEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
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.addGameId(builder, gameIdOffset);
|
||||
NudgeEvent.addFromUserId(builder, fromUserIdOffset);
|
||||
NudgeEvent.addSenderName(builder, senderNameOffset);
|
||||
return NudgeEvent.endNudgeEvent(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ import type { BoardLabelMode } from './boardlabels';
|
||||
export interface Toast {
|
||||
kind: 'error' | 'info';
|
||||
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<{
|
||||
@@ -95,6 +98,7 @@ export const app = $state<{
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
let reconnectTimer: 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
|
||||
// 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 {
|
||||
app.toast = { kind, text };
|
||||
app.toast = { kind, text, seq: ++toastSeq };
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
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');
|
||||
}
|
||||
} 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') {
|
||||
// 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
|
||||
|
||||
@@ -54,6 +54,24 @@ describe('codec', () => {
|
||||
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', () => {
|
||||
const att = new Uint8Array([1, 2, 3, 4]);
|
||||
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
|
||||
|
||||
+1
-1
@@ -537,7 +537,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
|
||||
case 'nudge': {
|
||||
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': {
|
||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('i18n catalog', () => {
|
||||
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', () => {
|
||||
expect(translate('ru', 'move.exchange')).toBe('обмен');
|
||||
expect(translate('ru', 'move.pass')).toBe('пас');
|
||||
|
||||
@@ -114,7 +114,8 @@ export const en = {
|
||||
|
||||
'chat.placeholder': 'Quick message…',
|
||||
'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.awaitingReply': "Waiting for the opponent's reply",
|
||||
'chat.empty': 'No messages yet.',
|
||||
|
||||
@@ -115,7 +115,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
|
||||
'chat.placeholder': 'Короткое сообщение…',
|
||||
'chat.send': 'Отправить',
|
||||
'chat.nudge': 'Жду вашего хода!',
|
||||
'chat.nudge': 'Жду Вашего хода 🤭',
|
||||
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
|
||||
'chat.nudgeAction': 'Поторопить',
|
||||
'chat.awaitingReply': 'Ждём реакцию соперника',
|
||||
'chat.empty': 'Сообщений пока нет.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { groupGames, isMyTurn } from './lobbysort';
|
||||
import { gamePhase, groupGames, isMyTurn, shouldBlink } from './lobbysort';
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
const ME = 'me';
|
||||
@@ -82,3 +82,28 @@ describe('groupGames', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,29 @@ export function isMyTurn(game: GameView, myId: string): boolean {
|
||||
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. */
|
||||
export interface LobbyGroups {
|
||||
yourTurn: GameView[];
|
||||
|
||||
+1
-1
@@ -307,7 +307,7 @@ export type PushEvent =
|
||||
| { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
|
||||
| { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
|
||||
| { 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: 'opponent_joined'; gameId: string; state?: StateView }
|
||||
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 TabBar from '../components/TabBar.svelte';
|
||||
import { app, handleError, refreshFeedbackBadge } from '../lib/app.svelte';
|
||||
@@ -10,7 +11,7 @@
|
||||
import { resultBadge } from '../lib/result';
|
||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||
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';
|
||||
|
||||
let games = $state<GameView[]>([]);
|
||||
@@ -60,6 +61,48 @@
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
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 {
|
||||
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
|
||||
if (g.status === 'open') return t('game.searchingForOpponent');
|
||||
@@ -209,7 +252,10 @@
|
||||
<span class="who">{opponents(g) || '—'}</span>
|
||||
<span class="sub">{scoreline(g)}</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>
|
||||
{#if group.finished}
|
||||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||||
@@ -385,6 +431,21 @@
|
||||
line-height: 1;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user