Open History to see the last commit
1import * as QuickActions from "expo-quick-actions";
2import { Platform } from "react-native";
3import type { NavigationState } from "@react-navigation/native";
4import type { ScopedThreadRef } from "@t3tools/contracts";
5import { Atom } from "effect/reactivity";
6
7import { environmentCatalog } from "../../connection/catalog";
8import {
9 loadRecentThreadShortcuts,
10 saveRecentThreadShortcuts,
11 type RecentThreadShortcut,
12} from "../../persistence/imperative";
13import { appAtomRegistry } from "../../state/atom-registry";
14import { environmentThreadShells } from "../../state/threads";
15import {
16 activeThreadRef,
17 buildShortcutActions,
18 shortcutHref,
19 withRecentThreadShortcut,
20} from "./appShortcuts";
21
22export interface RecentThreadShortcutsDependencies {
23 readonly load: () => Promise<ReadonlyArray<RecentThreadShortcut>>;
24 readonly save: (recents: ReadonlyArray<RecentThreadShortcut>) => Promise<void>;
25 readonly publish: (recents: ReadonlyArray<RecentThreadShortcut>) => Promise<void>;
26}
27
28/**
29 * Keeps the launcher's recent-thread list. Recording waits for the persisted
30 * list so the first thread opened after a cold start cannot clobber older
31 * entries, and a failed load never persists its empty fallback over valid
32 * history until a real thread open makes the in-memory list the new truth.
33 */
34export function createRecentThreadShortcuts(dependencies: RecentThreadShortcutsDependencies) {
35 let recents: ReadonlyArray<RecentThreadShortcut> | null = null;
36 let persistable = false;
37 let active: RecentThreadShortcut | null = null;
38 let savedEnvironmentIds: ReadonlySet<string> | null = null;
39 // Serialize saves so older lists cannot overwrite newer ones.
40 let saveQueue: Promise<unknown> = Promise.resolve();
41
42 const commit = (next: ReadonlyArray<RecentThreadShortcut>) => {
43 recents = next;
44 if (persistable) {
45 saveQueue = saveQueue.then(
46 () =>
47 dependencies.save(next).catch((error: unknown) => {
48 console.warn("[app-shortcuts] failed to persist recent threads", error);
49 }),
50 () => undefined,
51 );
52 }
53 void dependencies.publish(next).catch((error: unknown) => {
54 console.warn("[app-shortcuts] failed to update launcher shortcuts", error);
55 });
56 };
57
58 const isSaved = (thread: RecentThreadShortcut) =>
59 savedEnvironmentIds?.has(thread.environmentId) ?? true;
60
61 const withActive = (list: ReadonlyArray<RecentThreadShortcut>) => {
62 if (active === null || !isSaved(active)) return list;
63 const next = withRecentThreadShortcut(list, active);
64 if (next !== list) persistable = true;
65 return next;
66 };
67
68 const loaded = dependencies.load().then(
69 (threads) => {
70 persistable = true;
71 commit(withActive(threads.filter(isSaved)));
72 },
73 (error: unknown) => {
74 console.warn("[app-shortcuts] failed to load recent threads", error);
75 commit(withActive([]));
76 },
77 );
78
79 return {
80 loaded,
81 /** Resolves once every save issued so far has finished. */
82 saved: () => saveQueue,
83 recordActiveThread(next: RecentThreadShortcut | null): void {
84 active = next;
85 if (recents === null || next === null) return;
86 const updated = withActive(recents);
87 if (updated !== recents) commit(updated);
88 },
89 retainEnvironments(next: ReadonlySet<string>): void {
90 savedEnvironmentIds = next;
91 if (recents === null) return;
92 const kept = recents.filter(isSaved);
93 if (kept.length !== recents.length) commit(kept);
94 },
95 };
96}
97
98const activeThreadRefAtom = Atom.make<ScopedThreadRef | null>(null).pipe(
99 Atom.keepAlive,
100 Atom.withLabel("mobile:app-shortcuts-active-thread-ref"),
101);
102
103const activeThreadShortcutAtom = Atom.make((get): RecentThreadShortcut | null => {
104 const ref = get(activeThreadRefAtom);
105 if (ref === null) return null;
106 const title = get(environmentThreadShells.threadShellAtom(ref))?.title ?? "";
107 return { environmentId: ref.environmentId, threadId: ref.threadId, title };
108}).pipe(
109 Atom.withEquality(
110 (left: RecentThreadShortcut | null, right: RecentThreadShortcut | null) =>
111 left === right ||
112 (left !== null &&
113 right !== null &&
114 left.environmentId === right.environmentId &&
115 left.threadId === right.threadId &&
116 left.title === right.title),
117 ),
118);
119
120/** Wired to the root navigation container's state changes. */
121export function recordAppShortcutNavigationState(state: NavigationState): void {
122 // Launcher shortcuts are Android-only; iOS never subscribes to thread shells.
123 if (Platform.OS !== "android") return;
124 const next = activeThreadRef(state);
125 const current = appAtomRegistry.get(activeThreadRefAtom);
126 if (next?.environmentId === current?.environmentId && next?.threadId === current?.threadId) {
127 return;
128 }
129 appAtomRegistry.set(activeThreadRefAtom, next);
130}
131
132/**
133 * App-lifetime quick actions. `navigate` must queue until navigation is ready:
134 * a cold-start shortcut arrives as the launch action, and pushing it over the
135 * initial Home route makes back return home instead of exiting the app.
136 */
137export function installAppShortcuts(navigate: (href: string) => void): () => void {
138 const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null;
139 if (initialHref !== null) navigate(initialHref);
140 const subscription = QuickActions.addListener((action) => {
141 const href = shortcutHref(action);
142 if (href !== null) navigate(href);
143 });
144 if (Platform.OS !== "android") return () => subscription.remove();
145
146 const recents = createRecentThreadShortcuts({
147 load: loadRecentThreadShortcuts,
148 save: saveRecentThreadShortcuts,
149 publish: (list) => QuickActions.setItems(buildShortcutActions(list)),
150 });
151 const unsubscribe = appAtomRegistry.subscribe(
152 activeThreadShortcutAtom,
153 (active) => recents.recordActiveThread(active),
154 { immediate: true },
155 );
156 const unsubscribeCatalog = appAtomRegistry.subscribe(
157 environmentCatalog.catalogValueAtom,
158 (catalog) => {
159 if (catalog.isReady) recents.retainEnvironments(new Set(catalog.entries.keys()));
160 },
161 { immediate: true },
162 );
163 return () => {
164 subscription.remove();
165 unsubscribe();
166 unsubscribeCatalog();
167 };
168}