1import type { Action } from "expo-quick-actions";
2import type { NavigationState } from "@react-navigation/native";
3import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
4
5import type { RecentThreadShortcut } from "../../persistence/imperative";
6
7// Launchers cap visible shortcuts around 4; one slot is the static
8// "New task" entry, the rest rotate through recently opened threads.
9export const MAX_RECENT_THREAD_SHORTCUTS = 3;
10
11export const NEW_TASK_SHORTCUT_ID = "new-task";
12const NEW_TASK_SHORTCUT_HREF = "/new";
13
14// Adaptive icon resource generated by the expo-quick-actions config plugin
15// (androidIcons key in app.config.ts).
16const SHORTCUT_ICON = "shortcut_icon";
17
18// Matches only the thread deep-link shape shortcuts are allowed to carry:
19// exactly two non-empty segments, no nested paths, queries, or fragments.
20const THREAD_SHORTCUT_HREF_PATTERN = /^\/threads\/[^/?#]+\/[^/?#]+$/;
21
22function threadShortcutHref(thread: RecentThreadShortcut): string {
23 return `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`;
24}
25
26function firstRouteParam(value: string | string[] | undefined): string | null {
27 if (Array.isArray(value)) {
28 return value[0] ?? null;
29 }
30
31 return value ?? null;
32}
33
34/**
35 * Thread ref of the navigation state's ACTIVE route, or null. Never throws:
36 * route params can arrive malformed from external deep links or restored
37 * state, the branded id constructors throw on values failing their schema,
38 * and this runs during render of the root stack layout — an uncaught throw
39 * here would take down the whole navigation tree.
40 */
41export function activeThreadRef(state: NavigationState): ScopedThreadRef | null {
42 const route = state.routes[state.index];
43 if (route?.name !== "Thread") {
44 return null;
45 }
46
47 try {
48 const params = route.params as
49 | {
50 readonly environmentId?: string | string[];
51 readonly threadId?: string | string[];
52 }
53 | undefined;
54 const environmentId = firstRouteParam(params?.environmentId)?.trim();
55 const threadId = firstRouteParam(params?.threadId)?.trim();
56 if (!environmentId || !threadId) {
57 return null;
58 }
59
60 return {
61 environmentId: EnvironmentId.make(environmentId),
62 threadId: ThreadId.make(threadId),
63 };
64 } catch {
65 return null;
66 }
67}
68
69function threadShortcutLabel(thread: RecentThreadShortcut): string {
70 const title = thread.title.trim();
71 return title.length > 0 ? title : "Thread";
72}
73
74/**
75 * In-app navigation path carried by a shortcut, or null for anything
76 * malformed — shortcuts persist in the launcher across app updates, so this
77 * allowlists the exact destinations shortcuts can produce rather than
78 * trusting any path-shaped string (`//host`, arbitrary routes, stale
79 * persisted junk all navigate nowhere).
80 */
81export function shortcutHref(action: Action): string | null {
82 const href = action.params?.href;
83 if (typeof href !== "string") {
84 return null;
85 }
86
87 return href === NEW_TASK_SHORTCUT_HREF || THREAD_SHORTCUT_HREF_PATTERN.test(href) ? href : null;
88}
89
90/**
91 * Records an opened thread as the most recent shortcut entry. Returns the
92 * SAME array when nothing changed so effect chains keyed on identity skip
93 * redundant launcher/storage writes. An empty title never clobbers a known
94 * one — shells load after navigation, so the first record of a thread often
95 * predates its title.
96 */
97export function withRecentThreadShortcut(
98 current: ReadonlyArray<RecentThreadShortcut>,
99 opened: RecentThreadShortcut,
100): ReadonlyArray<RecentThreadShortcut> {
101 const existing = current.find(
102 (thread) =>
103 thread.environmentId === opened.environmentId && thread.threadId === opened.threadId,
104 );
105 const title = opened.title.trim().length > 0 ? opened.title : (existing?.title ?? opened.title);
106 if (current[0] === existing && existing !== undefined && existing.title === title) {
107 return current;
108 }
109
110 return [
111 { environmentId: opened.environmentId, threadId: opened.threadId, title },
112 ...current.filter((thread) => thread !== existing),
113 ].slice(0, MAX_RECENT_THREAD_SHORTCUTS);
114}
115
116/** Full launcher shortcut list: static "New task" first, then recents. */
117export function buildShortcutActions(recents: ReadonlyArray<RecentThreadShortcut>): Action[] {
118 return [
119 {
120 id: NEW_TASK_SHORTCUT_ID,
121 title: "New task",
122 icon: SHORTCUT_ICON,
123 params: { href: NEW_TASK_SHORTCUT_HREF },
124 },
125 ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map((thread): Action => ({
126 // The encoded href doubles as the launcher id: URI-encoding makes the
127 // env/thread join unambiguous (a plain `-` join lets different pairs
128 // collide and overwrite each other's launcher slots).
129 id: `thread:${threadShortcutHref(thread)}`,
130 title: threadShortcutLabel(thread),
131 icon: SHORTCUT_ICON,
132 params: { href: threadShortcutHref(thread) },
133 })),
134 ];
135}