Merge pull request #82 from zonghaoyuan/fix/play-oauth-resume
fix(play): resume in-progress game after OAuth full-page redirect
This commit is contained in:
+30
-32
@@ -17,7 +17,7 @@ import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelCon
|
|||||||
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
|
import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction";
|
||||||
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
|
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
|
||||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||||
import { createClient as createSupabaseClient } from "@/lib/supabase/client";
|
import { isAuthed, writeResumeSnapshot } from "@/lib/authResume";
|
||||||
import { AuthModal } from "@/components/AuthModal";
|
import { AuthModal } from "@/components/AuthModal";
|
||||||
import { UserChip } from "@/components/UserChip";
|
import { UserChip } from "@/components/UserChip";
|
||||||
|
|
||||||
@@ -854,15 +854,6 @@ function CategorySelect({
|
|||||||
const PENDING_START_KEY = "infiplot:pending-start";
|
const PENDING_START_KEY = "infiplot:pending-start";
|
||||||
const PENDING_PARSE_KEY = "infiplot:pending-parse";
|
const PENDING_PARSE_KEY = "infiplot:pending-parse";
|
||||||
|
|
||||||
// True when auth is disabled (self-host with blank Supabase env) or the visitor
|
|
||||||
// already has a session. Gates the vision call behind login.
|
|
||||||
async function isAuthed(): Promise<boolean> {
|
|
||||||
if (!AUTH_ENABLED) return true;
|
|
||||||
const sb = createSupabaseClient();
|
|
||||||
const { data } = await sb.auth.getUser();
|
|
||||||
return !!data.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared by the StyleModal uploader and the post-login resume path: turns a
|
// Shared by the StyleModal uploader and the post-login resume path: turns a
|
||||||
// resized data URL into an English style prompt, via the browser engine when a
|
// resized data URL into an English style prompt, via the browser engine when a
|
||||||
// BYO model config is present, otherwise the server route.
|
// BYO model config is present, otherwise the server route.
|
||||||
@@ -1399,19 +1390,11 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const persistPendingStart = () => {
|
const persistPendingStart = () => {
|
||||||
const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName };
|
const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName };
|
||||||
try {
|
// Quota fallback: the data-URL style ref (~100KB) is the usual culprit —
|
||||||
sessionStorage.setItem(PENDING_START_KEY, JSON.stringify(snap));
|
// drop it first; text-only form still resumes the start.
|
||||||
} catch {
|
writeResumeSnapshot(PENDING_START_KEY, snap, [
|
||||||
// Quota is usually blown by the data-URL style ref; drop it, keep text.
|
{ ...snap, customStyleRefImage: "" },
|
||||||
try {
|
]);
|
||||||
sessionStorage.setItem(
|
|
||||||
PENDING_START_KEY,
|
|
||||||
JSON.stringify({ ...snap, customStyleRefImage: "" }),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* still too big — give up on resume, the form just clears */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resumePendingParse = async () => {
|
const resumePendingParse = async () => {
|
||||||
@@ -1461,12 +1444,15 @@ export default function HomePage() {
|
|||||||
// is now signed in, restore and continue; otherwise clear stale snapshots.
|
// is now signed in, restore and continue; otherwise clear stale snapshots.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!AUTH_ENABLED) return;
|
if (!AUTH_ENABLED) return;
|
||||||
const hasPending =
|
const hasStart = sessionStorage.getItem(PENDING_START_KEY) !== null;
|
||||||
sessionStorage.getItem(PENDING_START_KEY) !== null ||
|
const hasParse = sessionStorage.getItem(PENDING_PARSE_KEY) !== null;
|
||||||
sessionStorage.getItem(PENDING_PARSE_KEY) !== null;
|
if (!hasStart && !hasParse) return;
|
||||||
if (!hasPending) return;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
// Gate BOTH snapshots on auth: a stale leftover from an abandoned login
|
||||||
|
// must not resurrect a half-flow. The parse key stores a raw data URL
|
||||||
|
// with its own restore path (resumePendingParse), so both are gated
|
||||||
|
// manually here rather than via consumeResumeSnapshot.
|
||||||
if (!(await isAuthed())) {
|
if (!(await isAuthed())) {
|
||||||
sessionStorage.removeItem(PENDING_START_KEY);
|
sessionStorage.removeItem(PENDING_START_KEY);
|
||||||
sessionStorage.removeItem(PENDING_PARSE_KEY);
|
sessionStorage.removeItem(PENDING_PARSE_KEY);
|
||||||
@@ -1492,10 +1478,11 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
if (AUTH_ENABLED) {
|
if (AUTH_ENABLED) {
|
||||||
const sb = createSupabaseClient();
|
if (!(await isAuthed())) {
|
||||||
const { data } = await sb.auth.getUser();
|
// Don't snapshot here — persistPendingStart fires via
|
||||||
if (!data.user) {
|
// AuthModal.onBeforeOAuth at redirect time, so the form is captured
|
||||||
persistPendingStart();
|
// for BOTH OAuth and (harmlessly) OTP paths at the single source of
|
||||||
|
// truth. OTP's onSuccess resumes in-place without needing the snapshot.
|
||||||
setPendingAction("start");
|
setPendingAction("start");
|
||||||
setAuthModalOpen(true);
|
setAuthModalOpen(true);
|
||||||
return;
|
return;
|
||||||
@@ -1992,7 +1979,8 @@ export default function HomePage() {
|
|||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
// Email-OTP stays on the page, so resume inline: parse first (it
|
// Email-OTP stays on the page, so resume inline: parse first (it
|
||||||
// reads its own snapshot), then the pending start.
|
// reads its own snapshot), then the pending start. OTP never
|
||||||
|
// triggers onBeforeOAuth, so no PENDING_START snapshot was written.
|
||||||
void resumePendingParse();
|
void resumePendingParse();
|
||||||
if (pendingAction === "start") {
|
if (pendingAction === "start") {
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
@@ -2004,6 +1992,16 @@ export default function HomePage() {
|
|||||||
start();
|
start();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
//
|
||||||
|
// Only snapshot when the user is mid-start: the OAuth redirect also
|
||||||
|
// fires for bare logins (UserChip / StyleModal onRequireAuth), where
|
||||||
|
// the user just wants to sign in — not kick off a game. Guarding on
|
||||||
|
// pendingAction keeps bare logins from auto-starting a session on
|
||||||
|
// return. (start() sets pendingAction="start" right before opening
|
||||||
|
// this modal.)
|
||||||
|
onBeforeOAuth={() => {
|
||||||
|
if (pendingAction === "start") persistPendingStart();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+203
-9
@@ -52,10 +52,39 @@ import type {
|
|||||||
} from "@infiplot/types";
|
} from "@infiplot/types";
|
||||||
import { track } from "@/lib/analytics";
|
import { track } from "@/lib/analytics";
|
||||||
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||||
|
import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume";
|
||||||
import { AuthModal } from "@/components/AuthModal";
|
import { AuthModal } from "@/components/AuthModal";
|
||||||
import { UserChip } from "@/components/UserChip";
|
import { UserChip } from "@/components/UserChip";
|
||||||
|
|
||||||
const MUTED_STORAGE_KEY = "infiplot:muted";
|
const MUTED_STORAGE_KEY = "infiplot:muted";
|
||||||
|
// One-shot snapshot of in-progress game state, written just before an OAuth
|
||||||
|
// full-page redirect (Google/GitHub) so the play page can resume the exact
|
||||||
|
// scene/beat after the round-trip. The redirect unmounts the app and destroys
|
||||||
|
// the in-memory Session (the server is stateless), so without this the play
|
||||||
|
// page re-bootstraps from `?card=…` and restarts the story. OTP login keeps
|
||||||
|
// state in-memory (no redirect) and never writes this. Consumed once on mount.
|
||||||
|
const PLAY_RESUME_KEY = "infiplot:play-resume";
|
||||||
|
|
||||||
|
// Serializable form of the action intercepted by a 401. `persistPlayResume`
|
||||||
|
// stashes whichever one is pending into sessionStorage; the deferred-replay
|
||||||
|
// effect re-dispatches it after `restorePlayResume` commits the restored state.
|
||||||
|
type PendingResumeAction =
|
||||||
|
| { kind: "choice"; choice: BeatChoice }
|
||||||
|
| { kind: "freeform"; text: string }
|
||||||
|
| { kind: "background-click"; x: number; y: number };
|
||||||
|
|
||||||
|
// Shape written to sessionStorage[PLAY_RESUME_KEY]. `imageOriginalUrl` is the
|
||||||
|
// remote CDN URL (never the blob: URL — those are revoked on unmount and won't
|
||||||
|
// survive the full-page reload); restorePlayResume re-resolves it to a fresh
|
||||||
|
// blob via getOrCreateBlobUrl.
|
||||||
|
type PlayResumeSnapshot = {
|
||||||
|
session: Session;
|
||||||
|
beatId: string;
|
||||||
|
visitedBeats: string[];
|
||||||
|
orientation: Orientation;
|
||||||
|
imageOriginalUrl: string;
|
||||||
|
pendingAction?: PendingResumeAction;
|
||||||
|
};
|
||||||
|
|
||||||
// Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a
|
// Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a
|
||||||
// non-BYO, unmuted player. Set high enough that one transient miss won't trip
|
// non-BYO, unmuted player. Set high enough that one transient miss won't trip
|
||||||
@@ -618,6 +647,22 @@ function PlayInner() {
|
|||||||
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
const [visionClickEnabled, setVisionClickEnabled] = useState(true);
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const authResolveRef = useRef<(() => void) | null>(null);
|
const authResolveRef = useRef<(() => void) | null>(null);
|
||||||
|
// Serializable description of the action that hit the 401 (choice / freeform
|
||||||
|
// text / background-click coords), captured alongside the retry closure. An
|
||||||
|
// OAuth round-trip destroys the closure, but this survives in sessionStorage
|
||||||
|
// so the exact action can be replayed after game state is restored.
|
||||||
|
const pendingResumeActionRef = useRef<PendingResumeAction | null>(null);
|
||||||
|
// Set by restorePlayResume when a snapshot carries a pending action; a
|
||||||
|
// dedicated effect dispatches it once the restored state has committed
|
||||||
|
// (phase "ready", session + scene present), then clears it. Mirrors the
|
||||||
|
// homepage's autoStartPending resume pattern.
|
||||||
|
const [pendingReplayAction, setPendingReplayAction] =
|
||||||
|
useState<PendingResumeAction | null>(null);
|
||||||
|
// Bumped by the OAuth-resume fallback to retrigger the bootstrap effect after
|
||||||
|
// relinquishing its `startedRef` slot (snapshot consumed but user not signed
|
||||||
|
// in → run normal card/preset/custom bootstrap instead of leaving a blank
|
||||||
|
// loading screen).
|
||||||
|
const [retryBootstrap, setRetryBootstrap] = useState(0);
|
||||||
// Top-of-screen progress toast for the gallery / story export pipeline.
|
// Top-of-screen progress toast for the gallery / story export pipeline.
|
||||||
// null when idle; { done, total, label } while collecting beat audio.
|
// null when idle; { done, total, label } while collecting beat audio.
|
||||||
const [exportProgress, setExportProgress] = useState<
|
const [exportProgress, setExportProgress] = useState<
|
||||||
@@ -627,10 +672,18 @@ function PlayInner() {
|
|||||||
// `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess
|
// `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess
|
||||||
// after the user signs in. Omitted by callers whose path can't actually 401
|
// after the user signs in. Omitted by callers whose path can't actually 401
|
||||||
// (initial load already gated on the homepage, recorded replay is local).
|
// (initial load already gated on the homepage, recorded replay is local).
|
||||||
|
// `action` is the serializable twin of `retry`: same intent, but survives an
|
||||||
|
// OAuth full-page redirect via sessionStorage so it can be replayed after
|
||||||
|
// game state is restored (the retry closure itself is destroyed on unmount).
|
||||||
const handleAuthError = useCallback(
|
const handleAuthError = useCallback(
|
||||||
(e: unknown, retry?: () => void): boolean => {
|
(
|
||||||
|
e: unknown,
|
||||||
|
retry?: () => void,
|
||||||
|
action?: PendingResumeAction,
|
||||||
|
): boolean => {
|
||||||
if (e instanceof AuthRequiredError) {
|
if (e instanceof AuthRequiredError) {
|
||||||
authResolveRef.current = retry ?? null;
|
authResolveRef.current = retry ?? null;
|
||||||
|
pendingResumeActionRef.current = action ?? null;
|
||||||
setAuthModalOpen(true);
|
setAuthModalOpen(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -639,6 +692,64 @@ function PlayInner() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Snapshot the in-progress game just before an OAuth full-page redirect so
|
||||||
|
// the play page can resume the exact scene/beat on return. Reads only refs
|
||||||
|
// (stable across renders), so an empty dep list is safe. Mirrors the
|
||||||
|
// homepage's persistPendingStart + quota-fallback degradation.
|
||||||
|
const persistPlayResume = useCallback((): void => {
|
||||||
|
const sess = sessionRef.current;
|
||||||
|
const beat = currentBeatRef.current;
|
||||||
|
const imageOriginalUrl = lastImageOriginalUrlRef.current;
|
||||||
|
if (!sess || !beat || !imageOriginalUrl) return;
|
||||||
|
const snap: PlayResumeSnapshot = {
|
||||||
|
session: sess,
|
||||||
|
beatId: beat.id,
|
||||||
|
visitedBeats: [...visitedBeatsRef.current],
|
||||||
|
orientation: sess.orientation ?? "landscape",
|
||||||
|
imageOriginalUrl,
|
||||||
|
pendingAction: pendingResumeActionRef.current ?? undefined,
|
||||||
|
};
|
||||||
|
// Quota-safe write: the only heavy field is the user-uploaded style ref
|
||||||
|
// (~100KB data URL), which only affects the Painter on FUTURE scenes, not
|
||||||
|
// the resumed scene — so stripping it degrades gracefully. Voices are
|
||||||
|
// deliberately kept (continuity > rare quota miss; a typical session of
|
||||||
|
// remote-image URLs + a few ~160KB voice refs fits under the 5MB cap).
|
||||||
|
writeResumeSnapshot(PLAY_RESUME_KEY, snap, [
|
||||||
|
// Fallback: drop the style-reference data URL from the session.
|
||||||
|
{ ...snap, session: { ...sess, styleReferenceImage: undefined } },
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore an in-progress game from a PLAY_RESUME_KEY snapshot after an OAuth
|
||||||
|
// round-trip. Re-resolves the remote image URL to a fresh blob (the old blob
|
||||||
|
// was revoked on unmount), repopulates the runtime refs the handlers read,
|
||||||
|
// and hands any pending action to the deferred-replay effect. Throws on a
|
||||||
|
// corrupt snapshot so the caller can fall back to normal bootstrap.
|
||||||
|
const restorePlayResume = useCallback(
|
||||||
|
async (snap: PlayResumeSnapshot): Promise<void> => {
|
||||||
|
const last = snap.session.history[snap.session.history.length - 1];
|
||||||
|
if (!last?.scene) throw new Error("resume snapshot missing current scene");
|
||||||
|
|
||||||
|
setOrientation(snap.orientation);
|
||||||
|
visitedBeatsRef.current = [...snap.visitedBeats];
|
||||||
|
lastImageOriginalUrlRef.current = snap.imageOriginalUrl;
|
||||||
|
|
||||||
|
setSession(snap.session);
|
||||||
|
setCurrentScene(last.scene);
|
||||||
|
setCurrentBeatId(snap.beatId);
|
||||||
|
|
||||||
|
const blobUrl = await getOrCreateBlobUrl(snap.imageOriginalUrl);
|
||||||
|
const ready = waitForImageReady();
|
||||||
|
setImageUrl(blobUrl);
|
||||||
|
await ready;
|
||||||
|
setPhase("ready");
|
||||||
|
track("scene_reached", { scene_index: snap.session.history.length });
|
||||||
|
|
||||||
|
if (snap.pendingAction) setPendingReplayAction(snap.pendingAction);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||||
// Accumulator for resolved prefetches across the whole session — every
|
// Accumulator for resolved prefetches across the whole session — every
|
||||||
@@ -1359,6 +1470,47 @@ function PlayInner() {
|
|||||||
if (startedRef.current) return;
|
if (startedRef.current) return;
|
||||||
startedRef.current = true;
|
startedRef.current = true;
|
||||||
|
|
||||||
|
// ── OAuth resume ────────────────────────────────────────────────
|
||||||
|
// Returning from a Google/GitHub round-trip? The full-page redirect
|
||||||
|
// destroyed the in-memory Session; if we stashed a snapshot just before
|
||||||
|
// navigating away (persistPlayResume via AuthModal.onBeforeOAuth) and the
|
||||||
|
// user is now signed in, restore the exact scene/beat instead of
|
||||||
|
// re-bootstrapping from `?card=…` (which would restart the story). OTP
|
||||||
|
// login never writes a snapshot — its onSuccess retry keeps state
|
||||||
|
// in-memory.
|
||||||
|
//
|
||||||
|
// Peek before awaiting: when there's no snapshot (the common case —
|
||||||
|
// normal card/preset/custom entry), fall straight through to the
|
||||||
|
// bootstrap below. Only when a snapshot exists do we enter the async
|
||||||
|
// gate, which itself removes the entry. This keeps the no-snapshot path
|
||||||
|
// off the retryBootstrap re-trigger loop entirely.
|
||||||
|
if (
|
||||||
|
AUTH_ENABLED &&
|
||||||
|
sessionStorage.getItem(PLAY_RESUME_KEY) !== null
|
||||||
|
) {
|
||||||
|
void (async () => {
|
||||||
|
const snap = await consumeResumeSnapshot<PlayResumeSnapshot>(
|
||||||
|
PLAY_RESUME_KEY,
|
||||||
|
);
|
||||||
|
if (!snap) {
|
||||||
|
// Snapshot existed but user isn't signed in / payload corrupt →
|
||||||
|
// consumeResumeSnapshot already removed it. Relinquish the slot so
|
||||||
|
// the normal bootstrap below re-runs on the next effect cycle.
|
||||||
|
startedRef.current = false;
|
||||||
|
setRetryBootstrap((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await restorePlayResume(snap);
|
||||||
|
} catch {
|
||||||
|
// Corrupt snapshot / network — relinquish and bootstrap normally.
|
||||||
|
startedRef.current = false;
|
||||||
|
setRetryBootstrap((n) => n + 1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 三条进入路径:
|
// 三条进入路径:
|
||||||
// ?card=<m0..f31> → 首页精选卡,直接从 /home/firstact/{name}.json
|
// ?card=<m0..f31> → 首页精选卡,直接从 /home/firstact/{name}.json
|
||||||
// 静态文件加载(已在构建期 prebake,免一切引擎调用)
|
// 静态文件加载(已在构建期 prebake,免一切引擎调用)
|
||||||
@@ -1579,7 +1731,30 @@ function PlayInner() {
|
|||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [params, router]);
|
}, [params, router, retryBootstrap, restorePlayResume]);
|
||||||
|
|
||||||
|
// ── Deferred replay of the action that hit 401 (OAuth resume) ─────────
|
||||||
|
// After restorePlayResume commits the restored session/scene/beat, dispatch
|
||||||
|
// the pending action so the player lands exactly where they were headed
|
||||||
|
// (seamless continuation). Runs once the restored state is interactive,
|
||||||
|
// then clears the slot. Mirrors the homepage's autoStartPending pattern.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingReplayAction) return;
|
||||||
|
if (phase !== "ready" || !session || !currentScene) return;
|
||||||
|
const action = pendingReplayAction;
|
||||||
|
setPendingReplayAction(null);
|
||||||
|
if (action.kind === "choice") {
|
||||||
|
onSelectChoice(action.choice);
|
||||||
|
} else if (action.kind === "freeform") {
|
||||||
|
void onFreeformInput(action.text);
|
||||||
|
} else {
|
||||||
|
void onBackgroundClick({ x: action.x, y: action.y });
|
||||||
|
}
|
||||||
|
// onSelectChoice/onFreeformInput/onBackgroundClick are stable inner
|
||||||
|
// functions keyed off the restored state; listing them would re-fire on
|
||||||
|
// every render, so we intentionally scope deps to the readiness gate.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pendingReplayAction, phase, session, currentScene]);
|
||||||
|
|
||||||
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
|
// ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ──────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1647,6 +1822,7 @@ function PlayInner() {
|
|||||||
visitedForCurrent: string[],
|
visitedForCurrent: string[],
|
||||||
exitLabel: string,
|
exitLabel: string,
|
||||||
retry?: () => void,
|
retry?: () => void,
|
||||||
|
action?: PendingResumeAction,
|
||||||
) {
|
) {
|
||||||
const sceneT0 = Date.now();
|
const sceneT0 = Date.now();
|
||||||
setPhase("transitioning");
|
setPhase("transitioning");
|
||||||
@@ -1706,7 +1882,7 @@ function PlayInner() {
|
|||||||
setPhase("ready");
|
setPhase("ready");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!handleAuthError(e, retry)) {
|
if (!handleAuthError(e, retry, action)) {
|
||||||
trackPlayError("scene", e, sceneT0);
|
trackPlayError("scene", e, sceneT0);
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -1903,8 +2079,13 @@ function PlayInner() {
|
|||||||
|
|
||||||
const cached = consumeChoice(poolRef.current, choice.id);
|
const cached = consumeChoice(poolRef.current, choice.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
void performSceneTransition(cached, exit, visited, choice.label, () =>
|
void performSceneTransition(
|
||||||
onSelectChoice(choice),
|
cached,
|
||||||
|
exit,
|
||||||
|
visited,
|
||||||
|
choice.label,
|
||||||
|
() => onSelectChoice(choice),
|
||||||
|
{ kind: "choice", choice },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1930,8 +2111,13 @@ function PlayInner() {
|
|||||||
return data;
|
return data;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
void performSceneTransition(promise, exit, visited, choice.label, () =>
|
void performSceneTransition(
|
||||||
onSelectChoice(choice),
|
promise,
|
||||||
|
exit,
|
||||||
|
visited,
|
||||||
|
choice.label,
|
||||||
|
() => onSelectChoice(choice),
|
||||||
|
{ kind: "choice", choice },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2037,9 +2223,10 @@ function PlayInner() {
|
|||||||
visited,
|
visited,
|
||||||
decision.freeformAction,
|
decision.freeformAction,
|
||||||
() => onFreeformInput(text),
|
() => onFreeformInput(text),
|
||||||
|
{ kind: "freeform", text },
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!handleAuthError(e, () => onFreeformInput(text))) {
|
if (!handleAuthError(e, () => onFreeformInput(text), { kind: "freeform", text })) {
|
||||||
trackPlayError("freeform", e, freeformT0);
|
trackPlayError("freeform", e, freeformT0);
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -2146,10 +2333,11 @@ function PlayInner() {
|
|||||||
visited,
|
visited,
|
||||||
decision.intent.freeformAction,
|
decision.intent.freeformAction,
|
||||||
() => onBackgroundClick(click),
|
() => onBackgroundClick(click),
|
||||||
|
{ kind: "background-click", x: click.x, y: click.y },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!handleAuthError(e, () => onBackgroundClick(click))) {
|
if (!handleAuthError(e, () => onBackgroundClick(click), { kind: "background-click", x: click.x, y: click.y })) {
|
||||||
trackPlayError("vision", e, visionT0);
|
trackPlayError("vision", e, visionT0);
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -2259,13 +2447,16 @@ function PlayInner() {
|
|||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
// User dismissed login — drop the retry, don't re-run the action.
|
// User dismissed login — drop the retry, don't re-run the action.
|
||||||
authResolveRef.current = null;
|
authResolveRef.current = null;
|
||||||
|
pendingResumeActionRef.current = null;
|
||||||
}}
|
}}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
const retry = authResolveRef.current;
|
const retry = authResolveRef.current;
|
||||||
authResolveRef.current = null;
|
authResolveRef.current = null;
|
||||||
|
pendingResumeActionRef.current = null;
|
||||||
retry?.();
|
retry?.();
|
||||||
}}
|
}}
|
||||||
|
onBeforeOAuth={persistPlayResume}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2457,13 +2648,16 @@ function PlayInner() {
|
|||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
// User dismissed login — drop the retry, don't re-run the action.
|
// User dismissed login — drop the retry, don't re-run the action.
|
||||||
authResolveRef.current = null;
|
authResolveRef.current = null;
|
||||||
|
pendingResumeActionRef.current = null;
|
||||||
}}
|
}}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
const retry = authResolveRef.current;
|
const retry = authResolveRef.current;
|
||||||
authResolveRef.current = null;
|
authResolveRef.current = null;
|
||||||
|
pendingResumeActionRef.current = null;
|
||||||
retry?.();
|
retry?.();
|
||||||
}}
|
}}
|
||||||
|
onBeforeOAuth={persistPlayResume}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ type AuthStep = "pick" | "email-input" | "otp-verify";
|
|||||||
export function AuthModal({
|
export function AuthModal({
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
onBeforeOAuth,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
|
// Fires synchronously before the OAuth full-page redirect (signInWithOAuth
|
||||||
|
// navigates the browser away, unmounting the whole React tree). Hosts that
|
||||||
|
// need to survive the round-trip (e.g. play page carrying in-memory game
|
||||||
|
// state) snapshot into sessionStorage here — sessionStorage.setItem is
|
||||||
|
// synchronous, so it completes before the navigation begins.
|
||||||
|
onBeforeOAuth?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<AuthStep>("pick");
|
const [step, setStep] = useState<AuthStep>("pick");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -31,6 +38,16 @@ export function AuthModal({
|
|||||||
async (provider: "google" | "github") => {
|
async (provider: "google" | "github") => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
// Snapshot before navigating away — the redirect below unmounts the app,
|
||||||
|
// so any host state must be persisted to sessionStorage *now*.
|
||||||
|
// Non-fatal: if the snapshot fails (e.g. sessionStorage is blocked in
|
||||||
|
// privacy mode), the OAuth flow still proceeds — the user just won't
|
||||||
|
// have their in-progress state restored on return.
|
||||||
|
try {
|
||||||
|
onBeforeOAuth?.();
|
||||||
|
} catch {
|
||||||
|
/* snapshot failure is non-fatal */
|
||||||
|
}
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { error: oauthError } = await supabase.auth.signInWithOAuth({
|
const { error: oauthError } = await supabase.auth.signInWithOAuth({
|
||||||
provider,
|
provider,
|
||||||
@@ -43,7 +60,7 @@ export function AuthModal({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[onBeforeOAuth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendOtp = useCallback(async () => {
|
const handleSendOtp = useCallback(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Shared primitives for surviving an OAuth full-page round-trip.
|
||||||
|
//
|
||||||
|
// Google / GitHub OAuth is a full-page redirect: it unmounts the React tree
|
||||||
|
// and discards all in-memory state (the server is stateless, so the client
|
||||||
|
// carries everything). To resume where the user left off after the redirect,
|
||||||
|
// a page snapshots its domain state into sessionStorage just before navigating
|
||||||
|
// away, then consumes the snapshot on the next mount — but only if the user is
|
||||||
|
// now actually signed in.
|
||||||
|
//
|
||||||
|
// Email-OTP login never redirects (it resolves in-page), so it bypasses this
|
||||||
|
// machinery entirely and resumes synchronously via AuthModal.onSuccess.
|
||||||
|
//
|
||||||
|
// This module holds the three page-agnostic pieces: the login check, a
|
||||||
|
// quota-safe sessionStorage write (heavy data-URL fields are stripped on
|
||||||
|
// QuotaExceededError), and the consume-once resume gate. Each page keeps its
|
||||||
|
// own snapshot shape and restore side effects — only the plumbing is shared.
|
||||||
|
|
||||||
|
import { AUTH_ENABLED } from "@/lib/supabase/config";
|
||||||
|
import { createClient as createSupabaseClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
|
// True when auth is disabled (self-host with blank Supabase env) or the visitor
|
||||||
|
// already has a session. Gates any auth-required action (and the resume path).
|
||||||
|
export async function isAuthed(): Promise<boolean> {
|
||||||
|
if (!AUTH_ENABLED) return true;
|
||||||
|
const sb = createSupabaseClient();
|
||||||
|
const { data } = await sb.auth.getUser();
|
||||||
|
return !!data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a resume snapshot to sessionStorage with a quota-safe fallback.
|
||||||
|
// `fallbacks` is an ordered list of progressively-lighter payloads to try if
|
||||||
|
// the primary write fails (typically QuotaExceededError from a data-URL image).
|
||||||
|
// Each fallback drops some non-essential heavy field while keeping the data
|
||||||
|
// needed to resume. A dropped field only affects *future* generation (e.g. the
|
||||||
|
// painter on later scenes), never the scene being resumed, so degrading is
|
||||||
|
// graceful. Returns true if any write succeeded.
|
||||||
|
export function writeResumeSnapshot<T>(
|
||||||
|
key: string,
|
||||||
|
primary: T,
|
||||||
|
fallbacks: readonly T[] = [],
|
||||||
|
): boolean {
|
||||||
|
const tryWrite = (candidate: T): boolean => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(candidate));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false; // QuotaExceededError (or disabled storage)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (tryWrite(primary)) return true;
|
||||||
|
for (const fb of fallbacks) {
|
||||||
|
if (tryWrite(fb)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume-once resume gate. Returns the parsed snapshot if one exists at `key`
|
||||||
|
// AND the user is now signed in (so a stale snapshot from a failed/abandoned
|
||||||
|
// login doesn't resurrect a half-flow). Always removes the entry — either it's
|
||||||
|
// consumed here, or it's stale and must not linger. Returns null when there's
|
||||||
|
// nothing to resume, the user isn't signed in, or the payload is corrupt.
|
||||||
|
//
|
||||||
|
// `removeItem` intentionally runs before `isAuthed()` so that a network error
|
||||||
|
// during the auth check does not leave a zombie snapshot behind. Without this
|
||||||
|
// ordering, callers that guard on the snapshot's presence (play-page bootstrap)
|
||||||
|
// would re-enter this path on every effect cycle, producing an infinite retry
|
||||||
|
// loop. Dropping the snapshot on a transient network glitch is an acceptable
|
||||||
|
// trade-off — the worst case is the user lands on the first scene instead of
|
||||||
|
// resuming mid-story, which is the same experience as before this feature.
|
||||||
|
export async function consumeResumeSnapshot<T>(key: string): Promise<T | null> {
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
let authed: boolean;
|
||||||
|
try {
|
||||||
|
authed = await isAuthed();
|
||||||
|
} catch {
|
||||||
|
// Network / unexpected error during auth check. Snapshot already removed
|
||||||
|
// (prevents the caller's retry loop); return null so callers fall back to
|
||||||
|
// their default path (normal bootstrap).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!authed) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null; // corrupt snapshot — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user