import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TipsDialogMode } from "@/components/tips-dialog"; import { effectivePlanOf, getEntitlements } from "@/lib/entitlements"; import { FRESH_LOGIN_WINDOW_MS, isPlanTip, pickAutoTip, TIP_AUTO_DELAY_MS, type TipDefinition, type TipId, tipsFor, } from "@/lib/tips"; import type { CloudUser } from "@/types"; /** Mirror of `settings_manager::TipsState`. */ export interface TipsState { auto_show: boolean; seen: string[]; last_auto_shown_at: number | null; auto_due: boolean; } export interface TipsDialogState { open: boolean; mode: TipsDialogMode; initialTipId: TipId | null; auto: boolean; /** Bumped on every open so the dialog remounts with fresh navigation state. */ session: number; } interface PaidWelcomeState { plan: string; status: "pending" | "open" | "done"; } interface UseTipsOptions { cloudUser: CloudUser | null; loggedInAt: string | null; /** * The app is settled enough to put a dialog in front of the user: not the * first-run session, terms accepted, nothing else blocking. */ ready: boolean; } /** * The tips flow: which tips this install may see, what has been seen, the * one tip a day that opens by itself, and the welcome for an account that * just turned paid. State lives in the app settings; this hook only decides. */ export function useTips({ cloudUser, loggedInAt, ready }: UseTipsOptions) { const [state, setState] = useState(null); const [dialog, setDialog] = useState({ open: false, mode: "browse", initialTipId: null, auto: false, session: 0, }); const [paidWelcome, setPaidWelcome] = useState(null); const entitlements = useMemo(() => getEntitlements(cloudUser), [cloudUser]); const tips = useMemo(() => tipsFor(entitlements), [entitlements]); const planTips = useMemo(() => tips.filter(isPlanTip), [tips]); useEffect(() => { let cancelled = false; invoke("get_tips_state") .then((loaded) => { if (!cancelled) setState(loaded); }) .catch((error: unknown) => { console.error("Failed to load the tips state:", error); }); return () => { cancelled = true; }; }, []); const openTips = useCallback( ( initialTipId: TipId | null = null, options: { mode?: TipsDialogMode; auto?: boolean } = {}, ) => { setDialog((previous) => ({ open: true, mode: options.mode ?? "browse", initialTipId, auto: options.auto ?? false, session: previous.session + 1, })); }, [], ); const closeTips = useCallback(() => { setDialog((previous) => previous.open ? { ...previous, open: false } : previous, ); }, []); // One observation per account and plan status. The backend remembers the // status and answers whether this is the moment to greet a new paid plan. const observedRef = useRef(null); useEffect(() => { if (!cloudUser) return; const paid = entitlements.active; const key = `${cloudUser.id}:${paid ? "paid" : "free"}`; if (observedRef.current === key) return; observedRef.current = key; const freshLogin = loggedInAt !== null && Date.now() - Date.parse(loggedInAt) < FRESH_LOGIN_WINDOW_MS; invoke("observe_cloud_plan", { userId: cloudUser.id, paid, freshLogin, }) .then((due) => { if (!due) return; setPaidWelcome({ plan: effectivePlanOf(cloudUser), status: "pending" }); }) .catch((error: unknown) => { console.error("Failed to record the cloud plan:", error); }); }, [cloudUser, entitlements.active, loggedInAt]); // The welcome waits for a quiet moment: the app settled and no tip open. useEffect(() => { if (!ready || dialog.open || paidWelcome?.status !== "pending") return; setPaidWelcome({ plan: paidWelcome.plan, status: "open" }); }, [ready, dialog.open, paidWelcome]); // The automatic tip: one unseen tip, a moment after the app settles, and // never on top of the paid welcome. Marked handled only once it opens, so a // welcome arriving during the delay simply takes its place. const autoHandledRef = useRef(false); useEffect(() => { if (!ready || !state || autoHandledRef.current) return; if (!state.auto_due || dialog.open) return; if (paidWelcome && paidWelcome.status !== "done") return; const tip = pickAutoTip(tips, state.seen); if (!tip) return; const timer = window.setTimeout(() => { autoHandledRef.current = true; openTips(tip.id, { mode: "single", auto: true }); }, TIP_AUTO_DELAY_MS); return () => window.clearTimeout(timer); }, [ready, state, dialog.open, paidWelcome, tips, openTips]); const markSeen = useCallback(async (id: TipId, auto: boolean) => { try { setState(await invoke("mark_tip_seen", { tipId: id, auto })); } catch (error) { console.error("Failed to remember the tip as seen:", error); } }, []); const setAutoShow = useCallback(async (enabled: boolean) => { try { setState(await invoke("set_tips_auto_show", { enabled })); } catch (error) { console.error("Failed to save the tips preference:", error); } }, []); const dismissPaidWelcome = useCallback(() => { setPaidWelcome((previous) => previous ? { ...previous, status: "done" } : previous, ); }, []); return { tips, planTips, seen: state?.seen ?? [], autoShow: state?.auto_show ?? true, dialog, openTips, closeTips, markSeen, setAutoShow, paidWelcome: { open: paidWelcome?.status === "open", plan: paidWelcome?.plan ?? "", }, dismissPaidWelcome, }; } export type TipsFlow = ReturnType; export type { TipDefinition };