d733ce3119
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.
260 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
}
|