feat(offline): background-preload dictionaries for offline readiness
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
An installed PWA (standalone web + confirmed email) warms the dictionaries for the player's enabled variants while online — on lobby entry and on a variant-preference change — using the per-variant version the profile now advertises, so a later switch to offline mode already has the data. - dict/preload.ts: pure preloadDicts (retry + linear backoff, honours the session dictionary miss-breaker); node-tested. - dict/preloadrun.ts: lazy browser orchestration (real getDawg), imported dynamically so the loader and move generator stay out of the main bundle. - offline.svelte.ts: kickDictPreload, gated by the pure, tested offlinePreloadEligible, plus the reactive first-lobby preload warning. - Header shows a poor-connection notice in the ad-banner slot on a first-lobby preload failure (offline.preloadWarning, en/ru). - App-entry bundle budget 112->113 for the irreducible main-side wiring (documented in bundle-size.mjs); the heavy parts remain lazy chunks. Docs: ARCHITECTURE offline-mode + dict-preload mechanism.
This commit is contained in:
+10
-1
@@ -1243,7 +1243,16 @@ browser-language detection — crawlers render with arbitrary languages). The fa
|
||||
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
||||
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
||||
installability requirement (a registered SW, needed for install on Android) and is the single
|
||||
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type
|
||||
growth point for the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the
|
||||
switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`,
|
||||
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
|
||||
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
|
||||
— 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
|
||||
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
|
||||
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
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
|
||||
@@ -20,10 +20,12 @@ import { join } from 'node:path';
|
||||
const DIST = 'dist';
|
||||
|
||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
||||
// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection /
|
||||
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy
|
||||
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 112, shared: 30, landing: 5 };
|
||||
// wiring, to 112 for the PWA install feature, then to 113 for the offline-mode wiring: the
|
||||
// dictionary-preload trigger and the offline-state reactives live in the app entry (the Header
|
||||
// reads them, the lobby/profile screens fire the trigger), while the heavy parts — the dict
|
||||
// loader, the move generator and the preload orchestration — stay in lazy chunks. Scoped CSS
|
||||
// lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 113, shared: 30, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app, openDebug } from '../lib/app.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
@@ -58,7 +58,11 @@
|
||||
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
||||
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
||||
per this same condition, once onboarding closes. -->
|
||||
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
||||
{#if dictPreloadWarning.active}
|
||||
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
|
||||
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
|
||||
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
|
||||
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
@@ -142,6 +146,18 @@
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
|
||||
the banner region so the bar does not jump when it appears. */
|
||||
.preload-warn {
|
||||
margin: 0;
|
||||
padding: 7px var(--pad);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { preloadDicts } from './preload';
|
||||
import type { Variant } from '../model';
|
||||
import type { Dawg } from './dawg';
|
||||
|
||||
// A non-null stand-in for a loaded reader — preloadDicts only checks getDawg's result for null.
|
||||
const DAWG = {} as Dawg;
|
||||
const noSleep = (): Promise<void> => Promise.resolve();
|
||||
|
||||
describe('preloadDicts', () => {
|
||||
it('fetches every enabled variant that has a known version', async () => {
|
||||
const calls: string[] = [];
|
||||
const res = await preloadDicts({ scrabble_en: 'v1', scrabble_ru: 'v2', erudit_ru: 'v3' }, ['scrabble_en', 'erudit_ru'], {
|
||||
getDawg: async (v: Variant, ver: string) => {
|
||||
calls.push(`${v}@${ver}`);
|
||||
return DAWG;
|
||||
},
|
||||
disabled: () => false,
|
||||
sleep: noSleep,
|
||||
});
|
||||
expect(res.ok).toEqual(['scrabble_en', 'erudit_ru']);
|
||||
expect(res.failed).toEqual([]);
|
||||
expect(calls).toEqual(['scrabble_en@v1', 'erudit_ru@v3']);
|
||||
});
|
||||
|
||||
it('marks a variant with no known version as failed without fetching it', async () => {
|
||||
const calls: string[] = [];
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en', 'scrabble_ru'], {
|
||||
getDawg: async (v: Variant) => {
|
||||
calls.push(v);
|
||||
return DAWG;
|
||||
},
|
||||
disabled: () => false,
|
||||
sleep: noSleep,
|
||||
});
|
||||
expect(res.ok).toEqual(['scrabble_en']);
|
||||
expect(res.failed).toEqual(['scrabble_ru']);
|
||||
expect(calls).toEqual(['scrabble_en']);
|
||||
});
|
||||
|
||||
it('retries a transient failure with linear backoff, then succeeds', async () => {
|
||||
let attempts = 0;
|
||||
const waits: number[] = [];
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||
getDawg: async () => (++attempts >= 3 ? DAWG : null),
|
||||
disabled: () => false,
|
||||
sleep: async (ms: number) => void waits.push(ms),
|
||||
retries: 3,
|
||||
backoffMs: 100,
|
||||
});
|
||||
expect(res.ok).toEqual(['scrabble_en']);
|
||||
expect(attempts).toBe(3);
|
||||
expect(waits).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it('gives up a persistent failure after the retry budget', async () => {
|
||||
let attempts = 0;
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||
getDawg: async () => {
|
||||
attempts++;
|
||||
return null;
|
||||
},
|
||||
disabled: () => false,
|
||||
sleep: noSleep,
|
||||
retries: 2,
|
||||
});
|
||||
expect(res.failed).toEqual(['scrabble_en']);
|
||||
expect(res.ok).toEqual([]);
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('stops retrying once the session miss-breaker trips', async () => {
|
||||
let attempts = 0;
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||
getDawg: async () => {
|
||||
attempts++;
|
||||
return null;
|
||||
},
|
||||
disabled: () => true,
|
||||
sleep: noSleep,
|
||||
retries: 5,
|
||||
});
|
||||
expect(res.failed).toEqual(['scrabble_en']);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Background dictionary preload for offline readiness. An installed PWA with a confirmed email
|
||||
// warms the dictionaries for the player's enabled variants while online, so a later switch to
|
||||
// deliberate offline mode has the data it needs. The pure preloadDicts here takes its side effects
|
||||
// (getDawg, the session miss-breaker, sleep) as dependencies, so it unit-tests in the node env. The
|
||||
// eligibility and once/online guard live in offline.svelte.ts (kickDictPreload); the browser
|
||||
// orchestration that supplies the real side effects and raises the in-lobby warning lives in
|
||||
// preloadrun.ts, which offline.svelte.ts imports dynamically so neither the loader nor the
|
||||
// generator is pulled into the main bundle.
|
||||
|
||||
import type { Variant } from '../model';
|
||||
import type { Dawg } from './dawg';
|
||||
|
||||
/** PreloadDeps injects preloadDicts's side effects so the logic stays pure and testable. */
|
||||
export interface PreloadDeps {
|
||||
/** getDawg resolves the (variant, version) reader, serving memory/IndexedDB before the network,
|
||||
* or null on any miss — mirrors the in-game loader. */
|
||||
getDawg: (variant: Variant, version: string) => Promise<Dawg | null>;
|
||||
/** disabled reports whether the session dictionary miss-breaker has tripped (too many network
|
||||
* misses this session); when it has, a network fetch will not recover, so retries stop. */
|
||||
disabled: () => boolean;
|
||||
/** sleep waits between retries; defaults to a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** retries is the number of extra attempts after the first (default 2). */
|
||||
retries?: number;
|
||||
/** backoffMs is the base linear backoff between attempts (default 800). */
|
||||
backoffMs?: number;
|
||||
}
|
||||
|
||||
/** PreloadResult reports which enabled variants ended up available (ok) and which are still
|
||||
* missing (failed) after the preload — the caller surfaces a warning when failed is non-empty. */
|
||||
export interface PreloadResult {
|
||||
ok: Variant[];
|
||||
failed: Variant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* preloadDicts fetches, via getDawg, the dictionary for each enabled variant that has a known
|
||||
* version, retrying transient misses with linear backoff. A variant with no known version, or one
|
||||
* still missing after the retry budget, lands in failed; the rest in ok. It never throws and stops
|
||||
* retrying a variant once the session miss-breaker (disabled) trips, since the network will not
|
||||
* recover this session — a cached dictionary is still served by getDawg regardless.
|
||||
*/
|
||||
export async function preloadDicts(
|
||||
versions: Partial<Record<Variant, string>>,
|
||||
enabled: readonly Variant[],
|
||||
deps: PreloadDeps,
|
||||
): Promise<PreloadResult> {
|
||||
const sleep = deps.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||
const retries = deps.retries ?? 2;
|
||||
const backoffMs = deps.backoffMs ?? 800;
|
||||
const ok: Variant[] = [];
|
||||
const failed: Variant[] = [];
|
||||
for (const variant of enabled) {
|
||||
const version = versions[variant];
|
||||
if (!version) {
|
||||
failed.push(variant);
|
||||
continue;
|
||||
}
|
||||
let dawg: Dawg | null = null;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
dawg = await deps.getDawg(variant, version);
|
||||
if (dawg || deps.disabled()) break;
|
||||
if (attempt < retries) await sleep(backoffMs * (attempt + 1));
|
||||
}
|
||||
(dawg ? ok : failed).push(variant);
|
||||
}
|
||||
return { ok, failed };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Browser-only orchestration for the offline dictionary preload. Kept apart from the pure
|
||||
// preloadDicts (preload.ts) — which unit-tests in the node env — and lazily imported by
|
||||
// offline.svelte.ts's kickDictPreload, so the dict loader/generator it pulls in stays out of the
|
||||
// main bundle. It supplies the real side effects (getDawg, the session miss-breaker) and raises the
|
||||
// in-lobby notice when a first-lobby preload cannot fetch every enabled variant's dictionary.
|
||||
|
||||
import type { Profile } from '../model';
|
||||
import { preloadDicts } from './preload';
|
||||
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||
import { setDictPreloadWarning } from '../offline.svelte';
|
||||
|
||||
/**
|
||||
* runPreload warms the dictionaries for the profile's enabled variants (using the versions the
|
||||
* profile advertises) via the real dict loader, so a later switch to offline mode has the data.
|
||||
* When warnOnFail is set (the first lobby entry), it raises the in-lobby notice if a variant is
|
||||
* still missing afterwards, and clears it on a run where every variant is available.
|
||||
*/
|
||||
export async function runPreload(prof: Profile, warnOnFail: boolean): Promise<void> {
|
||||
const res = await preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||
getDawg,
|
||||
disabled: dictLoadingDisabled,
|
||||
});
|
||||
if (warnOnFail) setDictPreloadWarning(res.failed.length > 0);
|
||||
}
|
||||
@@ -217,6 +217,7 @@ export const en = {
|
||||
'settings.offlineMode': 'Play mode',
|
||||
'settings.online': 'Online',
|
||||
'settings.offline': 'Offline',
|
||||
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
||||
|
||||
'about.title': 'About',
|
||||
'about.tab': 'Info',
|
||||
|
||||
@@ -217,6 +217,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.offlineMode': 'Режим игры',
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||
|
||||
'about.title': 'О программе',
|
||||
'about.tab': 'Инфо',
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
|
||||
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
|
||||
|
||||
import { loadOfflinePref, saveOfflinePref } from './offline';
|
||||
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
|
||||
import { isStandalone } from './pwa';
|
||||
import { insideTelegram } from './telegram';
|
||||
import { insideVK } from './vk';
|
||||
import type { Profile } from './model';
|
||||
|
||||
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
|
||||
let active = $state(loadOfflinePref());
|
||||
@@ -21,3 +25,55 @@ export function setOfflineMode(on: boolean): void {
|
||||
active = on;
|
||||
saveOfflinePref(on);
|
||||
}
|
||||
|
||||
// The dict-preload warning: true when a first-lobby background preload could not fetch every
|
||||
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
|
||||
// The lobby shows a notice in place of the ad banner while it holds.
|
||||
let preloadWarn = $state(false);
|
||||
|
||||
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
|
||||
export const dictPreloadWarning = {
|
||||
/** active is true while a first-lobby dictionary preload has left a variant unavailable. */
|
||||
get active(): boolean {
|
||||
return preloadWarn;
|
||||
},
|
||||
};
|
||||
|
||||
/** setDictPreloadWarning raises or clears the in-lobby preload-failure notice. */
|
||||
export function setDictPreloadWarning(on: boolean): void {
|
||||
preloadWarn = on;
|
||||
}
|
||||
|
||||
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
|
||||
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
||||
let preloadInFlight = false;
|
||||
|
||||
/**
|
||||
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
|
||||
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
|
||||
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
|
||||
* browser tab, without a confirmed email, while offline, or when a preload is already running;
|
||||
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
|
||||
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
|
||||
* and generator are imported dynamically, so neither is pulled into the main bundle.
|
||||
*/
|
||||
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
|
||||
if (preloadInFlight || !prof) return;
|
||||
const eligible = offlinePreloadEligible({
|
||||
hasEmail: !!prof.email,
|
||||
standalone: isStandalone(),
|
||||
inTelegram: insideTelegram(),
|
||||
inVK: insideVK(),
|
||||
online: typeof navigator === 'undefined' || navigator.onLine !== false,
|
||||
});
|
||||
if (!eligible) return;
|
||||
preloadInFlight = true;
|
||||
void import('./dict/preloadrun')
|
||||
.then((m) => m.runPreload(prof, warnOnFail))
|
||||
.catch(() => {
|
||||
/* best-effort warmup — a failed dynamic import just leaves offline data unprimed */
|
||||
})
|
||||
.finally(() => {
|
||||
preloadInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts } from './offline';
|
||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline';
|
||||
import type { Variant } from './model';
|
||||
|
||||
// A minimal in-memory localStorage for the persistence tests (node has none).
|
||||
@@ -35,4 +35,14 @@ describe('offline mode helpers', () => {
|
||||
const has = (v: Variant): boolean => v === 'scrabble_en';
|
||||
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
|
||||
});
|
||||
|
||||
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
|
||||
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
|
||||
expect(offlinePreloadEligible(base)).toBe(true);
|
||||
expect(offlinePreloadEligible({ ...base, hasEmail: false })).toBe(false);
|
||||
expect(offlinePreloadEligible({ ...base, standalone: false })).toBe(false);
|
||||
expect(offlinePreloadEligible({ ...base, inTelegram: true })).toBe(false);
|
||||
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
|
||||
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,3 +41,20 @@ export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant)
|
||||
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
|
||||
return enabled.filter((v) => !hasDict(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
||||
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
||||
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
|
||||
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
|
||||
* toggle's eligibility so the two never disagree.
|
||||
*/
|
||||
export function offlinePreloadEligible(opts: {
|
||||
hasEmail: boolean;
|
||||
standalone: boolean;
|
||||
inTelegram: boolean;
|
||||
inVK: boolean;
|
||||
online: boolean;
|
||||
}): boolean {
|
||||
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { badgeKind } from '../lib/unread';
|
||||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||||
import { preloadGames } from '../lib/preload';
|
||||
import { kickDictPreload } from '../lib/offline.svelte';
|
||||
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';
|
||||
@@ -51,7 +52,13 @@
|
||||
// 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);
|
||||
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 {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||
import { insideVK } from '../lib/vk';
|
||||
import { kickDictPreload } from '../lib/offline.svelte';
|
||||
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import {
|
||||
@@ -158,6 +159,9 @@
|
||||
notificationsInAppOnly,
|
||||
variantPreferences: variantPrefs,
|
||||
});
|
||||
// A variant-preference change may enable a variant whose dictionary is not yet cached; warm
|
||||
// it in the background for offline readiness (quietly — no in-lobby notice for a top-up).
|
||||
kickDictPreload(app.profile, false);
|
||||
showToast(t('profile.saved'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
|
||||
Reference in New Issue
Block a user