Files
scrabble-game/ui/src/screens/Lobby.svelte
T
Ilia Denisov 2f70ef1b85
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
fix(offline): route word-check through the game source; reload lobby on offline flip
Two offline bugs found on the contour:
- The word-check screen (CheckScreen) called gateway.gameState/checkWord
  directly, so in an offline (local) game it hit the network and errored
  ("something went wrong"). Route both through gameSource(id) — the local
  source answers from the device dawg. The complaint control (online-only,
  no offline backend) is hidden for a local game.
- The lobby did not react to the offline-mode toggle (which lives in Settings,
  so the lobby can stay mounted): an online game lingered until the next
  reload. Reload on an offlineMode flip so entering offline immediately shows
  only device-local games.

Cold offline launch hanging on the splash (boot still fetches the profile over
the network) is the separate C2 offline-boot follow-up (needs a persisted
profile + a boot short-circuit).
2026-07-06 14:04:23 +02:00

635 lines
23 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 Modal from '../components/Modal.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
import { localSource } from '../lib/gamesource';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
let incoming = $state<AccountRef[]>([]);
// True when the player has reached the simultaneous quick-game cap: the "New Game" tab is
// disabled and a notice shows under the lists. It rides the games.list response (which the
// lobby refetches on entry and on every game event) and the cached snapshot, so it renders
// in its last known state instantly and refreshes in the background. Optimistic default
// (enabled) for the very first, uncached load; the backend gate is the authority.
let atGameLimit = $state(false);
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() {
// Deliberate offline mode: never touch the network. The lobby shows only the device-local
// vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online
// games, invitations or incoming requests to fetch.
if (offlineMode.active) {
games = await localSource.list();
invitations = [];
incoming = [];
atGameLimit = false;
app.notifications = 0;
setLobby({ games, invitations, incoming, atGameLimit });
app.lobbyReady = true;
return;
}
try {
const list = await gateway.gamesList();
games = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
atGameLimit = list.atGameLimit;
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, atGameLimit });
// 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);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
kickDictPreload(app.profile, true);
}
} catch (e) {
handleError(e);
} finally {
// The first cold load has settled (success or error): release the loading splash. Set
// unconditionally on every refresh — it is an idempotent latch, reset only on logout.
app.lobbyReady = true;
}
}
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;
atGameLimit = cached.atGameLimit;
}
void load();
});
$effect(() => {
// Refetch on a real game event only — not the 10 s keep-alive heartbeat. Refetching on every
// heartbeat turned the lobby into a 10 s poll whose REST view of a just-committed opponent move
// could update (and blink) a card seconds before the matching your_turn event/toast arrived
// over the slower live stream; gating it keeps the card, its blink and the toast on one event.
if (app.lastEvent && app.lastEvent.kind !== 'heartbeat') void load();
});
// Reload when the deliberate offline mode flips (the toggle lives in Settings, so the lobby can
// stay mounted across the change): entering offline must immediately drop the online games and
// show only the device-local ones, and leaving it must refetch the online lobby. Guarded so the
// initial run — already covered by onMount — does not double-load.
let wasOffline = offlineMode.active;
$effect(() => {
const now = offlineMode.active;
if (now !== wasOffline) {
wasOffline = now;
void load();
}
});
const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId, app.chatUnread));
// 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(', ');
}
// 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, atGameLimit });
try {
await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming, atGameLimit });
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);
}
}
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
// in-game resign confirmation) rather than declining on the first tap.
let declineTarget = $state<Invitation | null>(null);
function confirmDecline() {
const inv = declineTarget;
declineTarget = null;
if (inv) declineInvite(inv);
}
</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="vrow">
{#if VARIANT_FLAG[inv.variant]}
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
{:else}
<img class="vflag-img" src="flag-ussr.svg" alt="" />
{/if}
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
</span>
{/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={t('invitations.cancel')}></button>
{:else}
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}></button>
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={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)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<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">
<span class="who-name">{opponents(g) || '—'}</span>
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span>
<span class="sub scoreline"
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myId, s)}{#if i > 0}<span class="sep">{' : '}</span
>{/if}<span
class="num"
class:win={standing === 'win'}
class:lose={standing === 'lose'}>{s.score}</span
>{/each}</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}
{#if atGameLimit}
<p class="limit">{t('lobby.limitReached')}</p>
{/if}
</div>
{#if declineTarget}
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
</div>
</Modal>
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
<span class="sq" data-coach="lobby-stats">✏️</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq" data-coach="lobby-settings">⚙️{#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;
}
/* A plain, low-emphasis line under the lists when the simultaneous-game cap is reached. */
.limit {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
text-align: center;
}
.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;
}
/* The middle column grows; the envelope and the action column stay at their natural width. */
.invite .info {
flex: 1;
}
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
.vrow {
display: flex;
align-items: baseline;
gap: 6px;
}
.vflag {
font-size: 1.1rem;
line-height: 1;
}
.vflag-img {
width: 1.3rem;
height: auto;
border-radius: 2px;
align-self: center;
}
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
.iconbtn {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.iconbtn:active {
background: var(--bg-elev);
}
/* 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(--bg-elev);
color: var(--text);
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: 5px 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 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: 600;
}
.who-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread
message, a softer amber when only nudges are unread. */
.unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.unread-dot.nudge {
background: var(--warn);
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
/* The score line is bold and a touch smaller than the surrounding muted sub-text. */
.scoreline {
font-weight: 700;
font-size: 0.8rem;
}
/* In-progress score colours (see scoreStanding): the viewer's number greens when leading or
tied, reds when losing; an opponent's number greens only when it ties the viewer (an equal
score paints both green). A fresh 0:0 board and the separators keep the muted .sub colour. */
.num.win {
color: var(--ok);
}
.num.lose {
color: var(--danger);
}
.emoji {
font-size: 1.35rem;
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;
}
}
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
.acts {
display: flex;
flex-direction: column;
gap: 10px;
flex: 0 0 auto;
justify-content: center;
}
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
</style>