import type { Action } from "expo-quick-actions"; import type { NavigationState } from "@react-navigation/native"; import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import type { RecentThreadShortcut } from "../../persistence/imperative"; // Launchers cap visible shortcuts around 4; one slot is the static // "New task" entry, the rest rotate through recently opened threads. export const MAX_RECENT_THREAD_SHORTCUTS = 3; export const NEW_TASK_SHORTCUT_ID = "new-task"; const NEW_TASK_SHORTCUT_HREF = "/new"; // Adaptive icon resource generated by the expo-quick-actions config plugin // (androidIcons key in app.config.ts). const SHORTCUT_ICON = "shortcut_icon"; // Matches only the thread deep-link shape shortcuts are allowed to carry: // exactly two non-empty segments, no nested paths, queries, or fragments. const THREAD_SHORTCUT_HREF_PATTERN = /^\/threads\/[^/?#]+\/[^/?#]+$/; function threadShortcutHref(thread: RecentThreadShortcut): string { return `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`; } function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { return value[0] ?? null; } return value ?? null; } /** * Thread ref of the navigation state's ACTIVE route, or null. Never throws: * route params can arrive malformed from external deep links or restored * state, the branded id constructors throw on values failing their schema, * and this runs during render of the root stack layout — an uncaught throw * here would take down the whole navigation tree. */ export function activeThreadRef(state: NavigationState): ScopedThreadRef | null { const route = state.routes[state.index]; if (route?.name !== "Thread") { return null; } try { const params = route.params as | { readonly environmentId?: string | string[]; readonly threadId?: string | string[]; } | undefined; const environmentId = firstRouteParam(params?.environmentId)?.trim(); const threadId = firstRouteParam(params?.threadId)?.trim(); if (!environmentId || !threadId) { return null; } return { environmentId: EnvironmentId.make(environmentId), threadId: ThreadId.make(threadId), }; } catch { return null; } } function threadShortcutLabel(thread: RecentThreadShortcut): string { const title = thread.title.trim(); return title.length > 0 ? title : "Thread"; } /** * In-app navigation path carried by a shortcut, or null for anything * malformed — shortcuts persist in the launcher across app updates, so this * allowlists the exact destinations shortcuts can produce rather than * trusting any path-shaped string (`//host`, arbitrary routes, stale * persisted junk all navigate nowhere). */ export function shortcutHref(action: Action): string | null { const href = action.params?.href; if (typeof href !== "string") { return null; } return href === NEW_TASK_SHORTCUT_HREF || THREAD_SHORTCUT_HREF_PATTERN.test(href) ? href : null; } /** * Records an opened thread as the most recent shortcut entry. Returns the * SAME array when nothing changed so effect chains keyed on identity skip * redundant launcher/storage writes. An empty title never clobbers a known * one — shells load after navigation, so the first record of a thread often * predates its title. */ export function withRecentThreadShortcut( current: ReadonlyArray, opened: RecentThreadShortcut, ): ReadonlyArray { const existing = current.find( (thread) => thread.environmentId === opened.environmentId && thread.threadId === opened.threadId, ); const title = opened.title.trim().length > 0 ? opened.title : (existing?.title ?? opened.title); if (current[0] === existing && existing !== undefined && existing.title === title) { return current; } return [ { environmentId: opened.environmentId, threadId: opened.threadId, title }, ...current.filter((thread) => thread !== existing), ].slice(0, MAX_RECENT_THREAD_SHORTCUTS); } /** Full launcher shortcut list: static "New task" first, then recents. */ export function buildShortcutActions(recents: ReadonlyArray): Action[] { return [ { id: NEW_TASK_SHORTCUT_ID, title: "New task", icon: SHORTCUT_ICON, params: { href: NEW_TASK_SHORTCUT_HREF }, }, ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map( (thread): Action => ({ // The encoded href doubles as the launcher id: URI-encoding makes the // env/thread join unambiguous (a plain `-` join lets different pairs // collide and overwrite each other's launcher slots). id: `thread:${threadShortcutHref(thread)}`, title: threadShortcutLabel(thread), icon: SHORTCUT_ICON, params: { href: threadShortcutHref(thread) }, }), ), ]; }