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
(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.
+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
}
// 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.
+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
// acted (moved or chatted) since their last nudge, even within the hour.
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()}
}
// 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()}
}
+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) {
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}}}
+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)
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
}
+4
View File
@@ -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
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
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.
+6 -3
View File
@@ -76,7 +76,9 @@ nudge) приходят от бота **этой партии** — по язы
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями. Завершённую партию можно **убрать из своего списка**:
линиями-разделителями. Пока лобби открыто и партия из списка **переходит на твой ход или
завершается**, её значок статуса **дважды моргает**, привлекая внимание (переход на ход
соперника — без моргания, применяется на месте). Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
@@ -180,8 +182,9 @@ nudge) приходят от бота **этой партии** — по язы
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
хода. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу.
соперника — не чаще раза в час (nudge — часть игрового чата); и всплывашка в приложении,
и внеприложенческий 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
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
+4 -1
View File
@@ -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
+12 -1
View File
@@ -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()
}
+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
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
+4
View File
@@ -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>
+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;
}
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);
}
}
+8 -2
View File
@@ -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
+18
View File
@@ -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
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)) };
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);
+5
View File
@@ -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('пас');
+2 -1
View File
@@ -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.',
+2 -1
View File
@@ -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': 'Сообщений пока нет.',
+26 -1
View File
@@ -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);
});
});
+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;
}
/** 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
View File
@@ -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 }
+69 -4
View File
@@ -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[]>([]);
@@ -54,12 +55,58 @@
void load();
});
$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 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 +256,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 +435,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;