Files
scrabble-game/ui/src/screens/Lobby.svelte
T
Ilia Denisov 12d128f1cc
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
feat(nudge): name the sender; blink lobby cards; re-animate toasts
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.
2026-06-16 19:09:24 +02:00

469 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
let incoming = $state<AccountRef[]>([]);
const guest = $derived(app.profile?.isGuest ?? true);
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
// operator feedback reply (the Settings → Info badge folds in here).
const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0));
async function load() {
try {
games = (await gateway.gamesList()).games;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
// invitations surface in their own lobby section above.
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
if (connection.online) void preloadGames(games);
} catch (e) {
handleError(e);
}
}
onMount(() => {
// Render instantly from the cached lists (so the screen does not "draw in" during
// the back-slide), then refresh in the background.
const cached = getLobby();
if (cached) {
games = cached.games;
invitations = cached.invitations;
incoming = cached.incoming;
}
void load();
});
$effect(() => {
if (app.lastEvent) 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');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖';
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
.join(', ');
}
function scoreline(g: GameView): string {
const me = g.seats.find((s) => s.accountId === myId);
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
// Hiding a finished game. The delete action sits behind each finished row and is
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
let drag: { id: string; x0: number; y0: number } | null = null;
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
let swiped = false;
function onRowDown(e: PointerEvent, id: string): void {
swiped = false;
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY };
}
function onRowUp(e: PointerEvent, finished: boolean): void {
if (!drag) return;
const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0;
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true;
revealedId = dx < 0 ? drag.id : null;
}
drag = null;
}
function openGame(g: GameView): void {
if (swiped) {
swiped = false;
return;
}
if (revealedId === g.id) {
revealedId = null;
return;
}
navigate(`/game/${g.id}`);
}
function toggleReveal(id: string): void {
revealedId = revealedId === id ? null : id;
}
async function hide(id: string): Promise<void> {
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming });
try {
await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming });
handleError(e);
}
}
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
if (r.gameId) navigate(`/game/${r.gameId}`);
else await load();
} catch (e) {
handleError(e);
}
}
const declineInvite = (inv: Invitation) => act(() => gateway.invitationDecline(inv.id));
const cancelInvite = (inv: Invitation) => act(() => gateway.invitationCancel(inv.id));
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
} catch (e) {
handleError(e);
}
}
const variantKey: Record<string, MessageKey> = {
scrabble_en: 'new.english',
scrabble_ru: 'new.russian',
erudit_ru: 'new.erudit',
};
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
<div class="lobby">
{#if invitations.length}
<section>
<h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)}
<div class="invite">
<span class="emoji">💌</span>
<span class="info">
{#if inv.inviter.accountId === myId}
<span class="who">{t('invitations.with', { names: inv.invitees.map((i) => i.displayName).join(', ') })}</span>
<span class="sub">{t('invitations.waiting')}</span>
{:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
{/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
{:else}
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
{/if}
</span>
</div>
{/each}
</section>
{/if}
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn, finished: false }, { h: 'lobby.theirTurn', list: groups.theirTurn, finished: false }, { h: 'lobby.finishedGames', list: groups.finished, finished: true }] as group (group.h)}
{#if group.list.length}
<section>
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)}
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
<div class="row">
<button
class="open"
onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished)}
onclick={() => openGame(g)}
>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{scoreline(g)}</span>
</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>
{:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
of the tab order / a11y tree since the .open button already does the same. -->
<button class="chev" onclick={() => openGame(g)} tabindex={-1} aria-hidden="true"></button>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}
{/each}
{#if !games.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
</div>
{#snippet tabbar()}
<TabBar>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" onclick={() => navigate('/stats')}>
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>
</button>
</TabBar>
{/snippet}
</Screen>
<style>
.lobby {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 18px;
}
h2 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px;
}
.empty {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
.invite {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
user-select: none;
}
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows. */
.list {
display: flex;
flex-direction: column;
}
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
own opaque background hides that action until revealed. */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
.del {
position: absolute;
inset: 0 0 0 auto;
width: 64px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--danger);
color: #fff;
font-size: 1.1rem;
}
.row {
position: relative;
display: flex;
align-items: center;
gap: 2px;
background: var(--bg);
transform: translateX(0);
transition: transform 0.18s ease;
}
.rowwrap.revealed .row {
transform: translateX(-64px);
}
.open {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
text-align: left;
padding: 10px 6px;
border: none;
background: none;
color: var(--text);
user-select: none;
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
}
/* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on
both tappable areas (the open body and the chevron / kebab) and the held
:active background, which added nothing and only spoiled the look. */
.open,
.chev,
.kebab {
-webkit-tap-highlight-color: transparent;
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 10px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.chev {
flex: 0 0 auto;
width: 30px;
padding: 10px 0;
border: none;
background: none;
text-align: center;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.who {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
.emoji {
font-size: 1.8rem;
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;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>