137 lines · 4.9 KB · TypeScript
RawDownload
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<RecentThreadShortcut>,  opened: RecentThreadShortcut,): ReadonlyArray<RecentThreadShortcut> {  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<RecentThreadShortcut>): 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) },      }),    ),  ];}