import { useRecyclingState } from "@legendapp/list/react-native"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentMachineKind } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import Svg, { Circle, Path } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { QueuedMessageIcon } from "./queued-message-icon"; import { resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** * Shared presentation for the thread lists: the compact (phone) Home list and * the iPad sidebar render the SAME items — group headers with collapse, * thread rows with status/PR/subtitle, and show-more rows — differing only in * metrics and chrome via `variant`. */ export type ThreadListVariant = "compact" | "sidebar"; /** Left inset that aligns compact secondary rows with the title column. */ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor( pr: Pick, colorScheme: "light" | "dark", ) { const dark = colorScheme === "dark"; if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) { return dark ? "#a1a1aa" : "#71717a"; } switch (pr.state) { case "open": return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; case null: case "closed": return dark ? "#a1a1aa" : "#71717a"; } } function PullRequestIcon(props: { readonly size: number; readonly color: string }) { return ( ); } /* ─── Project group header ───────────────────────────────────────────── */ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { readonly variant: ThreadListVariant; readonly project: EnvironmentProject; readonly title: string; readonly threadCount: number; readonly collapsed: boolean; readonly isFirst: boolean; readonly groupKey: string; readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; /** Project a quick new thread should target; null hides the button. */ readonly newThreadTarget?: EnvironmentProject | null; readonly onNewThread?: (project: EnvironmentProject) => void; }) { const { groupKey, onGroupAction, onNewThread } = props; const newThreadTarget = props.newThreadTarget ?? null; const compact = props.variant === "compact"; const handleToggle = useCallback( () => onGroupAction(groupKey, "toggle-collapsed"), [groupKey, onGroupAction], ); const handleNewThread = useCallback(() => { if (newThreadTarget) { onNewThread?.(newThreadTarget); } }, [newThreadTarget, onNewThread]); const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null; // The new-thread button is a SIBLING of the collapse toggle, not a child: // nested touchables are unreachable to VoiceOver/TalkBack (the parent // swallows focus). Row padding lives on the container (explicit styles — // dynamic padding classes on Pressable did not apply reliably) so both // children share one centerline; hitSlop restores the padded tap area. const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 }; return ( {props.title} {props.threadCount} {showNewThreadButton ? ( ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} > ) : null} ); }); /* ─── Show more / show less row ──────────────────────────────────────── */ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { readonly variant: ThreadListVariant; readonly hiddenCount: number; readonly canShowLess: boolean; readonly groupKey: string; readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; }) { const showsMore = props.hiddenCount > 0; const compact = props.variant === "compact"; const { groupKey, onGroupAction } = props; const handleShowMore = useCallback( () => onGroupAction(groupKey, "show-more"), [groupKey, onGroupAction], ); const handleShowLess = useCallback( () => onGroupAction(groupKey, "show-less"), [groupKey, onGroupAction], ); const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( ({ opacity: pressed ? 0.6 : 1, paddingHorizontal: compact ? 14 : 12, paddingVertical: compact ? 7 : 6, borderCurve: "continuous", })} > {label} ); return ( {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} ); }); /* ─── Pending task row ───────────────────────────────────────────────── */ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, ]; /** * Unsent work: a task queued in the outbox for its environment to reconnect, * or a draft still sitting in the project's new-task composer. Tapping * reopens the composer with everything prefilled; the row disappears once * the work is sent and the real thread arrives. The two kinds differ in what * happens next, so the pill and icon say which one this is: a queued task * sends itself, a draft waits for the user. */ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly variant: ThreadListVariant; readonly pendingTask: PendingNewTask; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; readonly isLast: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const compact = props.variant === "compact"; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const isDraft = pendingTask.kind === "draft"; const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt); // The pill only has room for one word, so what happens next goes in the // subtitle: a queued task sends itself, a draft waits for the user. const subtitleParts = [ isDraft ? null : "Sends on reconnect", props.environmentLabel, pendingTask.branch, ].filter((part): part is string => Boolean(part)); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); }, [onDeletePendingTask, pendingTask], ); const statusPill = isDraft ? ( Draft ) : ( Pending ); const subtitleRow = subtitleParts.length > 0 ? ( {props.environmentLabel && props.environmentMachine ? ( ) : null} {subtitleParts.join(" · ")} ) : null; const accessibilityHint = isDraft ? "Opens the draft in the new task composer" : "Sends when the environment reconnects. Opens the task for editing"; const rowContent = compact ? ( onSelectPendingTask(pendingTask)} > {pendingTask.title} {statusPill} {timestamp !== null ? ( {timestamp} ) : null} {subtitleRow} ) : ( onSelectPendingTask(pendingTask)} style={{ borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, }} > {pendingTask.title} {statusPill} {timestamp !== null ? ( {timestamp} ) : null} {subtitleRow} ); return ( {rowContent} ); }); /* ─── Thread row ─────────────────────────────────────────────────────── */ const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; export const ThreadListRow = memo(function ThreadListRow(props: { readonly variant: ThreadListVariant; readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; /** A message for this thread is waiting in the outbox. */ readonly hasQueuedMessages?: boolean; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly isLast: boolean; /** Sidebar only: the thread currently open in the detail pane. */ readonly selected?: boolean; /** Defaults to window width minus compact margins. */ readonly fullSwipeWidth?: number; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); const { themeAppearance: colorScheme, materialYouStyleLayoutActive } = useAppearancePreferences(); const compact = props.variant === "compact"; const selected = props.selected === true; const visuallySelected = selected && (!compact || materialYouStyleLayoutActive); // Recycling-safe: resets when the list container is reused for another // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; const drawerColor = theme["--color-drawer"]; const pressedBackgroundColor = theme["--color-subtle"]; const selectedBackgroundColor = theme["--color-user-bubble"]; const materialSelectedBackgroundColor = theme["--color-thread-selected"]; const materialSelectedForegroundColor = theme["--color-thread-selected-foreground"]; const selectedForegroundColor = theme["--color-user-bubble-foreground"]; const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle, onNewThreadOnBranch, } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread); const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); const threadAccessibilityLabel = [ thread.title, pr?.accessibilityLabel, props.hasQueuedMessages ? "messages queued to send" : null, ] .filter(Boolean) .join(", "); const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => Boolean(part), ); const backgroundColor = compact ? screenColor : drawerColor; const effectiveSelectedBackground = materialYouStyleLayoutActive ? materialSelectedBackgroundColor : selectedBackgroundColor; const effectiveSelectedForeground = materialYouStyleLayoutActive ? materialSelectedForegroundColor : selectedForegroundColor; const effectivePressedBackground = visuallySelected ? themeColorWithAlpha(String(effectiveSelectedForeground), 0.16) : pressedBackgroundColor; const effectiveStatus = visuallySelected && status ? { ...status, pillClassName: materialYouStyleLayoutActive ? "bg-thread-selected-foreground/20" : "bg-user-bubble-foreground/20", textClassName: materialYouStyleLayoutActive ? "text-thread-selected-foreground" : "text-user-bubble-foreground", } : status; const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], ); const menuActions = useMemo( () => [ ...(thread.branch ? [ { id: "new-thread-on-branch", title: Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`, image: "square.and.pencil", }, ] : []), THREAD_ROW_MENU_ACTIONS[0]!, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), THREAD_ROW_MENU_ACTIONS[1]!, ], [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], ); const primaryAction = useMemo( () => ({ accessibilityLabel: `Archive ${thread.title}`, icon: "archivebox" as const, label: "Archive", onPress: handleArchive, }), [handleArchive, thread.title], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread], ); const statusPill = effectiveStatus ? ( {effectiveStatus.label} ) : null; const subtitleRow = subtitleParts.length > 0 || pr !== null ? ( {subtitleParts.length > 0 ? ( <> {props.environmentLabel && props.environmentMachine ? ( ) : null} {subtitleParts.join(" · ")} ) : null} {pr !== null ? ( {pr.kind === "stack" ? ( ) : ( )} {pr.label} ) : null} ) : null; const rowContent = (close: () => void) => compact ? ( { close(); onSelectThread(thread); }} > {thread.title} {props.hasQueuedMessages ? ( ) : null} {statusPill} {timestamp} {props.searchMatch ? ( ) : null} {subtitleRow} ) : ( setHovered(true)} onHoverOut={() => setHovered(false)} onPress={() => { close(); onSelectThread(thread); }} style={({ pressed }) => ({ backgroundColor: visuallySelected ? effectiveSelectedBackground : pressed || hovered ? effectivePressedBackground : backgroundColor, borderRadius: SIDEBAR_ROW_RADIUS, cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10, })} > {thread.title} {props.hasQueuedMessages ? ( ) : null} {statusPill} {timestamp} {props.searchMatch ? ( ) : null} {subtitleRow} ); return ( {(close) => ( // Messages-style row actions on long-press. iOS: a real // UIContextMenuInteraction with the row as the zoom preview (needs the // patched @react-native-menu, see // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the // interaction is hosted by the component view and the underlying // UIButton passes touches through, so row taps keep working). Android: // ControlPillMenu injects onLongPress into the row and anchors the // token-styled dropdown to it; taps and swipes are untouched. {rowContent(close)} )} ); });