1import { useActiveNewTask } from "../../state/active-new-task";
2import { ThreadUndoNotice } from "../home/ThreadUndoNotice";
3import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
4import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
5import {
6 useAppearancePreferences,
7 useAndroidFloatingTabs,
8} from "../settings/appearance/AppearancePreferencesProvider";
9import type {
10 EnvironmentProject,
11 EnvironmentThreadShell,
12} from "@t3tools/client-runtime/state/shell";
13import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
14import { AnimatedLegendList } from "@legendapp/list/reanimated";
15import { THREAD_SIDEBAR_ROW_LAYOUT } from "./threadListAnimations";
16import { useAtomValue } from "@effect/atom-react";
17import { useMemo, useRef, useState } from "react";
18import { Platform, StyleSheet, View } from "react-native";
19import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
21import { FLOATING_TABS } from "../layout/workspace/floating-workspace-tabs";
22import { useSafeAreaInsets } from "react-native-safe-area-context";
23
24import { AppText as Text } from "../../components/ui/AppText";
25import { Button } from "../../components/ui/Button";
26import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
27import { useProjects } from "../../state/entities";
28import { steadyNavigationThreadShellsAtom } from "../../state/threads";
29import { useThreadSearch } from "../../state/queries";
30import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
31import { useThreadListNow } from "./use-thread-list-now";
32import { usePendingThreadOrder } from "../../state/thread-order";
33import { threadListEnvironmentsAtom } from "../../state/presentation";
34import {
35 useLaunchingThreads,
36 usePendingNewTasks,
37 type PendingNewTask,
38} from "../../state/use-pending-new-tasks";
39import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
40import { useWorkspaceState } from "../../state/workspace";
41import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
42import { useThreadListV2 } from "./useThreadListV2";
43import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
44import { requestThreadMessageJump } from "./threadMessageJump";
45import { useThreadListSearchMatches } from "./useThreadListSearchMatches";
46import { useHomeListOptions } from "../home/home-list-options";
47import { buildHomeListFilterMenuItems } from "../home/home-list-filter-menu";
48import { buildHomeProjectScopes, resolveHomeProjectFilterScope } from "../home/homeThreadList";
49import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
50import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
51import { useThreadListActions } from "../home/useThreadListActions";
52import { useFabScroll } from "../home/FabScrollContext";
53import { WorkspaceListHeader } from "../layout/workspace/WorkspaceListHeader";
54import { WorkspaceSidebarColumn } from "../layout/workspace/WorkspaceSidebarColumn";
55import {
56 ThreadListV2PendingRow,
57 ThreadListV2Row,
58 ThreadListV2SettledShelfHeader,
59 ThreadListV2ShowMoreRow,
60 ThreadListV2SnoozedShelfHeader,
61 ThreadListV2WorkingShelfHeader,
62} from "./thread-list-v2-items";
63import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
64import type { ThreadPrPresentation } from "../../state/thread-pr-presentation";
65import { ThreadPrDemandProvider } from "../../state/thread-pr-demand";
66import {
67 isThreadListV2ListItem,
68 threadListV2ListItemsAreEqual,
69 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
70 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
71 type ThreadListV2ListItem,
72} from "./threadListV2";
73import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail";
74import { NativeScrollView } from "../../native/NativeScrollViewMarker";
75
76type SidebarListItem =
77 | ThreadListV2ListItem
78 | {
79 readonly type: "v2-show-more";
80 readonly key: string;
81 readonly hiddenCount: number;
82 readonly state?: "loading" | "failed";
83 };
84
85interface ThreadNavigationSidebarProps {
86 readonly width: number;
87 readonly visible: boolean;
88 readonly selectedThreadKey: string | null;
89 readonly onOpenSettings: () => void;
90 readonly onOpenEnvironmentSettings: () => void;
91 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
92 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
93 readonly onOpenPendingTask: (pendingTask: PendingNewTask) => void;
94 readonly onSearchQueryChange: (query: string) => void;
95 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
96 readonly onOpenThreadPullRequests: (
97 thread: EnvironmentThreadShell,
98 pr: ThreadPrPresentation,
99 ) => void;
100 readonly onRequestVisibility: () => void;
101 readonly searchQuery: string;
102}
103
104export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
105 return (
106 <ThreadPrDemandProvider visible={props.visible}>
107 <WorkspaceSidebarColumn width={props.width}>
108 <ThreadNavigationSidebarPane {...props} />
109 </WorkspaceSidebarColumn>
110 </ThreadPrDemandProvider>
111 );
112}
113
114function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) {
115 const floatingTabsEnabled = useAndroidFloatingTabs();
116 const { themeVariables: materialTheme } = useAppearancePreferences();
117 const drawerColor = materialTheme["--color-drawer"];
118
119 const insets = useSafeAreaInsets();
120 const { fabClearance, fabSize } = useAndroidControlSizing();
121 const projects = useProjects();
122 const threads = useAtomValue(steadyNavigationThreadShellsAtom);
123 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
124 const { savedConnectionsById } = useSavedRemoteConnections();
125 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
126 const sidebarScrollGesture = useNativeGesture();
127 const {
128 archiveThread,
129 confirmDeleteThread,
130 settleThread,
131 snoozeThread,
132 unsnoozeThread,
133 unsettleThread,
134 pinThread,
135 unpinThread,
136 setThreadAutoSettle,
137 moveThread,
138 renameThread,
139 regenerateThreadTitle,
140 } = useThreadListActions();
141 const pendingTasks = usePendingNewTasks();
142 const launchingThreads = useLaunchingThreads(threads);
143 const activeNewTask = useActiveNewTask();
144 const queuedThreadKeys = useQueuedThreadKeys();
145 const selectDetail = useSelectWorkspaceDetail();
146 const { confirmDeletePendingTask } = usePendingTaskListActions((params) =>
147 selectDetail({ name: "Thread", params }),
148 );
149 const environments = Object.values(savedConnectionsById)
150 .map((connection) => ({
151 environmentId: connection.environmentId,
152 label: connection.environmentLabel,
153 }))
154 .sort((left, right) => left.label.localeCompare(right.label));
155 const availableEnvironmentIds = new Set(
156 environments.map((environment) => environment.environmentId),
157 );
158 const { options, setSelectedEnvironmentId, setSelectedProjectKey } =
159 useHomeListOptions(availableEnvironmentIds);
160 const searchEnvironmentIds =
161 options.selectedEnvironmentId === null
162 ? workspaceEnvironments
163 .filter((environment) => environment.connectionState === "connected")
164 .map((environment) => environment.environmentId)
165 : workspaceEnvironments.some(
166 (environment) =>
167 environment.environmentId === options.selectedEnvironmentId &&
168 environment.connectionState === "connected",
169 )
170 ? [options.selectedEnvironmentId]
171 : [];
172 const projectScopes = useMemo(
173 () =>
174 buildHomeProjectScopes({
175 projects,
176 environmentId: options.selectedEnvironmentId,
177 projectGroupingMode: options.projectGroupingMode,
178 }),
179 [projects, options.selectedEnvironmentId, options.projectGroupingMode],
180 );
181 const projectFilterOptions = pickableProjectGroups(projectScopes).map((scope) => ({
182 key: scope.key,
183 label: scope.title,
184 }));
185 const projectTitleByProjectKey = useMemo(
186 () =>
187 new Map(
188 projectScopes.flatMap((scope) =>
189 scope.projectRefs.map(
190 (projectRef) =>
191 [
192 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
193 scope.title,
194 ] as const,
195 ),
196 ),
197 ),
198 [projectScopes],
199 );
200 const selectedProjectScope = resolveHomeProjectFilterScope(projectScopes, projects, options);
201 const threadSearch = useThreadSearch(
202 searchEnvironmentIds,
203 props.searchQuery,
204 selectedProjectScope === null
205 ? {}
206 : { projectIds: selectedProjectScope.projectRefs.map((ref) => ref.projectId) },
207 );
208 // This map reaches LegendList's extraData, which re-renders every row whenever it changes
209 // identity; the hook keeps it stable.
210 const threadSearchMatchByKey = useThreadListSearchMatches(threads, threadSearch);
211 const selectedProjectKey = selectedProjectScope?.key ?? null;
212 const projectByKey = useMemo(() => {
213 const map = new Map<string, EnvironmentProject>();
214 for (const project of projects) {
215 map.set(scopedProjectKey(project.environmentId, project.id), project);
216 }
217 return map;
218 }, [projects]);
219
220 const [settledVisibleCount, setSettledVisibleCount] = useState(
221 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
222 );
223 const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`;
224 const [lastSettledResetKey, setLastSettledResetKey] = useState(settledResetKey);
225 if (lastSettledResetKey !== settledResetKey) {
226 setLastSettledResetKey(settledResetKey);
227 setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
228 }
229 const showMoreSettled = () =>
230 setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT);
231 const {
232 loaded: shelfPreferencesLoaded,
233 settledShelfExpanded,
234 snoozedShelfExpanded,
235 workingShelfEnabled,
236 workingShelfExpanded,
237 toggleSettledShelf,
238 toggleSnoozedShelf,
239 toggleWorkingShelf,
240 } = useThreadListV2ShelfPreferences();
241 const now = useThreadListNow(threads);
242 const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
243 const {
244 providersByEnvironmentId,
245 machineByEnvironmentId,
246 settlementEnvironmentIds,
247 snoozeEnvironmentIds,
248 snoozePresetsByEnvironmentId,
249 pinningEnvironmentIds,
250 autoSettleOptOutEnvironmentIds,
251 pinReorderEnvironmentIds,
252 activeReorderEnvironmentIds,
253 titleRegenerationEnvironmentIds,
254 } = listEnvironments;
255 const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
256 const pendingOrder = usePendingThreadOrder();
257 const {
258 layout: threadListV2Layout,
259 items: threadListV2Items,
260 contentOnlyThreadKeys,
261 olderSettledShelf,
262 } = useThreadListV2({
263 threads: launchingThreads,
264 pendingTasks,
265 environmentId: options.selectedEnvironmentId,
266 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
267 searchQuery: props.searchQuery,
268 searchMatches: threadSearchMatchByKey,
269 settlementEnvironmentIds,
270 snoozeEnvironmentIds,
271 pinReorderEnvironmentIds,
272 activeReorderEnvironmentIds,
273 queuedThreadKeys,
274 settledLimit: settledVisibleCount,
275 now,
276 pendingOrder,
277 shelfPreferencesLoaded,
278 workingShelfEnabled,
279 workingShelfExpanded,
280 snoozedShelfExpanded,
281 settledShelfExpanded,
282 selectedThreadKey: props.selectedThreadKey ?? null,
283 });
284 const listItems: readonly SidebarListItem[] =
285 settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0
286 ? [
287 ...threadListV2Items,
288 {
289 type: "v2-show-more",
290 key: "v2-show-more",
291 hiddenCount: threadListV2Layout.hiddenSettledCount,
292 ...(olderSettledShelf.loading
293 ? { state: "loading" as const }
294 : olderSettledShelf.failed
295 ? { state: "failed" as const }
296 : {}),
297 },
298 ]
299 : threadListV2Items;
300
301 const handleSwipeableWillOpen = (methods: SwipeableMethods) => {
302 if (openSwipeableRef.current !== methods) {
303 openSwipeableRef.current?.close();
304 openSwipeableRef.current = methods;
305 }
306 };
307 const handleSwipeableClose = (methods: SwipeableMethods) => {
308 if (openSwipeableRef.current === methods) {
309 openSwipeableRef.current = null;
310 }
311 };
312 // Destructure callback props; capturing all props would invalidate these row-closing handlers on unrelated changes.
313 const { onSelectThread, onOpenThreadPullRequests } = props;
314 // A row listed for a message match opens the thread at that message, by
315 // tap or by shortcut.
316 const handleSelectThread = (thread: EnvironmentThreadShell) => {
317 const key = threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id });
318 const messageId = contentOnlyThreadKeys.has(key)
319 ? threadSearchMatchByKey.get(key)?.messageId
320 : undefined;
321 if (messageId) {
322 requestThreadMessageJump(scopedThreadKey(thread.environmentId, thread.id), messageId);
323 }
324 onSelectThread(thread);
325 openSwipeableRef.current?.close();
326 };
327 const handleOpenThreadPullRequests = (
328 thread: EnvironmentThreadShell,
329 pr: ThreadPrPresentation,
330 ) => {
331 onOpenThreadPullRequests(thread, pr);
332 openSwipeableRef.current?.close();
333 };
334 const handleScrollBeginDrag = () => {
335 openSwipeableRef.current?.close();
336 };
337 const onFabScroll = useFabScroll();
338 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
339 onScroll: onFabScroll,
340 onScrollBeginDrag: handleScrollBeginDrag,
341 });
342 // Recycle equality needs project/search data for titles and favicons; keep the minute clock on items to limit updates.
343 const listExtraData = {
344 activeNewTask,
345 selectedThreadKey: props.selectedThreadKey ?? "",
346 projectByKey,
347 projectTitleByProjectKey,
348 savedConnectionsById,
349 listEnvironments,
350 threadSearchMatchByKey,
351 workingShelfEnabled,
352 };
353 useThreadJumpShortcuts(listItems, handleSelectThread);
354 const sidebarItemsAreEqual = (previous: SidebarListItem, item: SidebarListItem): boolean => {
355 if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) {
356 return threadListV2ListItemsAreEqual(previous, item);
357 }
358 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
359 return previous.hiddenCount === item.hiddenCount && previous.state === item.state;
360 }
361 return false;
362 };
363 const renderListItem = ({ item }: { readonly item: SidebarListItem }) => {
364 switch (item.type) {
365 case "v2-pending": {
366 const pendingScopeKey = scopedProjectKey(
367 item.pendingTask.environmentId,
368 item.pendingTask.projectId,
369 );
370 return (
371 <ThreadListV2PendingRow
372 pendingTask={item.pendingTask}
373 selected={
374 activeNewTask?.pendingKey === item.pendingTask.key ||
375 (item.pendingTask.kind === "pending" &&
376 scopedThreadKey(
377 item.pendingTask.environmentId,
378 item.pendingTask.message.threadId,
379 ) === props.selectedThreadKey)
380 }
381 project={projectByKey.get(pendingScopeKey) ?? null}
382 projectTitle={projectTitleByProjectKey.get(pendingScopeKey)}
383 environmentLabel={
384 Object.keys(savedConnectionsById).length > 1
385 ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null)
386 : null
387 }
388 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
389 pane="sidebar"
390 showPendingDivider={item.showPendingDivider}
391 onSelectPendingTask={props.onOpenPendingTask}
392 onDeletePendingTask={confirmDeletePendingTask}
393 fullSwipeWidth={props.width - 20}
394 onSwipeableClose={handleSwipeableClose}
395 onSwipeableWillOpen={handleSwipeableWillOpen}
396 simultaneousSwipeGesture={sidebarScrollGesture}
397 />
398 );
399 }
400 case "v2-thread": {
401 const thread = item.item.thread;
402 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
403 return (
404 <ThreadListV2Row
405 onNewThreadOnBranch={props.onNewThreadOnBranch}
406 thread={thread}
407 variant={item.item.variant}
408 hasQueuedMessages={item.hasQueuedMessages}
409 snoozed={item.item.snoozed}
410 pinned={item.item.pinned}
411 wokeAt={item.item.wokeAt}
412 snoozePresetMinute={item.snoozePresetMinute ?? ""}
413 snoozeWakeLabelText={item.snoozeWakeLabelText}
414 timeLabel={item.timeLabel}
415 project={projectByKey.get(scopeKey) ?? null}
416 providerInstance={resolveProviderInstance(thread)}
417 providers={providersByEnvironmentId.get(thread.environmentId)}
418 environmentLabel={
419 Object.keys(savedConnectionsById).length > 1
420 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
421 : null
422 }
423 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
424 searchMatch={
425 // Only rows listed for their messages show one and open at it.
426 contentOnlyThreadKeys.has(
427 threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }),
428 )
429 ? threadSearchMatchByKey.get(
430 threadSearchMatchKey({
431 environmentId: thread.environmentId,
432 threadId: thread.id,
433 }),
434 )
435 : undefined
436 }
437 searchQuery={threadSearch.query}
438 pane="sidebar"
439 selected={
440 !activeNewTask &&
441 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
442 }
443 fullSwipeWidth={props.width - 20}
444 onSelectThread={handleSelectThread}
445 onOpenThreadPullRequests={handleOpenThreadPullRequests}
446 onDeleteThread={confirmDeleteThread}
447 onArchiveThread={archiveThread}
448 onRenameThread={renameThread}
449 onRegenerateThreadTitle={regenerateThreadTitle}
450 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
451 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
452 onSettleThread={settleThread}
453 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
454 snoozePresets={snoozePresetsByEnvironmentId.get(thread.environmentId)}
455 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
456 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
457 reorderSupported={
458 item.item.pinned
459 ? pinReorderEnvironmentIds.has(thread.environmentId)
460 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
461 }
462 canMoveUp={item.canMoveUp}
463 canMoveDown={item.canMoveDown}
464 onSnoozeThread={snoozeThread}
465 onUnsnoozeThread={unsnoozeThread}
466 onUnsettleThread={unsettleThread}
467 onPinThread={pinThread}
468 onUnpinThread={unpinThread}
469 onSetThreadAutoSettle={setThreadAutoSettle}
470 onMoveThread={moveThread}
471 onSwipeableClose={handleSwipeableClose}
472 onSwipeableWillOpen={handleSwipeableWillOpen}
473 simultaneousSwipeGesture={sidebarScrollGesture}
474 />
475 );
476 }
477 case "v2-working-shelf":
478 return (
479 <ThreadListV2WorkingShelfHeader
480 count={item.count}
481 disabled={item.disabled}
482 expanded={item.expanded}
483 onToggle={toggleWorkingShelf}
484 pane="sidebar"
485 />
486 );
487 case "v2-snoozed-shelf":
488 return (
489 <ThreadListV2SnoozedShelfHeader
490 count={item.count}
491 disabled={item.disabled}
492 expanded={item.expanded}
493 onToggle={toggleSnoozedShelf}
494 pane="sidebar"
495 />
496 );
497 case "v2-settled-shelf":
498 return (
499 <ThreadListV2SettledShelfHeader
500 count={item.count}
501 disabled={item.disabled}
502 expanded={item.expanded}
503 onToggle={toggleSettledShelf}
504 pane="sidebar"
505 />
506 );
507 case "v2-show-more":
508 return (
509 <ThreadListV2ShowMoreRow
510 pane="sidebar"
511 hiddenCount={item.hiddenCount}
512 {...(item.state === undefined ? {} : { state: item.state })}
513 onPress={() => {
514 olderSettledShelf.retry();
515 showMoreSettled();
516 }}
517 />
518 );
519 }
520 };
521 const header = (
522 <WorkspaceListHeader
523 accessibilityLabel="T3 Code, Threads"
524 onOpenEnvironments={props.onOpenEnvironmentSettings}
525 onOpenSettings={props.onOpenSettings}
526 search={{
527 value: props.searchQuery,
528 onChangeText: props.onSearchQueryChange,
529 placeholder: "Search",
530 accessibilityLabel: "Search threads",
531 }}
532 filter={{
533 customized: options.selectedEnvironmentId !== null || selectedProjectKey !== null,
534 items: buildHomeListFilterMenuItems({
535 environments,
536 projects: projectFilterOptions,
537 selectedEnvironmentId: options.selectedEnvironmentId,
538 selectedProjectKey,
539 onEnvironmentChange: setSelectedEnvironmentId,
540 onProjectChange: setSelectedProjectKey,
541 }),
542 }}
543 sidebar={{
544 width: props.width,
545 visible: props.visible,
546 onRequestVisibility: props.onRequestVisibility,
547 }}
548 />
549 );
550 const showAddEnvironment =
551 !catalogState.isLoadingConnections &&
552 !catalogState.hasReadyEnvironment &&
553 !catalogState.hasConnectingEnvironment;
554 const listEmpty = (
555 <View>
556 <Text
557 className={
558 Platform.OS === "android"
559 ? "px-4 py-4 text-center text-sm text-drawer-foreground-muted"
560 : "px-2 py-4 text-sm text-drawer-foreground-muted"
561 }
562 >
563 {catalogState.isLoadingConnections
564 ? "Loading threads…"
565 : showAddEnvironment
566 ? "No environments connected"
567 : props.searchQuery.trim().length > 0
568 ? threadSearch.isPending
569 ? "Searching thread messages…"
570 : "No matching threads"
571 : selectedProjectScope !== null
572 ? `No threads in ${selectedProjectScope.title}`
573 : "No threads yet"}
574 </Text>
575 {showAddEnvironment ? (
576 <View className={Platform.OS === "android" ? "self-center" : "px-2"}>
577 <Button
578 tone="primary"
579 size="compact"
580 icon="plus"
581 label="Add environment"
582 onPress={props.onOpenEnvironmentSettings}
583 />
584 </View>
585 ) : null}
586 </View>
587 );
588
589 if (Platform.OS === "ios") {
590 return (
591 <>
592 {header}
593 <View className="flex-1">
594 <SwipeableScrollGateProvider enabled={swipeEnabled}>
595 <GestureDetector gesture={sidebarScrollGesture}>
596 <AnimatedLegendList
597 renderScrollComponent={NativeScrollView}
598 itemLayoutAnimation={THREAD_SIDEBAR_ROW_LAYOUT}
599 data={listItems}
600 drawDistance={500}
601 estimatedItemSize={64}
602 extraData={listExtraData}
603 getItemType={(item) => item.type}
604 itemsAreEqual={sidebarItemsAreEqual}
605 keyExtractor={(item) => item.key}
606 renderItem={renderListItem}
607 automaticallyAdjustsScrollIndicatorInsets
608 // The column lays the list under its bar, glass or solid; UIKit insets it past both.
609 contentInsetAdjustmentBehavior="automatic"
610 contentContainerStyle={[
611 styles.threadListContent,
612 {
613 paddingBottom: Math.max(insets.bottom, 16) + 16,
614 paddingTop: 6,
615 },
616 ]}
617 keyboardDismissMode="on-drag"
618 keyboardShouldPersistTaps="handled"
619 {...scrollGateHandlers}
620 recycleItems
621 scrollEventThrottle={16}
622 showsVerticalScrollIndicator={false}
623 style={styles.threadList}
624 ListEmptyComponent={listEmpty}
625 />
626 </GestureDetector>
627 </SwipeableScrollGateProvider>
628 <ThreadUndoNotice visible={props.visible} />
629 </View>
630 </>
631 );
632 }
633
634 // Android: the bar floats below the list.
635 return (
636 <View testID="thread-navigation-sidebar" className="flex-1 bg-header">
637 <View
638 className="flex-1"
639 style={{
640 marginHorizontal: 4,
641 paddingBottom: insets.bottom,
642 backgroundColor: drawerColor,
643 borderTopLeftRadius: 28,
644 borderTopRightRadius: 28,
645 overflow: "hidden",
646 }}
647 >
648 {listItems.length === 0 ? (
649 <View className="flex-1 items-center justify-center">{listEmpty}</View>
650 ) : (
651 <SwipeableScrollGateProvider enabled={swipeEnabled}>
652 <GestureDetector gesture={sidebarScrollGesture}>
653 {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- The Android sidebar has no native header to register with. */}
654 <AnimatedLegendList
655 itemLayoutAnimation={THREAD_SIDEBAR_ROW_LAYOUT}
656 data={listItems}
657 drawDistance={500}
658 estimatedItemSize={64}
659 extraData={listExtraData}
660 getItemType={(item) => item.type}
661 itemsAreEqual={sidebarItemsAreEqual}
662 keyExtractor={(item) => item.key}
663 renderItem={renderListItem}
664 contentContainerStyle={{
665 paddingBottom: floatingTabsEnabled
666 ? FLOATING_TABS.bottom + FLOATING_TABS.edge + fabSize
667 : Math.max(insets.bottom, 16) + fabClearance - insets.bottom,
668 paddingTop: 6,
669 }}
670 keyboardDismissMode="on-drag"
671 keyboardShouldPersistTaps="handled"
672 {...scrollGateHandlers}
673 recycleItems
674 scrollEventThrottle={16}
675 showsVerticalScrollIndicator={false}
676 style={styles.threadList}
677 ListEmptyComponent={listEmpty}
678 />
679 </GestureDetector>
680 </SwipeableScrollGateProvider>
681 )}
682 </View>
683 {header}
684 <ThreadUndoNotice visible={props.visible} />
685 </View>
686 );
687}
688
689const styles = StyleSheet.create({
690 threadList: {
691 flex: 1,
692 },
693 threadListContent: {
694 paddingHorizontal: 8,
695 },
696});