1import * as QuickActions from "expo-quick-actions";
2import { useEffect, useMemo, useRef, useState } from "react";
3import { Platform } from "react-native";
4import { useLinkTo, type NavigationState } from "@react-navigation/native";
5
6import {
7 loadRecentThreadShortcuts,
8 saveRecentThreadShortcuts,
9 type RecentThreadShortcut,
10} from "../../persistence/imperative";
11import { useThreadShell } from "../../state/entities";
12import {
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 */
25export function useAppShortcuts(state: NavigationState): void {
26 useShortcutNavigation();
27 useRecentThreadShortcutSync(state);
28}
29
30function 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
56function 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}