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.
This commit is contained in:
@@ -28,6 +28,8 @@ export const app = $state<{
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
localeLocked: boolean;
|
||||
/** Pending incoming friend requests + invitations, for the lobby badge. */
|
||||
notifications: number;
|
||||
}>({
|
||||
ready: false,
|
||||
session: null,
|
||||
@@ -39,6 +41,7 @@ export const app = $state<{
|
||||
reduceMotion: false,
|
||||
boardLabels: 'beginner',
|
||||
localeLocked: false,
|
||||
notifications: 0,
|
||||
});
|
||||
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
@@ -76,12 +79,35 @@ function openStream(): void {
|
||||
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;
|
||||
@@ -98,6 +124,7 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
handleError(err);
|
||||
}
|
||||
openStream();
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
@@ -186,6 +213,30 @@ export function setLocalePref(locale: Locale): void {
|
||||
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 {
|
||||
@@ -198,3 +249,11 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user