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