1import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
2import { SheetHeader } from "../../components/ui/SheetHeader";
3import { appAtomRegistry } from "../../state/atom-registry";
4import { useAtomValue } from "@effect/atom-react";
5import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
6import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
7import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox";
8import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
9import { Animated, Modal, Pressable, useWindowDimensions, View } from "react-native";
10import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
11import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
12import { threadDragGapOffset } from "./threadDragGap";
13import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
14
15import { AppText as Text } from "../../components/ui/AppText";
16import { SymbolView } from "../../components/ui/AppSymbol";
17import { scopedThreadKey } from "../../lib/scopedEntities";
18import { deriveThreadArrangementSizing } from "../../lib/layout";
19import { environmentServerConfigsAtom } from "../../state/server";
20import { threadListEnvironmentsAtom } from "../../state/presentation";
21import { steadyNavigationThreadShellsAtom, steadyShellsHoldingAtom } from "../../state/threads";
22import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
23import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf";
24import {
25 pendingThreadOrderAtom,
26 threadDropBusyAtom,
27 threadArrangementOpenAtom,
28} from "../../state/thread-order";
29import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
30import { useThreadListActions } from "../home/useThreadListActions";
31import {
32 createThreadMovePlanner,
33 threadDragAction,
34 type ThreadMoveDestination,
35} from "./threadOrder";
36import {
37 getThreadListV2OrderedSection,
38 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
39 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
40 threadListInboxReturns,
41} from "./threadListV2";
42import { useThreadListNow } from "./use-thread-list-now";
43import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
44import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
45
46const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id);
47type Section = "pinned" | "active" | "snoozed" | "settled";
48type Destination = Exclude<ThreadMoveDestination, string>;
49type Row = {
50 key: string;
51 section: Section;
52 thread?: EnvironmentThreadShell;
53 /** The Settled section's "Show more" row. */
54 more?: true;
55 offset: number;
56 height: number;
57};
58type Drag = {
59 orderVersion: string;
60 sourceSection: Section;
61 thread: EnvironmentThreadShell;
62 startY: number;
63 translation: number;
64 destination: Destination | null;
65};
66
67function ArrangementRow(props: {
68 height: number;
69 offset: number;
70 lifted: boolean;
71 dragging: boolean;
72 children: ReactNode;
73}) {
74 const { dragging, offset, lifted } = props;
75 const style = useAnimatedStyle(() => ({
76 transform: [
77 {
78 translateY: dragging
79 ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System })
80 : offset,
81 },
82 ],
83 opacity: lifted ? 0 : 1,
84 }));
85 return (
86 <Reanimated.View
87 style={[{ height: props.height }, style]}
88 className="flex-row items-center border-b border-border-subtle px-5"
89 >
90 {props.children}
91 </Reanimated.View>
92 );
93}
94
95function DragHandle(props: {
96 title: string;
97 disabled: boolean;
98 onStart: () => void;
99 onMove: (translation: number) => void;
100 onEnd: (cancelled: boolean) => void;
101 onStep: (direction: "up" | "down") => void;
102 sectionActions: readonly { name: "pinned" | "active" | "settled"; label: string }[];
103 onSectionMove: (section: "pinned" | "active" | "settled") => void;
104 canMoveUp: boolean;
105 canMoveDown: boolean;
106}) {
107 const latest = useRef(props);
108 /* oxlint-disable react/refs -- The pan gesture captures its callbacks once per `disabled` change; they read the latest handlers when native gesture events fire. */
109 latest.current = props;
110 const gesture = useMemo(
111 () =>
112 // oxlint-disable-next-line react/capitalized-calls -- Gesture.Pan is a gesture factory, not a React component.
113 Gesture.Pan()
114 .enabled(!props.disabled)
115 .minDistance(0)
116 .shouldCancelWhenOutside(false)
117 .runOnJS(true)
118 .onStart(() => latest.current.onStart())
119 .onUpdate((event) => latest.current.onMove(event.translationY))
120 .onEnd((event) => latest.current.onMove(event.translationY))
121 .onFinalize((_, success) => latest.current.onEnd(!success)),
122 [props.disabled, latest],
123 );
124 /* oxlint-enable react/refs */
125 return (
126 <GestureDetector gesture={gesture}>
127 <View
128 collapsable={false}
129 accessible
130 accessibilityRole="adjustable"
131 accessibilityLabel={`Reorder ${props.title}`}
132 accessibilityHint="Move up and Move down reorder within this section. Other actions move between sections."
133 accessibilityState={{ disabled: props.disabled }}
134 accessibilityActions={[
135 ...props.sectionActions,
136 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
137 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
138 ]}
139 onAccessibilityAction={({ nativeEvent }) => {
140 if (props.disabled) return;
141 const sectionAction = props.sectionActions.find(
142 (action) => action.name === nativeEvent.actionName,
143 );
144 if (sectionAction) props.onSectionMove(sectionAction.name);
145 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
146 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
147 }}
148 style={{
149 width: 48,
150 height: 48,
151 alignItems: "center",
152 justifyContent: "center",
153 opacity: props.disabled ? 0.3 : 1,
154 }}
155 >
156 <SymbolView
157 name="line.3.horizontal"
158 size={22}
159 tintColorClassName="accent-foreground-muted"
160 />
161 </View>
162 </GestureDetector>
163 );
164}
165
166function ThreadArrangementSheet(props: { onClose: () => void }) {
167 const { appearance } = useAppearancePreferences();
168 const { fontScale } = useWindowDimensions();
169 const { rowHeight, headerHeight } = deriveThreadArrangementSizing({
170 baseFontSize: appearance.baseFontSize,
171 fontScale,
172 });
173 const threads = useAtomValue(steadyNavigationThreadShellsAtom);
174 const configs = useAtomValue(environmentServerConfigsAtom);
175 const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
176 const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom);
177 const pendingOrder = useAtomValue(pendingThreadOrderAtom);
178 const dropBusy = useAtomValue(threadDropBusyAtom);
179 const holdingRows = useAtomValue(steadyShellsHoldingAtom);
180 const { moveThread } = useThreadListActions();
181 const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
182 const now = useThreadListNow(threads);
183 const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
184 const [settledVisibleCount, setSettledVisibleCount] = useState(
185 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
186 );
187 // Older settled threads load in pages, as in the thread list.
188 const olderSettledShelf = useOlderSettledShelf({
189 scopes: [...configs.keys()].map((environmentId) => ({ environmentId, projectIds: null })),
190 expanded: expanded.settled,
191 visibleCount: settledVisibleCount,
192 });
193 const sections = useMemo(() => {
194 const shared = {
195 threads,
196 now,
197 queuedThreadKeys,
198 pendingOrder,
199 settlementEnvironmentIds: new Set(configs.keys()),
200 snoozeEnvironmentIds: new Set(configs.keys()),
201 };
202 const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" });
203 const active = getThreadListV2OrderedSection({ ...shared, section: "active" });
204 const visible = new Set([...pinned, ...active].map(keyOf));
205 const parked = threads.filter(
206 (thread) => thread.archivedAt === null && !visible.has(keyOf(thread)),
207 );
208 return {
209 pinned,
210 active: workingShelfEnabled
211 ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt)
212 : active,
213 snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
214 settled: sortSettledThreads(parked.filter((thread) => !effectiveSnoozed(thread, { now }))),
215 };
216 }, [threads, configs, now, queuedThreadKeys, pendingOrder, workingShelfEnabled]);
217 const listedSettled = sections.settled.filter(olderSettledShelf.isListed);
218 useOlderSettledShelfGrowth(olderSettledShelf, listedSettled);
219 const visibleSettled = listedSettled.slice(0, settledVisibleCount);
220 const settledCount = sections.settled.length + olderSettledShelf.unloadedCount(threads);
221 const planners = useMemo(() => {
222 const planner = (section: "pinned" | "active") =>
223 createThreadMovePlanner({
224 ordered: sections[section],
225 allThreads: threads,
226 section,
227 reorderableEnvironmentIds:
228 section === "pinned"
229 ? listEnvironments.pinReorderEnvironmentIds
230 : workingShelfEnabled
231 ? new Set()
232 : listEnvironments.activeReorderEnvironmentIds,
233 });
234 return { pinned: planner("pinned"), active: planner("active") };
235 }, [sections, threads, listEnvironments, workingShelfEnabled]);
236 const rows = useMemo(() => {
237 const result: Row[] = [];
238 let offset = 0;
239 for (const section of ["pinned", "active", "snoozed", "settled"] as const) {
240 if (section === "snoozed" && sections[section].length === 0) continue;
241 result.push({ key: section, section, offset, height: headerHeight });
242 offset += headerHeight;
243 if ((section === "snoozed" || section === "settled") && !expanded[section]) continue;
244 for (const thread of section === "settled" ? visibleSettled : sections[section]) {
245 result.push({ key: keyOf(thread), section, thread, offset, height: rowHeight });
246 offset += rowHeight;
247 }
248 if (section === "settled" && settledCount > visibleSettled.length) {
249 result.push({ key: "settled-more", section, more: true, offset, height: rowHeight });
250 offset += rowHeight;
251 }
252 }
253 return result;
254 }, [sections, expanded, visibleSettled, settledCount, rowHeight, headerHeight]);
255 const list = useRef<LegendListRef>(null);
256 const geometry = useRef({ height: 0, offset: 0 });
257 const drag = useRef<Drag | null>(null);
258 const frame = useRef<number | null>(null);
259 const [preview, setPreview] = useState<Drag | null>(null);
260 const [translateY] = useState(() => new Animated.Value(0));
261 const latest = useRef({ rows, planners, moveThread });
262 // oxlint-disable-next-line react/refs -- The autoscroll rAF loop started at drag start reads the current rows and planners on every frame.
263 latest.current = { rows, planners, moveThread };
264
265 function stop() {
266 if (frame.current !== null) cancelAnimationFrame(frame.current);
267 frame.current = null;
268 drag.current = null;
269 setPreview(null);
270 }
271 const orderVersion = rows
272 .map(
273 (row) => `${row.key}:${row.height}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`,
274 )
275 .join("|");
276 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the in-progress drag gesture when the thread order changes underneath it
277 useEffect(() => {
278 // oxlint-disable-next-line react/exhaustive-effect-dependencies, react/set-state-in-effect -- A changed thread order cancels the in-progress drag and clears its preview; stop only touches refs and state setters.
279 stop();
280 }, [orderVersion]);
281 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the pending drag animation frame on unmount
282 useEffect(
283 () => () => {
284 if (frame.current !== null) cancelAnimationFrame(frame.current);
285 },
286 [],
287 );
288
289 function update(translation: number) {
290 const current = drag.current;
291 if (current === null) return;
292 current.translation = translation;
293 const { height, offset } = geometry.current;
294 const y = current.startY + translation;
295 translateY.setValue(Math.max(0, Math.min(height - rowHeight, y - rowHeight / 2)));
296 const contentY = Math.max(0, y + offset);
297 const target =
298 latest.current.rows.find((row) => contentY < row.offset + row.height) ??
299 latest.current.rows.at(-1);
300 let destination: Destination | null = null;
301 if (
302 target &&
303 y >= 0 &&
304 y <= height &&
305 (target.section === "pinned" || target.section === "active" || target.section === "settled")
306 ) {
307 const candidate: Destination = {
308 section: target.section,
309 targetId: target.thread ? target.key : null,
310 placement:
311 !target.thread || contentY < target.offset + target.height / 2 ? "before" : "after",
312 };
313 if (target.section === "settled") {
314 if (current.sourceSection !== "settled" && configs.has(current.thread.environmentId))
315 destination = { section: "settled", targetId: null, placement: "before" };
316 } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null)
317 destination = candidate;
318 }
319 if (
320 current.destination?.targetId !== destination?.targetId ||
321 current.destination?.section !== destination?.section ||
322 current.destination?.placement !== destination?.placement
323 ) {
324 current.destination = destination;
325 setPreview({ ...current });
326 }
327 }
328 function start(row: Row) {
329 if (!row.thread || preview !== null) return;
330 drag.current = {
331 orderVersion,
332 sourceSection: row.section,
333 thread: row.thread,
334 startY: row.offset + row.height / 2 - geometry.current.offset,
335 translation: 0,
336 destination: null,
337 };
338 setPreview({ ...drag.current });
339 update(0);
340 // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
341 let last = performance.now();
342 const tick = () => {
343 const current = drag.current;
344 if (!current) return;
345 const timestamp = performance.now();
346 const dt = Math.min(timestamp - last, 32);
347 last = timestamp;
348 const bounds = geometry.current;
349 const y = current.startY + current.translation;
350 const speed =
351 y < 48
352 ? -Math.min(1, (48 - y) / 48)
353 : y > bounds.height - 48
354 ? Math.min(1, (y - bounds.height + 48) / 48)
355 : 0;
356 const tail = latest.current.rows.at(-1);
357 const maximum = Math.max(0, (tail ? tail.offset + tail.height : 0) - bounds.height);
358 const offset = Math.max(0, Math.min(maximum, bounds.offset + speed * dt * 0.5));
359 if (offset !== bounds.offset) {
360 bounds.offset = offset;
361 list.current?.scrollToOffset({ offset, animated: false });
362 update(current.translation);
363 }
364 frame.current = requestAnimationFrame(tick);
365 };
366 frame.current = requestAnimationFrame(tick);
367 }
368 const visiblePreview = preview?.orderVersion === orderVersion ? preview : null;
369 const sourceRow = visiblePreview
370 ? rows.find((row) => row.key === keyOf(visiblePreview.thread))
371 : undefined;
372 const destination = visiblePreview?.destination;
373 const targetRow = destination
374 ? rows.find(
375 (row) =>
376 row.section === destination.section &&
377 row.key === (destination.targetId ?? destination.section),
378 )
379 : undefined;
380 const insertionOffset = targetRow
381 ? targetRow.offset +
382 (!targetRow.thread || destination?.placement === "after" ? targetRow.height : 0)
383 : sourceRow?.offset;
384 return (
385 <Modal
386 visible
387 animationType="slide"
388 presentationStyle="fullScreen"
389 onRequestClose={props.onClose}
390 >
391 <GestureHandlerRootView style={{ flex: 1 }}>
392 {/* A Modal is its own native hierarchy, so measure its own safe area, including side
393 insets for the camera, instead of the presenting screen's. */}
394 <SafeAreaProvider style={{ flex: 1 }}>
395 <View className="flex-1 bg-screen">
396 <SafeAreaView style={{ flex: 1 }}>
397 <SheetHeader
398 presentation="inline"
399 title="Arrange threads"
400 onClose={props.onClose}
401 accessibilityLabel="Close arrange threads"
402 />
403 <Text className="px-5 pb-3 text-sm text-foreground-muted">
404 Drag to reorder, pin, or settle. Changes save when you drop.
405 </Text>
406 <View
407 onLayout={(event) => {
408 geometry.current.height = event.nativeEvent.layout.height;
409 }}
410 className="flex-1"
411 style={{ overflow: "hidden" }}
412 >
413 {/* oxlint-disable-next-line t3code/require-registered-list-scroll -- A full-screen Modal with an inline header; there is no navigation header to register with. */}
414 <LegendList
415 ref={list}
416 data={rows}
417 keyExtractor={(row) => row.key}
418 extraData={{
419 visiblePreview,
420 insertionOffset,
421 dropBusy,
422 pendingOrder,
423 listEnvironments,
424 planners,
425 configs,
426 }}
427 // Rows hold their drag gesture, so each keeps its own view.
428 recycleItems={false}
429 scrollEnabled={visiblePreview === null}
430 onScroll={(event) => {
431 geometry.current.offset = event.nativeEvent.contentOffset.y;
432 }}
433 scrollEventThrottle={16}
434 getFixedItemSize={(row) => row.height}
435 renderItem={({ item }) => {
436 const thread = item.thread;
437 const planner =
438 item.section === "pinned" || item.section === "active"
439 ? planners[item.section]
440 : null;
441 const configLoaded = thread ? configs.has(thread.environmentId) : false;
442 const sectionActions = thread
443 ? (["pinned", "active", "settled"] as const).flatMap<{
444 name: "pinned" | "active" | "settled";
445 label: string;
446 }>((section) => {
447 if (section === item.section) return [];
448 const label = threadDragAction(item.section, section);
449 if (!label) return [];
450 if (section === "settled")
451 return configLoaded ? [{ name: section, label }] : [];
452 if (
453 ((section === "pinned" || thread.pinnedAt != null) && !configLoaded) ||
454 (section === "active" && item.section === "settled" && !configLoaded) ||
455 (section === "active" && item.section === "snoozed" && !configLoaded)
456 )
457 return [];
458 return planners[section](item.key, {
459 section,
460 targetId: null,
461 placement: "before",
462 })
463 ? [{ name: section, label }]
464 : [];
465 })
466 : [];
467 return (
468 <ArrangementRow
469 height={item.height}
470 dragging={visiblePreview !== null}
471 lifted={item.key === sourceRow?.key}
472 offset={
473 sourceRow && insertionOffset !== undefined
474 ? threadDragGapOffset(
475 item.offset,
476 sourceRow.offset,
477 sourceRow.height,
478 insertionOffset,
479 )
480 : 0
481 }
482 >
483 {thread ? (
484 <>
485 <Text numberOfLines={2} className="flex-1 text-base">
486 {thread.title}
487 </Text>
488 <DragHandle
489 title={thread.title}
490 disabled={
491 dropBusy ||
492 pendingOrder !== null ||
493 holdingRows ||
494 (!listEnvironments.pinReorderEnvironmentIds.has(
495 thread.environmentId,
496 ) &&
497 !listEnvironments.activeReorderEnvironmentIds.has(
498 thread.environmentId,
499 ))
500 }
501 sectionActions={sectionActions}
502 onSectionMove={(section) => {
503 void moveThread(thread, {
504 section,
505 targetId: null,
506 placement: "before",
507 });
508 }}
509 canMoveUp={planner?.(item.key, "up") != null}
510 canMoveDown={planner?.(item.key, "down") != null}
511 onStep={(direction) => {
512 void moveThread(thread, direction);
513 }}
514 onStart={() => start(item)}
515 onMove={update}
516 onEnd={(cancelled) => {
517 const current = drag.current;
518 if (cancelled || !current?.destination) {
519 stop();
520 return;
521 }
522 if (frame.current !== null) cancelAnimationFrame(frame.current);
523 frame.current = null;
524 drag.current = null;
525 void moveThread(current.thread, current.destination).finally(stop);
526 }}
527 />
528 </>
529 ) : item.more ? (
530 <Pressable
531 disabled={olderSettledShelf.loading}
532 className="flex-1 justify-center self-stretch"
533 onPress={() => {
534 olderSettledShelf.retry();
535 setSettledVisibleCount(
536 (count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT,
537 );
538 }}
539 >
540 <Text className="text-sm text-foreground-muted">
541 {olderSettledShelf.loading
542 ? "Loading"
543 : olderSettledShelf.failed
544 ? "Could not load older threads. Retry"
545 : `Show more (${settledCount - visibleSettled.length} settled hidden)`}
546 </Text>
547 </Pressable>
548 ) : (
549 <Pressable
550 disabled={item.section === "pinned" || item.section === "active"}
551 className="flex-1 justify-center self-stretch"
552 onPress={() => {
553 const section = item.section;
554 if (section === "snoozed" || section === "settled")
555 setExpanded((value) => ({ ...value, [section]: !value[section] }));
556 }}
557 >
558 <Text className="text-sm font-t3-semibold text-foreground-muted">
559 {item.section[0]!.toUpperCase() + item.section.slice(1)} (
560 {item.section === "settled"
561 ? settledCount
562 : sections[item.section].length}
563 )
564 </Text>
565 </Pressable>
566 )}
567 </ArrangementRow>
568 );
569 }}
570 />
571 {visiblePreview ? (
572 <Animated.View
573 pointerEvents="none"
574 className="absolute left-5 right-5 justify-center rounded-xl border border-border bg-screen px-4"
575 style={{ top: 0, height: rowHeight, transform: [{ translateY }] }}
576 >
577 <Text
578 numberOfLines={visiblePreview.destination?.section ? 1 : 2}
579 className="text-base font-t3-medium"
580 >
581 {visiblePreview.thread.title}
582 </Text>
583 {visiblePreview.destination?.section ? (
584 <Text className="text-xs text-foreground-muted">
585 {threadDragAction(
586 visiblePreview.sourceSection,
587 visiblePreview.destination.section,
588 )}
589 </Text>
590 ) : null}
591 </Animated.View>
592 ) : null}
593 </View>
594 </SafeAreaView>
595 </View>
596 </SafeAreaProvider>
597 </GestureHandlerRootView>
598 </Modal>
599 );
600}
601
602export function ThreadArrangementHost() {
603 const open = useAtomValue(threadArrangementOpenAtom);
604 return open ? (
605 <ThreadArrangementSheet onClose={() => appAtomRegistry.set(threadArrangementOpenAtom, false)} />
606 ) : null;
607}