Files
scrabble-game/ui/src/lib/app.svelte.ts
T
Ilia Denisov d733ce3119
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s
Stage 8: UI social/account/history surfaces
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
2026-06-03 19:47:40 +02:00

260 lines
7.3 KiB
TypeScript

// Central app state + actions. Holds the session/profile, client preferences, a
// transient toast, and the latest live event (screens react to it via $effect). All
// gateway calls funnel through here so errors map to one user-facing toast and an
// expired session logs out.
import type { Profile, PushEvent, Session } from './model';
import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
kind: 'error' | 'info';
text: string;
}
export const app = $state<{
ready: boolean;
session: Session | null;
profile: Profile | null;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */
notifications: number;
}>({
ready: false,
session: null,
profile: null,
toast: null,
lastEvent: null,
theme: 'auto',
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false,
notifications: 0,
});
let unsubscribeStream: (() => void) | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (app.toast = null), 4000);
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
return;
}
showToast(t(errorKey(err.code)), 'error');
return;
}
showToast(t('error.generic'), 'error');
}
function openStream(): void {
closeStream();
unsubscribeStream = gateway.subscribe(
(e) => {
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
} else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'match_found') {
navigate(`/game/${e.gameId}`);
} else if (e.kind === 'notify') {
void refreshNotifications();
}
},
() => showToast(t('error.unavailable'), 'error'),
);
}
/**
* refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
* Guests have no social surfaces, so it is a no-op for them.
*/
export async function refreshNotifications(): Promise<void> {
if (!app.session || app.profile?.isGuest) {
app.notifications = 0;
return;
}
try {
const [incoming, invitations] = await Promise.all([
gateway.friendsIncoming(),
gateway.invitationsList(),
]);
app.notifications = incoming.length + invitations.length;
} catch {
// Best-effort; leave the previous count on a transient failure.
}
}
function closeStream(): void {
unsubscribeStream?.();
unsubscribeStream = null;
}
async function adoptSession(s: Session): Promise<void> {
gateway.setToken(s.token);
app.session = s;
await saveSession(s);
try {
app.profile = await gateway.profileGet();
if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale));
} catch (err) {
handleError(err);
}
openStream();
void refreshNotifications();
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
app.locale = prefs.locale;
app.localeLocked = true;
setLocale(prefs.locale);
} else {
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
app.locale = guess;
setLocale(guess);
}
const saved = await loadSession();
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
} else if (router.route.name !== 'login') {
navigate('/login');
}
app.ready = true;
}
export async function loginGuest(): Promise<void> {
try {
const s = await gateway.authGuest(app.locale);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email);
return true;
} catch (err) {
handleError(err);
return false;
}
}
export async function loginEmail(email: string, code: string): Promise<void> {
try {
const s = await gateway.authEmailLogin(email, code);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function logout(): Promise<void> {
closeStream();
gateway.setToken(null);
await clearSession();
app.session = null;
app.profile = null;
navigate('/login');
}
function persistPrefs(): void {
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
});
}
export function setTheme(theme: ThemePref): void {
app.theme = theme;
applyTheme(theme);
persistPrefs();
}
export function setLocalePref(locale: Locale): void {
app.locale = locale;
app.localeLocked = true;
setLocale(locale);
persistPrefs();
void persistLanguageToServer(locale);
}
/**
* persistLanguageToServer writes the chosen interface language through to the
* durable account's preferred_language, so the single Settings control is the
* source of truth (guests keep only the client preference). Best-effort.
*/
async function persistLanguageToServer(locale: Locale): Promise<void> {
const p = app.profile;
if (!p || p.isGuest || p.preferredLanguage === locale) return;
try {
app.profile = await gateway.profileUpdate({
displayName: p.displayName,
preferredLanguage: locale,
timeZone: p.timeZone,
awayStart: p.awayStart,
awayEnd: p.awayEnd,
blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests,
});
} catch {
// The client locale already changed; the server sync is best-effort.
}
}
export function setReduceMotion(on: boolean): void {
app.reduceMotion = on;
applyReduceMotion(on);
persistPrefs();
}
export function setBoardLabels(mode: BoardLabelMode): void {
app.boardLabels = mode;
persistPrefs();
}
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
// may have been missed while the client was hidden/closed (poll + push, see §10).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
});
}