A signed-in user with a snapshot goes straight to home; the same boot requests run in the background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login, ignored from another build or when older than 7 days, and dropped when the fresh profile is a different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the exiting one). Notifications/characters wait for the profile answer so an expired access token costs the same requests as before. refreshHome applies only the latest answer. Home shows a "saved data" strip when the map could not be refreshed. Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map): Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s -> 6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
10 KiB
TypeScript
225 lines
10 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
|
||
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
|
||
import { companionStore, useCompanion } from '@/state/companion';
|
||
import { homeSnapshot } from '@/state/homeSnapshot';
|
||
import { fetchHome, subjectStore } from '@/state/subject';
|
||
|
||
/**
|
||
* دادههایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
|
||
* و تعدادِ اعلانِ نخوانده. هر صفحه بعد از کاری که اینها را عوض میکند
|
||
* (پایانِ درس، عوض کردنِ پایه، …) refresh را صدا میزند.
|
||
*/
|
||
interface AppData {
|
||
profile: UserProfile;
|
||
grades: Grade[];
|
||
gradeTitle: string;
|
||
summary: UserSummary | null;
|
||
home: HomeResponse | null;
|
||
homeError: unknown;
|
||
/** همراهی که کاربر در صفحهی کاراکترها انتخاب کرده؛ پیشفرض سیگما. */
|
||
companion: SeyarChar;
|
||
unreadCount: number;
|
||
/** `gradeId` را فقط وقتی بدهید که پایه همین الان عوض شده و state هنوز رندر نشده. */
|
||
refreshHome: (gradeId?: number) => Promise<void>;
|
||
/** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمونها و درسها هر دو همین را میخوانند. */
|
||
selectSubject: (key: string) => void;
|
||
/** کتابی که روی `home` است؛ بینقشه (هنوز نرسیده) null. */
|
||
subjectKey: string | null;
|
||
refreshSummary: () => Promise<void>;
|
||
refreshUnread: () => Promise<void>;
|
||
setProfile: (profile: UserProfile) => void;
|
||
setCompanion: (character: Character) => void;
|
||
setUnreadCount: (count: number) => void;
|
||
}
|
||
|
||
const AppDataContext = createContext<AppData | null>(null);
|
||
|
||
/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */
|
||
export interface Prefetch {
|
||
home: HomeResponse | null;
|
||
summary: UserSummary | null;
|
||
}
|
||
|
||
export function AppDataProvider({
|
||
initialProfile,
|
||
grades: initialGrades,
|
||
prefetch,
|
||
stale = false,
|
||
onIdentityChanged,
|
||
children,
|
||
}: {
|
||
initialProfile: UserProfile;
|
||
grades: Grade[];
|
||
prefetch?: Prefetch;
|
||
/**
|
||
* دادهی اولیه عکسِ ذخیرهشده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همهچیز پشتِ صحنه
|
||
* دوباره گرفته میشود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمیشود؛ فقط کاربر منتظرشان نمیماند.
|
||
*/
|
||
stale?: boolean;
|
||
/** پروفایلِ تازه با عکس نمیخواند (کاربرِ دیگر، یا بینام/بیپایه که باید به تکمیلِ پروفایل برود). */
|
||
onIdentityChanged?: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
const [profile, setProfile] = useState(initialProfile);
|
||
const [grades, setGrades] = useState(initialGrades);
|
||
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
|
||
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
|
||
const [homeError, setHomeError] = useState<unknown>(null);
|
||
// همراه در companionStore است تا بیرون از AppData (اسپلش، نشان، لودرها) هم خوانده شود.
|
||
const companion = useCompanion().char;
|
||
const setCompanionChar = companionStore.set;
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
|
||
const gradeId = profile.gradeId;
|
||
// نقشهی کتابهایی که دیده شده؛ جابهجایی به کتابِ دیدهشده فوری است و پشتصحنه تازه میشود.
|
||
const homeCache = useRef(new Map<string, HomeResponse>());
|
||
// فقط پاسخِ آخرین درخواست روی صفحه مینشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازهی
|
||
// پشتِ صحنه پایهی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشهی تازه را بپوشاند.
|
||
const homeSeq = useRef(0);
|
||
|
||
const refreshHome = useCallback(async (forGrade?: number) => {
|
||
const target = forGrade ?? gradeId;
|
||
const seq = ++homeSeq.current;
|
||
try {
|
||
const fresh = await fetchHome(target);
|
||
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
|
||
if (seq !== homeSeq.current) return;
|
||
setHome(fresh);
|
||
setHomeError(null);
|
||
} catch (err) {
|
||
if (seq === homeSeq.current) setHomeError(err);
|
||
}
|
||
}, [gradeId]);
|
||
|
||
const selectSubject = useCallback(
|
||
(key: string) => {
|
||
if (gradeId == null) return;
|
||
subjectStore.set(gradeId, key);
|
||
const cached = homeCache.current.get(`${gradeId}:${key}`);
|
||
// کتابِ دیدهشده فوری میآید؛ دیدهنشده یک لحظه «در حال آوردن» است تا نقشهی کتابِ قبلی زیرِ نامِ
|
||
// کتابِ تازه نماند.
|
||
setHome(cached ?? null);
|
||
setHomeError(null);
|
||
void refreshHome();
|
||
},
|
||
[gradeId, refreshHome],
|
||
);
|
||
|
||
const refreshSummary = useCallback(async () => {
|
||
try {
|
||
setSummary(await api.summary());
|
||
} catch {
|
||
/* آمار تزئینی است؛ نبودنش صفحه را خراب نمیکند */
|
||
}
|
||
}, []);
|
||
|
||
const refreshUnread = useCallback(async () => {
|
||
try {
|
||
setUnreadCount((await api.notifications.list()).unreadCount);
|
||
} catch {
|
||
/* نشانِ اعلان اختیاری است */
|
||
}
|
||
}, []);
|
||
|
||
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه میسازد و نباید همهی اینها دوباره گرفته شوند.
|
||
const started = useRef(false);
|
||
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمینویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
|
||
const identityLost = useRef(false);
|
||
useEffect(() => {
|
||
if (started.current) return;
|
||
started.current = true;
|
||
// اعلانها و همراه برای صفحهی اول لازم نیستند.
|
||
const loadExtras = () => {
|
||
if (!api.auth.isSignedIn()) return;
|
||
void refreshUnread();
|
||
api.characters
|
||
.list()
|
||
.then((list) => {
|
||
// بیانتخاب یعنی پیشفرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
|
||
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
|
||
})
|
||
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
|
||
};
|
||
if (stale) {
|
||
api.profile
|
||
.get()
|
||
.then((fresh) => {
|
||
if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) {
|
||
identityLost.current = true;
|
||
onIdentityChanged?.();
|
||
return false;
|
||
}
|
||
setProfile(fresh);
|
||
// پایه در دستگاهِ دیگری عوض شده؛ نقشهای که همزمان با پایهی عکس گرفته شد به کار نمیآید.
|
||
if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId);
|
||
return true;
|
||
})
|
||
// ۴۰۱ را http خودش به خروج میبرد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس میماند و نوارِ «اطلاعاتِ
|
||
// ذخیرهشده» در خانه (homeError) خبر میدهد؛ نقشه همزمان همین درخواستها را میزند و همانجا شکست میخورد.
|
||
.catch((err: unknown) => {
|
||
console.warn('profile refresh failed; showing the stored one', err);
|
||
return true;
|
||
})
|
||
// بعد از پروفایل، نه همزمان: با توکنِ یکساعتهی منقضی هر درخواستِ همزمان یک ۴۰۱ و یک تکرار میشود؛
|
||
// اینطور تعدادِ درخواستها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲).
|
||
.then((stays) => stays && loadExtras());
|
||
api
|
||
.grades()
|
||
.then(setGrades)
|
||
.catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err));
|
||
void refreshHome();
|
||
void refreshSummary();
|
||
} else {
|
||
// آنچه در اسپلش رسیده دوباره گرفته نمیشود؛ فقط کمها.
|
||
if (!prefetch?.home) void refreshHome();
|
||
if (!prefetch?.summary) void refreshSummary();
|
||
loadExtras();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [refreshHome, refreshSummary, refreshUnread]);
|
||
|
||
// هر دادهی تازه عکسِ خانه را بهروز میکند. بارِ اولِ حالتِ stale همان عکسِ خواندهشده است و نوشته نمیشود،
|
||
// تا savedAt فقط با پاسخِ سرور جلو برود.
|
||
const skipSnapshotWrite = useRef(stale);
|
||
useEffect(() => {
|
||
if (skipSnapshotWrite.current) {
|
||
skipSnapshotWrite.current = false;
|
||
return;
|
||
}
|
||
// پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد.
|
||
if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return;
|
||
homeSnapshot.save({ profile, grades, home, summary });
|
||
}, [profile, grades, home, summary]);
|
||
|
||
const value = useMemo<AppData>(
|
||
() => ({
|
||
profile,
|
||
grades,
|
||
gradeTitle: grades.find((g) => g.id === profile.gradeId)?.title ?? '',
|
||
summary,
|
||
home,
|
||
homeError,
|
||
companion,
|
||
unreadCount,
|
||
refreshHome,
|
||
selectSubject,
|
||
subjectKey: home?.subject?.key ?? null,
|
||
refreshSummary,
|
||
refreshUnread,
|
||
setProfile,
|
||
setCompanion: (character) => setCompanionChar(seyarFromSlug(character.slug) ?? 'sigma'),
|
||
setUnreadCount,
|
||
}),
|
||
[profile, grades, summary, home, homeError, companion, unreadCount, refreshHome, selectSubject, refreshSummary, refreshUnread],
|
||
);
|
||
|
||
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;
|
||
}
|
||
|
||
export function useAppData(): AppData {
|
||
const data = useContext(AppDataContext);
|
||
if (!data) throw new Error('useAppData بیرون از AppDataProvider صدا زده شد');
|
||
return data;
|
||
}
|