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

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:
Ilia Denisov
2026-06-16 19:09:24 +02:00
parent e9dfa4ccb8
commit 12d128f1cc
26 changed files with 331 additions and 36 deletions
+13 -9
View File
@@ -6,15 +6,19 @@
</script>
{#if app.toast}
<div
class="toast {app.toast.kind}"
role="status"
aria-live="polite"
in:fly={{ y: 32, duration: dur }}
out:fade={{ duration: dur }}
>
{app.toast.text}
</div>
<!-- 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"
aria-live="polite"
in:fly={{ y: 32, duration: dur }}
out:fade={{ duration: dur }}
>
{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 }
+64 -3
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[]>([]);
@@ -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;