Merge pull request 'fix(offline): cold-boot offline from the persisted session + profile' (#199) from feature/offline-boot into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m41s
This commit was merged in pull request #199.
This commit is contained in:
@@ -1259,7 +1259,11 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
|||||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||||
the ad-banner slot. The gateway registers the `.webmanifest` MIME type
|
the ad-banner slot. A **cold launch already in offline mode** boots from the persisted session and
|
||||||
|
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||||
|
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||||
|
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||||
|
and boots online instead. The gateway registers the `.webmanifest` MIME type
|
||||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import { parseStartParam } from './deeplink';
|
|||||||
import {
|
import {
|
||||||
clearOnboarding,
|
clearOnboarding,
|
||||||
clearSession,
|
clearSession,
|
||||||
|
clearProfile,
|
||||||
|
loadProfile,
|
||||||
|
saveProfile,
|
||||||
loadOnboarding,
|
loadOnboarding,
|
||||||
loadPrefs,
|
loadPrefs,
|
||||||
loadSession,
|
loadSession,
|
||||||
@@ -53,6 +56,8 @@ import {
|
|||||||
} from './session';
|
} from './session';
|
||||||
import type { CoachSeries } from './coachmark';
|
import type { CoachSeries } from './coachmark';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
|
import { offlineMode } from './offline.svelte';
|
||||||
|
import { shouldBootOffline } from './offline';
|
||||||
import { isConnectionCode } from './retry';
|
import { isConnectionCode } from './retry';
|
||||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||||
import { advanceCached } from './gamedelta';
|
import { advanceCached } from './gamedelta';
|
||||||
@@ -460,6 +465,7 @@ export async function refreshProfile(): Promise<void> {
|
|||||||
if (!app.session) return;
|
if (!app.session) return;
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort; the banner just stays as it was until the next fetch.
|
// Best-effort; the banner just stays as it was until the next fetch.
|
||||||
}
|
}
|
||||||
@@ -517,6 +523,11 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
await saveSession(s);
|
await saveSession(s);
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
// Persist the profile so a later offline cold start can launch from it (its variant
|
||||||
|
// preferences, dictionary versions and display name) without a network fetch. Snapshot to a
|
||||||
|
// plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
|
||||||
|
// the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
|
||||||
|
void saveProfile($state.snapshot(app.profile));
|
||||||
// The live interface language follows the device — the explicit local choice (saved in
|
// The live interface language follows the device — the explicit local choice (saved in
|
||||||
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
||||||
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
||||||
@@ -790,6 +801,24 @@ export async function bootstrap(): Promise<void> {
|
|||||||
// and skipped in the mock build; see lib/pwa.svelte.
|
// and skipped in the mock build; see lib/pwa.svelte.
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
|
|
||||||
|
// Deliberate offline mode carried over from a prior session: with no network the session adoption
|
||||||
|
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
|
||||||
|
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
|
||||||
|
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
|
||||||
|
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
|
||||||
|
// with no cached profile is unreachable: enabling offline requires a prior online session.)
|
||||||
|
if (offlineMode.active) {
|
||||||
|
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
|
||||||
|
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
|
||||||
|
gateway.setToken(offlineSession!.token);
|
||||||
|
app.session = offlineSession!;
|
||||||
|
app.profile = offlineProfile!;
|
||||||
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||||
@@ -1023,6 +1052,7 @@ export async function logout(): Promise<void> {
|
|||||||
app.splashDone = false;
|
app.splashDone = false;
|
||||||
gateway.setToken(null);
|
gateway.setToken(null);
|
||||||
await clearSession();
|
await clearSession();
|
||||||
|
await clearProfile();
|
||||||
app.session = null;
|
app.session = null;
|
||||||
app.profile = null;
|
app.profile = null;
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import type { Variant } from '../model';
|
|||||||
export interface Seat {
|
export interface Seat {
|
||||||
kind: 'human' | 'robot';
|
kind: 'human' | 'robot';
|
||||||
name: string;
|
name: string;
|
||||||
|
/** The player's real account id for a human seat, so the client identifies "you" and the correct
|
||||||
|
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
|
||||||
|
accountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
|
/** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ describe('LocalSource.list', () => {
|
|||||||
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
|
expect(list.every((g) => g.vsAi && g.status === 'active')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries the human seat account id into the GameView so the client identifies "you"', async () => {
|
||||||
|
const src = new LocalSource();
|
||||||
|
await src.create({
|
||||||
|
...opts('local:me', 3n),
|
||||||
|
seats: [
|
||||||
|
{ kind: 'human', name: 'You', accountId: 'acct-42' },
|
||||||
|
{ kind: 'robot', name: 'Robot' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const [g] = await src.list();
|
||||||
|
expect(g.seats[0].accountId).toBe('acct-42'); // the human's real id, matched against the session
|
||||||
|
expect(g.seats[1].accountId).not.toBe('acct-42'); // the robot keeps a synthetic id
|
||||||
|
});
|
||||||
|
|
||||||
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
|
it('reconstructs a game persisted by a previous session (not in the source cache)', async () => {
|
||||||
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
|
// Seed the store via one source, then list from a fresh source whose in-memory cache is empty.
|
||||||
await new LocalSource().create(opts('local:old', 7n));
|
await new LocalSource().create(opts('local:old', 7n));
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const now = nowUnix();
|
const now = nowUnix();
|
||||||
const record = serializeGame(game, {
|
const record = serializeGame(game, {
|
||||||
id: opts.id,
|
id: opts.id,
|
||||||
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name })),
|
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })),
|
||||||
createdAtUnix: now,
|
createdAtUnix: now,
|
||||||
updatedAtUnix: now,
|
updatedAtUnix: now,
|
||||||
robotLastMoveAtUnix: now,
|
robotLastMoveAtUnix: now,
|
||||||
@@ -341,7 +341,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const { game, record } = entry;
|
const { game, record } = entry;
|
||||||
const seats: Seat[] = record.seats.map((s, i) => ({
|
const seats: Seat[] = record.seats.map((s, i) => ({
|
||||||
seat: i,
|
seat: i,
|
||||||
accountId: `${LOCAL_ID_PREFIX}${s.kind}:${i}`,
|
accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`,
|
||||||
displayName: s.name,
|
displayName: s.name,
|
||||||
score: game.scoreOf(i),
|
score: game.scoreOf(i),
|
||||||
hintsUsed: 0,
|
hintsUsed: 0,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline';
|
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline } from './offline';
|
||||||
import type { Variant } from './model';
|
import type { Variant } from './model';
|
||||||
|
|
||||||
// A minimal in-memory localStorage for the persistence tests (node has none).
|
// A minimal in-memory localStorage for the persistence tests (node has none).
|
||||||
@@ -45,4 +45,11 @@ describe('offline mode helpers', () => {
|
|||||||
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
|
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
|
||||||
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
|
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
|
||||||
|
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
|
||||||
|
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
|
||||||
|
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
|
||||||
|
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,3 +58,13 @@ export function offlinePreloadEligible(opts: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
|
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shouldBootOffline decides whether a cold start skips the network and launches straight into
|
||||||
|
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
|
||||||
|
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
|
||||||
|
* the app must boot online once first — the caller then drops the sticky flag and continues online.
|
||||||
|
*/
|
||||||
|
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
|
||||||
|
return opts.offlineActive && opts.hasSession && opts.hasProfile;
|
||||||
|
}
|
||||||
|
|||||||
+20
-1
@@ -3,7 +3,7 @@
|
|||||||
// re-login), with a localStorage fallback. Losing the store just means re-login —
|
// re-login), with a localStorage fallback. Losing the store just means re-login —
|
||||||
// acceptable, and for a guest it simply mints a fresh guest.
|
// acceptable, and for a guest it simply mints a fresh guest.
|
||||||
|
|
||||||
import type { Session } from './model';
|
import type { Session, Profile } from './model';
|
||||||
import type { ThemePref } from './theme';
|
import type { ThemePref } from './theme';
|
||||||
import type { Locale } from './i18n/catalog';
|
import type { Locale } from './i18n/catalog';
|
||||||
import type { BoardLabelMode } from './boardlabels';
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
@@ -119,6 +119,25 @@ export function clearSession(): Promise<void> {
|
|||||||
return kvDel(SESSION_KEY);
|
return kvDel(SESSION_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROFILE_KEY = 'profile';
|
||||||
|
|
||||||
|
/** loadProfile reads the last persisted profile (or null). It backs the offline cold-boot: with no
|
||||||
|
* network, bootstrap uses it instead of fetching a fresh one. */
|
||||||
|
export function loadProfile(): Promise<Profile | null> {
|
||||||
|
return kvGet<Profile>(PROFILE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** saveProfile persists the caller's own profile so a later offline launch can start from it (its
|
||||||
|
* variant preferences, dictionary versions and display name), best-effort. */
|
||||||
|
export function saveProfile(p: Profile): Promise<void> {
|
||||||
|
return kvSet(PROFILE_KEY, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearProfile drops the persisted profile (on logout, with the session). */
|
||||||
|
export function clearProfile(): Promise<void> {
|
||||||
|
return kvDel(PROFILE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export interface Prefs {
|
export interface Prefs {
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { GatewayError, type GatewayClient } from './client';
|
|||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { offlineMode } from './offline.svelte';
|
||||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
import { maintenanceRetryMs } from './maintenance';
|
import { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
@@ -19,6 +20,13 @@ import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'
|
|||||||
const MAX_RETRIES = 6;
|
const MAX_RETRIES = 6;
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// The offline kill switch: in deliberate offline mode every network operation is refused before it
|
||||||
|
// leaves the device, so offline truly means offline regardless of any UI affordance that slips
|
||||||
|
// through. The local (device-only) game path never uses this transport, so it is unaffected.
|
||||||
|
function assertOnline(): void {
|
||||||
|
if (offlineMode.active) throw new GatewayError('offline');
|
||||||
|
}
|
||||||
|
|
||||||
export function createTransport(baseUrl: string): GatewayClient {
|
export function createTransport(baseUrl: string): GatewayClient {
|
||||||
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
|
||||||
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
|
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
|
||||||
@@ -32,6 +40,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
const reachabilityProbe = async (): Promise<void> => {
|
const reachabilityProbe = async (): Promise<void> => {
|
||||||
if (!token) throw new Error('no session');
|
if (!token) throw new Error('no session');
|
||||||
|
assertOnline();
|
||||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
};
|
};
|
||||||
registerProbe(reachabilityProbe);
|
registerProbe(reachabilityProbe);
|
||||||
@@ -43,6 +52,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||||
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
|
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
|
||||||
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
|
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
|
||||||
|
assertOnline();
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +92,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
|
|
||||||
async fetchDict(variant, version, opts) {
|
async fetchDict(variant, version, opts) {
|
||||||
if (!token) throw new Error('fetchDict: no session');
|
if (!token) throw new Error('fetchDict: no session');
|
||||||
|
assertOnline();
|
||||||
// Low priority so the browser schedules the (large) dictionary behind the game's own
|
// Low priority so the browser schedules the (large) dictionary behind the game's own
|
||||||
// requests — the move eval, state and live events — on a slow link (EDGE). Ignored
|
// requests — the move eval, state and live events — on a slow link (EDGE). Ignored
|
||||||
// where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The
|
// where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The
|
||||||
@@ -99,6 +110,7 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
|
|
||||||
async reportLocalEval(counts) {
|
async reportLocalEval(counts) {
|
||||||
if (!token) throw new Error('reportLocalEval: no session');
|
if (!token) throw new Error('reportLocalEval: no session');
|
||||||
|
assertOnline();
|
||||||
// keepalive so a batch flushed as the app is backgrounded still reaches the edge.
|
// keepalive so a batch flushed as the app is backgrounded still reaches the edge.
|
||||||
const res = await fetch(`${origin}/metrics/local-eval`, {
|
const res = await fetch(`${origin}/metrics/local-eval`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -319,6 +331,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
},
|
},
|
||||||
|
|
||||||
subscribe(onEvent, onError) {
|
subscribe(onEvent, onError) {
|
||||||
|
// No live stream in offline mode (the kill switch); return an inert unsubscribe.
|
||||||
|
if (offlineMode.active) return () => {};
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { aboutContent } from '../lib/aboutContent';
|
import { aboutContent } from '../lib/aboutContent';
|
||||||
import { onExternalLinkClick } from '../lib/links';
|
import { onExternalLinkClick } from '../lib/links';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
<p class="muted">{t('about.version', { v: version })}</p>
|
<p class="muted">{t('about.version', { v: version })}</p>
|
||||||
|
|
||||||
{#if !(app.profile?.isGuest ?? true)}
|
{#if !(app.profile?.isGuest ?? true)}
|
||||||
<button class="feedback" onclick={() => navigate('/feedback')}>
|
<button class="feedback" disabled={offlineMode.active} onclick={() => navigate('/feedback')}>
|
||||||
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
|
{t('feedback.title')}{#if app.feedbackReplyUnread}<span class="fbadge">1</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@
|
|||||||
seed: randomSeed(),
|
seed: randomSeed(),
|
||||||
multipleWords: multipleWordsForRequest(v, multipleWords),
|
multipleWords: multipleWordsForRequest(v, multipleWords),
|
||||||
seats: [
|
seats: [
|
||||||
{ kind: 'human', name: app.profile?.displayName ?? 'You' },
|
// The human seat carries the real account id so the game screen and the lobby identify
|
||||||
|
// "you" and the correct turn exactly as for an online game (the robot uses a synthetic id).
|
||||||
|
{ kind: 'human', name: app.profile?.displayName ?? 'You', accountId: app.session?.userId },
|
||||||
{ kind: 'robot', name: 'Robot' },
|
{ kind: 'robot', name: 'Robot' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -110,7 +112,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (guest) return;
|
// Offline mode offers only the local vs_ai flow (the friends section is hidden), and the network
|
||||||
|
// is off, so skip the friend fetch — it would be refused by the transport and toast an error.
|
||||||
|
if (guest || offlineMode.active) return;
|
||||||
try {
|
try {
|
||||||
friends = await gateway.friendsList();
|
friends = await gateway.friendsList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Friends from './Friends.svelte';
|
import Friends from './Friends.svelte';
|
||||||
import About from './About.svelte';
|
import About from './About.svelte';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
|
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
|
||||||
@@ -23,6 +24,12 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (guest && tab === 'friends') tab = 'settings';
|
if (guest && tab === 'friends') tab = 'settings';
|
||||||
});
|
});
|
||||||
|
// Offline mode has no network, so the Profile and Friends surfaces (which read/save over the
|
||||||
|
// network) are disabled — fall back to Settings if the hub is on one (a deep-link or a live flip).
|
||||||
|
// Settings stays reachable: it holds the online/offline toggle.
|
||||||
|
$effect(() => {
|
||||||
|
if (offlineMode.active && (tab === 'profile' || tab === 'friends')) tab = 'settings';
|
||||||
|
});
|
||||||
|
|
||||||
const titleKey: Record<SettingsTab, MessageKey> = {
|
const titleKey: Record<SettingsTab, MessageKey> = {
|
||||||
settings: 'settings.title',
|
settings: 'settings.title',
|
||||||
@@ -48,11 +55,11 @@
|
|||||||
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
||||||
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
|
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
<button class="tab" class:active={tab === 'profile'} disabled={offlineMode.active} onclick={() => (tab = 'profile')}>
|
||||||
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
|
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{#if !guest}
|
{#if !guest}
|
||||||
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
|
<button class="tab" class:active={tab === 'friends'} disabled={offlineMode.active} onclick={() => (tab = 'friends')}>
|
||||||
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
|
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user