feat(ui): explicit offline state inside an online game (+ offline word check)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped

When an online game loses the connection the game screen now says so and freezes
instead of silently greying out: a "connection lost" banner appears and the rack,
the move controls, the add-friend/block controls and the chat/dictionary entry all
disable (a started move stays a draft, committed by the player on reconnect). It is
driven off the net-state machine (netState.offline), so it also covers the
Telegram/VK mini-apps, where a lost connection was previously mute in-game.

If the player is already in the dictionary when the drop happens, the word check
falls back to the game's pinned on-device dictionary (exact when that dawg is
cached; "unavailable offline" otherwise) and the network-only complaint + external
look-up hide. Chat, when already open, keeps its existing read-only degrade
(send/nudge disable). New pure helper localWordCheck is unit-tested; the in-game
gating gets an e2e.
This commit is contained in:
Ilia Denisov
2026-07-14 08:53:18 +02:00
parent c34e2a8dbf
commit 28afcff551
11 changed files with 212 additions and 11 deletions
+23 -2
View File
@@ -15,6 +15,7 @@
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { netState } from '../lib/netstate.svelte';
import { maybeShowInterstitial } from '../lib/ads';
import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
@@ -63,6 +64,10 @@
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
// toasts a blocked call would otherwise raise).
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
// offlineInGame marks an online game that has lost the connection (confirmed offline, on any
// platform including Telegram/VK): the play area shows an explicit "connection lost" banner and
// the tray/actions freeze. It follows netState (not offlineMode, which is inert in the mini-apps).
const offlineInGame = $derived(!isLocalGameId(id) && netState.offline);
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null;
@@ -1463,6 +1468,7 @@
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
if (view?.game.vsAi || isLocalGameId(id)) return false;
return (
netReady &&
!!s.accountId &&
!app.profile?.isGuest &&
s.accountId !== app.session?.userId &&
@@ -1476,7 +1482,7 @@
// An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi || isLocalGameId(id)) return false;
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
return netReady && !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
}
</script>
@@ -1490,6 +1496,9 @@
selfInset={landscape}
>
{#if view}
{#if offlineInGame}
<div class="offline-banner" role="status">{t('game.offlineBanner')}</div>
{/if}
{#if landscape}
<div class="game-land">
<div class="leftpane">
@@ -1607,7 +1616,7 @@
— so drop the entry only when a local game is finished; an active local game keeps it for
the Dictionary. An online game always keeps it (chat outlives the game). -->
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
<button class="hicon" disabled={!netReady} onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button>
{/if}
@@ -1694,6 +1703,7 @@
draggingId={reorderDragId}
dropIndex={reorderTo}
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
frozen={!netReady}
ondown={onRackDown}
/>
{/if}
@@ -2178,6 +2188,17 @@
color: var(--text-muted);
padding: 40px;
}
/* An online game that lost the connection: a slim, non-blocking strip at the top of the play area.
A solid token background (not color-mix) keeps it visible on the old-WebView floor. */
.offline-banner {
padding: 7px var(--pad);
text-align: center;
font-size: 0.85rem;
line-height: 1.25;
color: var(--text);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.ghost {
position: fixed;
width: 40px;