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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user