Open History to see the last commit
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
7const MAX_RECENT_THREAD_SHORTCUTS = 3;
8
9const NEW_TASK_SHORTCUT_ID = "new-task";
10const NEW_TASK_SHORTCUT_HREF = "/new";
11
12const SHORTCUT_ICON = "shortcut_icon";
13
14const THREAD_SHORTCUT_HREF_PATTERN = /^\/threads\/[^/?#]+\/[^/?#]+$/;
15
16function threadShortcutHref(thread: RecentThreadShortcut): string {
17 return `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`;
18}
19
20function firstRouteParam(value: string | string[] | undefined): string | null {
21 if (Array.isArray(value)) {
22 return value[0] ?? null;
23 }
24
25 return value ?? null;
26}
27
28/** Never throw: malformed deep-link or restored ids can fail schema constructors during root-stack render. */
29export function activeThreadRef(state: NavigationState): ScopedThreadRef | null {
30 const route = state.routes[state.index];
31 if (route?.name !== "Thread") {
32 return null;
33 }
34
35 try {
36 const params = route.params as
37 | {
38 readonly environmentId?: string | string[];
39 readonly threadId?: string | string[];
40 }
41 | undefined;
42 const environmentId = firstRouteParam(params?.environmentId)?.trim();
43 const threadId = firstRouteParam(params?.threadId)?.trim();
44 if (!environmentId || !threadId) {
45 return null;
46 }
47
48 return {
49 environmentId: EnvironmentId.make(environmentId),
50 threadId: ThreadId.make(threadId),
51 };
52 } catch {
53 return null;
54 }
55}
56
57function threadShortcutLabel(thread: RecentThreadShortcut): string {
58 const title = thread.title.trim();
59 return title.length > 0 ? title : "Thread";
60}
61
62/** Launcher shortcuts survive updates; allowlist destinations so malformed or stale paths cannot navigate. */
63export function shortcutHref(action: Action): string | null {
64 const href = action.params?.href;
65 if (typeof href !== "string") {
66 return null;
67 }
68
69 return href === NEW_TASK_SHORTCUT_HREF || THREAD_SHORTCUT_HREF_PATTERN.test(href) ? href : null;
70}
71
72export function withRecentThreadShortcut(
73 current: ReadonlyArray<RecentThreadShortcut>,
74 opened: RecentThreadShortcut,
75): ReadonlyArray<RecentThreadShortcut> {
76 const existing = current.find(
77 (thread) =>
78 thread.environmentId === opened.environmentId && thread.threadId === opened.threadId,
79 );
80 const title = opened.title.trim().length > 0 ? opened.title : (existing?.title ?? opened.title);
81 if (current[0] === existing && existing !== undefined && existing.title === title) {
82 return current;
83 }
84
85 return [
86 { environmentId: opened.environmentId, threadId: opened.threadId, title },
87 ...current.filter((thread) => thread !== existing),
88 ].slice(0, MAX_RECENT_THREAD_SHORTCUTS);
89}
90
91export function buildShortcutActions(recents: ReadonlyArray<RecentThreadShortcut>): Action[] {
92 return [
93 {
94 id: NEW_TASK_SHORTCUT_ID,
95 title: "New task",
96 icon: SHORTCUT_ICON,
97 params: { href: NEW_TASK_SHORTCUT_HREF },
98 },
99 ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map((thread): Action => ({
100 // URI-encoded hrefs avoid env/thread id collisions that plain hyphen joins allow.
101 id: `thread:${threadShortcutHref(thread)}`,
102 title: threadShortcutLabel(thread),
103 icon: SHORTCUT_ICON,
104 params: { href: threadShortcutHref(thread) },
105 })),
106 ];
107}