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; /** عوضِ کتابِ نقشه (هندسه، حسابان، …)؛ آزمون‌ها و درس‌ها هر دو همین را می‌خوانند. */ selectSubject: (key: string) => void; /** کتابی که روی `home` است؛ بی‌نقشه (هنوز نرسیده) null. */ subjectKey: string | null; refreshSummary: () => Promise; refreshUnread: () => Promise; setProfile: (profile: UserProfile) => void; setCompanion: (character: Character) => void; setUnreadCount: (count: number) => void; } const AppDataContext = createContext(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(prefetch?.summary ?? null); const [home, setHome] = useState(prefetch?.home ?? null); const [homeError, setHomeError] = useState(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()); // فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی // پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند. 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( () => ({ 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 {children}; } export function useAppData(): AppData { const data = useContext(AppDataContext); if (!data) throw new Error('useAppData بیرون از AppDataProvider صدا زده شد'); return data; }