| 1 | import * as QuickActions from "expo-quick-actions"; |
| 2 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 3 | import { Platform } from "react-native"; |
| 4 | import { useLinkTo, type NavigationState } from "@react-navigation/native"; |
| 5 | |
| 6 | import { |
| 7 | loadRecentThreadShortcuts, |
| 8 | saveRecentThreadShortcuts, |
| 9 | type RecentThreadShortcut, |
| 10 | } from "../../persistence/imperative"; |
| 11 | import { useThreadShell } from "../../state/entities"; |
| 12 | import { |
| 13 | activeThreadRef, |
| 14 | buildShortcutActions, |
| 15 | shortcutHref, |
| 16 | withRecentThreadShortcut, |
| 17 | } from "./appShortcuts"; |
| 18 | |
| 19 | /** |
| 20 | * Owns the launcher app shortcuts (Android long-press menu): keeps the |
| 21 | * static "New task" entry plus the recently opened threads in sync, and |
| 22 | * routes shortcut taps — cold start included — to their in-app screens. |
| 23 | * Mounted once in the root stack layout. |
| 24 | */ |
| 25 | export function useAppShortcuts(state: NavigationState): void { |
| 26 | useShortcutNavigation(); |
| 27 | useRecentThreadShortcutSync(state); |
| 28 | } |
| 29 | |
| 30 | function useShortcutNavigation(): void { |
| 31 | const linkTo = useLinkTo(); |
| 32 | const handledInitialAction = useRef(false); |
| 33 | |
| 34 | useEffect(() => { |
| 35 | // Cold start: the tapped shortcut arrives as the launch action, before |
| 36 | // any listener can fire. Navigating from here pushes the target over the |
| 37 | // initial Home route, so back returns home instead of exiting the app. |
| 38 | if (!handledInitialAction.current) { |
| 39 | handledInitialAction.current = true; |
| 40 | const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; |
| 41 | if (initialHref !== null) { |
| 42 | linkTo(initialHref); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | const subscription = QuickActions.addListener((action) => { |
| 47 | const href = shortcutHref(action); |
| 48 | if (href !== null) { |
| 49 | linkTo(href); |
| 50 | } |
| 51 | }); |
| 52 | return () => subscription.remove(); |
| 53 | }, [linkTo]); |
| 54 | } |
| 55 | |
| 56 | function useRecentThreadShortcutSync(state: NavigationState): void { |
| 57 | // Launcher shortcuts are Android-only. A null ref on iOS keeps this hook |
| 58 | // (mounted in the root stack layout) from subscribing the root to the |
| 59 | // active thread's shell, which would re-render every screen on each |
| 60 | // title/status/session change. |
| 61 | const threadRef = useMemo( |
| 62 | () => (Platform.OS === "android" ? activeThreadRef(state) : null), |
| 63 | [state], |
| 64 | ); |
| 65 | const threadShell = useThreadShell(threadRef); |
| 66 | // null until the persisted list loads; recording waits on it so the first |
| 67 | // thread opened after a cold start cannot clobber older entries. |
| 68 | const [recents, setRecents] = useState<ReadonlyArray<RecentThreadShortcut> | null>(null); |
| 69 | // Gates storage writes: a failed load falls back to an empty in-memory |
| 70 | // list (so the launcher still gets the "New task" item), but persisting |
| 71 | // that fallback would erase valid history over a transient read error. |
| 72 | // Real thread opens flip this on — by then the list is the new truth. |
| 73 | const persistableRef = useRef(false); |
| 74 | // Saves are fire-and-forget; chaining them keeps an older list from |
| 75 | // finishing after (and overwriting) a newer one. |
| 76 | const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve()); |
| 77 | |
| 78 | useEffect(() => { |
| 79 | if (Platform.OS !== "android") { |
| 80 | return; |
| 81 | } |
| 82 | |
| 83 | let cancelled = false; |
| 84 | void loadRecentThreadShortcuts() |
| 85 | .then((threads) => { |
| 86 | if (!cancelled) { |
| 87 | persistableRef.current = true; |
| 88 | setRecents(threads); |
| 89 | } |
| 90 | }) |
| 91 | .catch((error) => { |
| 92 | console.warn("[app-shortcuts] failed to load recent threads", error); |
| 93 | if (!cancelled) { |
| 94 | setRecents([]); |
| 95 | } |
| 96 | }); |
| 97 | return () => { |
| 98 | cancelled = true; |
| 99 | }; |
| 100 | }, []); |
| 101 | |
| 102 | const loaded = recents !== null; |
| 103 | const environmentId = threadRef?.environmentId ?? null; |
| 104 | const threadId = threadRef?.threadId ?? null; |
| 105 | const title = threadShell?.title ?? ""; |
| 106 | useEffect(() => { |
| 107 | if (!loaded || environmentId === null || threadId === null) { |
| 108 | return; |
| 109 | } |
| 110 | |
| 111 | // withRecentThreadShortcut returns the same array when nothing changed, |
| 112 | // so React bails out and the persist effect below does not re-fire. |
| 113 | setRecents((current) => { |
| 114 | if (current === null) { |
| 115 | return current; |
| 116 | } |
| 117 | const next = withRecentThreadShortcut(current, { environmentId, threadId, title }); |
| 118 | if (next !== current) { |
| 119 | persistableRef.current = true; |
| 120 | } |
| 121 | return next; |
| 122 | }); |
| 123 | }, [loaded, environmentId, threadId, title]); |
| 124 | |
| 125 | useEffect(() => { |
| 126 | if (recents === null) { |
| 127 | return; |
| 128 | } |
| 129 | |
| 130 | if (persistableRef.current) { |
| 131 | saveQueueRef.current = saveQueueRef.current.then( |
| 132 | () => |
| 133 | saveRecentThreadShortcuts(recents).catch((error) => { |
| 134 | console.warn("[app-shortcuts] failed to persist recent threads", error); |
| 135 | }), |
| 136 | () => undefined, |
| 137 | ); |
| 138 | } |
| 139 | void QuickActions.setItems(buildShortcutActions(recents)).catch((error) => { |
| 140 | console.warn("[app-shortcuts] failed to update launcher shortcuts", error); |
| 141 | }); |
| 142 | }, [recents]); |
| 143 | } |