324 lines
9.4 KiB
TypeScript
324 lines
9.4 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 { LinkResult, 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, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
|
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
|
|
import { parseStartParam } from './deeplink';
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* applyLinkResult applies a completed account link or merge (Stage 11): it adopts a
|
|
* switched session (a guest initiator whose durable counterpart won, so the active
|
|
* account changed) or, otherwise, refreshes the current profile in place.
|
|
*/
|
|
export async function applyLinkResult(r: LinkResult): Promise<void> {
|
|
if (r.session && r.session.token) {
|
|
await adoptSession(r.session);
|
|
return;
|
|
}
|
|
app.profile = await gateway.profileGet();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
|
|
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
|
|
// outside Telegram (no initData), refuse to render and send the visitor to the
|
|
// site root.
|
|
if (onTelegramPath() && !insideTelegram()) {
|
|
if (typeof location !== 'undefined') location.replace('/');
|
|
return;
|
|
}
|
|
if (insideTelegram()) {
|
|
const launch = telegramLaunch();
|
|
if (launch.theme) applyTelegramTheme(launch.theme);
|
|
try {
|
|
await adoptSession(await gateway.authTelegram(launch.initData));
|
|
await routeStartParam(launch.startParam);
|
|
} catch (err) {
|
|
handleError(err);
|
|
navigate('/login');
|
|
}
|
|
app.ready = true;
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* routeStartParam navigates a Telegram deep-link start parameter to its target: a
|
|
* specific game, the friends screen with a friend-code redemption, or the lobby
|
|
* (where invitations surface as a badge).
|
|
*/
|
|
async function routeStartParam(param: string): Promise<void> {
|
|
const link = parseStartParam(param);
|
|
switch (link.kind) {
|
|
case 'game':
|
|
navigate(`/game/${link.id}`);
|
|
return;
|
|
case 'friendCode':
|
|
navigate('/friends');
|
|
try {
|
|
const friend = await gateway.friendCodeRedeem(link.code);
|
|
showToast(t('friends.added', { name: friend.displayName }));
|
|
void refreshNotifications();
|
|
} catch (err) {
|
|
handleError(err);
|
|
}
|
|
return;
|
|
default:
|
|
navigate('/');
|
|
}
|
|
}
|
|
|
|
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,
|
|
notificationsInAppOnly: p.notificationsInAppOnly,
|
|
});
|
|
} 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();
|
|
});
|
|
}
|