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:
@@ -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>
|
||||
|
||||
@@ -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