1import { appAtomRegistry } from "../../state/atom-registry";
2import { useAtomValue } from "@effect/atom-react";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
5import { sortInboxThreadsByReturn } from "@t3tools/client-runtime/state/thread-inbox";
6import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
7import { Animated, FlatList, Modal, Pressable, View } from "react-native";
8import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
9import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
10import { threadDragGapOffset } from "./threadDragGap";
11import { useSafeAreaInsets } from "react-native-safe-area-context";
12
13import { AppText as Text } from "../../components/AppText";
14import { SymbolView } from "../../components/AppSymbol";
15import { scopedThreadKey } from "../../lib/scopedEntities";
16import { environmentCapabilitiesAtom } from "../../state/server";
17import { environmentThreadShells } from "../../state/threads";
18import {
19 pendingThreadOrderAtom,
20 threadDropBusyAtom,
21 threadArrangementOpenAtom,
22} from "../../state/thread-order";
23import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
24import { useThreadListActions } from "../home/useThreadListActions";
25import {
26 createThreadMovePlanner,
27 threadDragAction,
28 type ThreadMoveDestination,
29} from "./threadOrder";
30import { getThreadListV2OrderedSection, threadListInboxReturns } from "./threadListV2";
31import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
32
33const ROW_HEIGHT = 56;
34const HEADER_HEIGHT = 48;
35const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id);
36type Section = "pinned" | "active" | "snoozed" | "settled";
37type Destination = Exclude<ThreadMoveDestination, string>;
38type Row = {
39 key: string;
40 section: Section;
41 thread?: EnvironmentThreadShell;
42 offset: number;
43 height: number;
44};
45type Drag = {
46 orderVersion: string;
47 sourceSection: Section;
48 thread: EnvironmentThreadShell;
49 startY: number;
50 translation: number;
51 destination: Destination | null;
52};
53
54function ArrangementRow(props: {
55 height: number;
56 offset: number;
57 lifted: boolean;
58 dragging: boolean;
59 children: ReactNode;
60}) {
61 const { dragging, offset, lifted } = props;
62 const style = useAnimatedStyle(() => ({
63 transform: [
64 {
65 translateY: dragging
66 ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System })
67 : offset,
68 },
69 ],
70 opacity: lifted ? 0 : 1,
71 }));
72 return (
73 <Reanimated.View
74 style={[{ height: props.height }, style]}
75 className="flex-row items-center border-b border-border-subtle px-5"
76 >
77 {props.children}
78 </Reanimated.View>
79 );
80}
81
82/** Native pan recognition wins over list scrolling only inside the handle. */
83function DragHandle(props: {
84 title: string;
85 disabled: boolean;
86 onStart: () => void;
87 onMove: (translation: number) => void;
88 onEnd: (cancelled: boolean) => void;
89 onStep: (direction: "up" | "down") => void;
90 sectionActions: readonly { name: "pinned" | "active" | "settled"; label: string }[];
91 onSectionMove: (section: "pinned" | "active" | "settled") => void;
92 canMoveUp: boolean;
93 canMoveDown: boolean;
94}) {
95 const latest = useRef(props);
96 latest.current = props;
97 const gesture = useMemo(
98 () =>
99 Gesture.Pan()
100 .enabled(!props.disabled)
101 .minDistance(0)
102 .shouldCancelWhenOutside(false)
103 .runOnJS(true)
104 .onStart(() => latest.current.onStart())
105 .onUpdate((event) => latest.current.onMove(event.translationY))
106 .onEnd((event) => latest.current.onMove(event.translationY))
107 .onFinalize((_, success) => latest.current.onEnd(!success)),
108 [props.disabled],
109 );
110 return (
111 <GestureDetector gesture={gesture}>
112 <View
113 collapsable={false}
114 accessible
115 accessibilityRole="adjustable"
116 accessibilityLabel={`Reorder ${props.title}`}
117 accessibilityHint="Move up and Move down reorder within this section. Other actions move between sections."
118 accessibilityState={{ disabled: props.disabled }}
119 accessibilityActions={[
120 ...props.sectionActions,
121 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
122 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
123 ]}
124 onAccessibilityAction={({ nativeEvent }) => {
125 if (props.disabled) return;
126 const sectionAction = props.sectionActions.find(
127 (action) => action.name === nativeEvent.actionName,
128 );
129 if (sectionAction) props.onSectionMove(sectionAction.name);
130 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
131 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
132 }}
133 style={{
134 width: 48,
135 height: 48,
136 alignItems: "center",
137 justifyContent: "center",
138 opacity: props.disabled ? 0.3 : 1,
139 }}
140 >
141 <SymbolView
142 name="line.3.horizontal"
143 size={22}
144 tintColorClassName="accent-foreground-muted"
145 />
146 </View>
147 </GestureDetector>
148 );
149}
150
151export function ThreadArrangementSheet(props: { onClose: () => void }) {
152 const insets = useSafeAreaInsets();
153 const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom);
154 const capabilitiesByEnvironment = useAtomValue(environmentCapabilitiesAtom);
155 const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom);
156 const pendingOrder = useAtomValue(pendingThreadOrderAtom);
157 const dropBusy = useAtomValue(threadDropBusyAtom);
158 const { moveThread } = useThreadListActions();
159 const { workingShelfEnabled } = useThreadListV2ShelfPreferences();
160 const [now, setNow] = useState(() => new Date().toISOString());
161 const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
162 useEffect(() => {
163 const wakeAt = Math.min(
164 ...threads.flatMap((thread) => {
165 const at = Date.parse(thread.snoozedUntil ?? "");
166 return at > Date.parse(now) ? [at] : [];
167 }),
168 );
169 if (!Number.isFinite(wakeAt)) return;
170 const timer = setTimeout(
171 () => setNow(new Date().toISOString()),
172 Math.min(Math.max(0, wakeAt - Date.now()) + 1, 2_147_483_647),
173 );
174 return () => clearTimeout(timer);
175 }, [threads, now]);
176 const sections = useMemo(() => {
177 const shared = {
178 threads,
179 now,
180 queuedThreadKeys,
181 pendingOrder,
182 settlementEnvironmentIds: new Set(
183 [...capabilitiesByEnvironment].flatMap(([id, supported]) =>
184 supported.threadSettlement ? [id] : [],
185 ),
186 ),
187 snoozeEnvironmentIds: new Set(
188 [...capabilitiesByEnvironment].flatMap(([id, supported]) =>
189 supported.threadSnooze ? [id] : [],
190 ),
191 ),
192 };
193 const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" });
194 const active = getThreadListV2OrderedSection({ ...shared, section: "active" });
195 const visible = new Set([...pinned, ...active].map(keyOf));
196 const parked = threads.filter(
197 (thread) => thread.archivedAt === null && !visible.has(keyOf(thread)),
198 );
199 return {
200 pinned,
201 // The Working beta orders the inbox by time; show that order here too.
202 active: workingShelfEnabled
203 ? sortInboxThreadsByReturn(active, threadListInboxReturns.returnedAt)
204 : active,
205 snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
206 settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),
207 };
208 }, [
209 threads,
210 capabilitiesByEnvironment,
211 now,
212 queuedThreadKeys,
213 pendingOrder,
214 workingShelfEnabled,
215 ]);
216 const planners = useMemo(() => {
217 const planner = (section: "pinned" | "active") =>
218 createThreadMovePlanner({
219 ordered: sections[section],
220 allThreads: threads,
221 section,
222 // A time-ordered inbox has no slots, so Active takes no drops while
223 // the Working beta is on. The saved arrangement stays untouched.
224 reorderableEnvironmentIds: new Set(
225 [...capabilitiesByEnvironment].flatMap(([id, supported]) =>
226 (
227 section === "pinned"
228 ? supported.threadPinReorder
229 : !workingShelfEnabled && supported.threadActiveReorder
230 )
231 ? [id]
232 : [],
233 ),
234 ),
235 });
236 return { pinned: planner("pinned"), active: planner("active") };
237 }, [sections, threads, capabilitiesByEnvironment, workingShelfEnabled]);
238 const rows = useMemo(() => {
239 const result: Row[] = [];
240 let offset = 0;
241 for (const section of ["pinned", "active", "snoozed", "settled"] as const) {
242 if (section === "snoozed" && sections[section].length === 0) continue;
243 result.push({ key: section, section, offset, height: HEADER_HEIGHT });
244 offset += HEADER_HEIGHT;
245 if ((section === "snoozed" || section === "settled") && !expanded[section]) continue;
246 for (const thread of sections[section]) {
247 result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT });
248 offset += ROW_HEIGHT;
249 }
250 }
251 return result;
252 }, [sections, expanded]);
253 const list = useRef<FlatList<Row>>(null);
254 const geometry = useRef({ height: 0, offset: 0 });
255 const drag = useRef<Drag | null>(null);
256 const frame = useRef<number | null>(null);
257 const [preview, setPreview] = useState<Drag | null>(null);
258 const translateY = useRef(new Animated.Value(0)).current;
259 const latest = useRef({ rows, planners, moveThread });
260 latest.current = { rows, planners, moveThread };
261
262 function stop() {
263 if (frame.current !== null) cancelAnimationFrame(frame.current);
264 frame.current = null;
265 drag.current = null;
266 setPreview(null);
267 }
268 const orderVersion = rows
269 .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`)
270 .join("|");
271 useEffect(() => {
272 stop();
273 }, [orderVersion]);
274 useEffect(
275 () => () => {
276 if (frame.current !== null) cancelAnimationFrame(frame.current);
277 },
278 [],
279 );
280
281 function update(translation: number) {
282 const current = drag.current;
283 if (current === null) return;
284 current.translation = translation;
285 const { height, offset } = geometry.current;
286 const y = current.startY + translation;
287 translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2)));
288 const contentY = Math.max(0, y + offset);
289 const target =
290 latest.current.rows.find((row) => contentY < row.offset + row.height) ??
291 latest.current.rows.at(-1);
292 let destination: Destination | null = null;
293 if (
294 target &&
295 y >= 0 &&
296 y <= height &&
297 (target.section === "pinned" || target.section === "active" || target.section === "settled")
298 ) {
299 const candidate: Destination = {
300 section: target.section,
301 targetId: target.thread ? target.key : null,
302 placement:
303 !target.thread || contentY < target.offset + target.height / 2 ? "before" : "after",
304 };
305 if (target.section === "settled") {
306 if (
307 current.sourceSection !== "settled" &&
308 capabilitiesByEnvironment.get(current.thread.environmentId)?.threadSettlement
309 )
310 destination = { section: "settled", targetId: null, placement: "before" };
311 } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null)
312 destination = candidate;
313 }
314 if (
315 current.destination?.targetId !== destination?.targetId ||
316 current.destination?.section !== destination?.section ||
317 current.destination?.placement !== destination?.placement
318 ) {
319 current.destination = destination;
320 setPreview({ ...current });
321 }
322 }
323 function start(row: Row) {
324 if (!row.thread || preview !== null) return;
325 drag.current = {
326 orderVersion,
327 sourceSection: row.section,
328 thread: row.thread,
329 startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset,
330 translation: 0,
331 destination: null,
332 };
333 setPreview({ ...drag.current });
334 update(0);
335 let last = performance.now();
336 const tick = () => {
337 const current = drag.current;
338 if (!current) return;
339 const timestamp = performance.now();
340 const dt = Math.min(timestamp - last, 32);
341 last = timestamp;
342 const bounds = geometry.current;
343 const y = current.startY + current.translation;
344 const speed =
345 y < 48
346 ? -Math.min(1, (48 - y) / 48)
347 : y > bounds.height - 48
348 ? Math.min(1, (y - bounds.height + 48) / 48)
349 : 0;
350 const tail = latest.current.rows.at(-1);
351 const maximum = Math.max(0, (tail ? tail.offset + tail.height : 0) - bounds.height);
352 const offset = Math.max(0, Math.min(maximum, bounds.offset + speed * dt * 0.5));
353 if (offset !== bounds.offset) {
354 bounds.offset = offset;
355 list.current?.scrollToOffset({ offset, animated: false });
356 update(current.translation);
357 }
358 frame.current = requestAnimationFrame(tick);
359 };
360 frame.current = requestAnimationFrame(tick);
361 }
362 const visiblePreview = preview?.orderVersion === orderVersion ? preview : null;
363 const sourceRow = visiblePreview
364 ? rows.find((row) => row.key === keyOf(visiblePreview.thread))
365 : undefined;
366 const destination = visiblePreview?.destination;
367 const targetRow = destination
368 ? rows.find(
369 (row) =>
370 row.section === destination.section &&
371 row.key === (destination.targetId ?? destination.section),
372 )
373 : undefined;
374 const insertionOffset = targetRow
375 ? targetRow.offset +
376 (!targetRow.thread || destination?.placement === "after" ? targetRow.height : 0)
377 : sourceRow?.offset;
378 return (
379 <Modal
380 visible
381 animationType="slide"
382 presentationStyle="fullScreen"
383 onRequestClose={props.onClose}
384 >
385 <GestureHandlerRootView style={{ flex: 1 }}>
386 <View
387 className="flex-1 bg-screen"
388 style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
389 >
390 <View className="flex-row items-center justify-between gap-3 px-5 py-3">
391 <Text className="flex-1 text-xl font-t3-semibold">Arrange threads</Text>
392 <Pressable
393 accessibilityRole="button"
394 onPress={props.onClose}
395 className="min-h-11 justify-center px-3"
396 >
397 <Text className="text-base text-primary-text">Done</Text>
398 </Pressable>
399 </View>
400 <Text className="px-5 pb-3 text-sm text-foreground-muted">
401 Drag to reorder, pin, or settle. Changes save when you drop.
402 </Text>
403 <View
404 onLayout={(event) => {
405 geometry.current.height = event.nativeEvent.layout.height;
406 }}
407 className="flex-1"
408 style={{ overflow: "hidden" }}
409 >
410 <FlatList
411 ref={list}
412 data={rows}
413 keyExtractor={(row) => row.key}
414 scrollEnabled={visiblePreview === null}
415 removeClippedSubviews={false}
416 onScroll={(event) => {
417 geometry.current.offset = event.nativeEvent.contentOffset.y;
418 }}
419 scrollEventThrottle={16}
420 getItemLayout={(_, index) => ({
421 length: rows[index]!.height,
422 offset: rows[index]!.offset,
423 index,
424 })}
425 renderItem={({ item }) => {
426 const thread = item.thread;
427 const planner =
428 item.section === "pinned" || item.section === "active"
429 ? planners[item.section]
430 : null;
431 const capabilities = thread && capabilitiesByEnvironment.get(thread.environmentId);
432 const sectionActions = thread
433 ? (["pinned", "active", "settled"] as const).flatMap<{
434 name: "pinned" | "active" | "settled";
435 label: string;
436 }>((section) => {
437 if (section === item.section) return [];
438 const label = threadDragAction(item.section, section);
439 if (!label) return [];
440 if (section === "settled")
441 return capabilities?.threadSettlement ? [{ name: section, label }] : [];
442 if (
443 ((section === "pinned" || thread.pinnedAt != null) &&
444 !capabilities?.threadPinning) ||
445 (section === "active" &&
446 item.section === "settled" &&
447 !capabilities?.threadSettlement) ||
448 (section === "active" &&
449 item.section === "snoozed" &&
450 !capabilities?.threadSnooze)
451 )
452 return [];
453 return planners[section](item.key, {
454 section,
455 targetId: null,
456 placement: "before",
457 })
458 ? [{ name: section, label }]
459 : [];
460 })
461 : [];
462 return (
463 <ArrangementRow
464 height={item.height}
465 dragging={visiblePreview !== null}
466 lifted={item.key === sourceRow?.key}
467 offset={
468 sourceRow && insertionOffset !== undefined
469 ? threadDragGapOffset(
470 item.offset,
471 sourceRow.offset,
472 sourceRow.height,
473 insertionOffset,
474 )
475 : 0
476 }
477 >
478 {thread ? (
479 <>
480 <Text numberOfLines={2} className="flex-1 text-base">
481 {thread.title}
482 </Text>
483 <DragHandle
484 title={thread.title}
485 disabled={
486 dropBusy ||
487 pendingOrder !== null ||
488 !(
489 capabilitiesByEnvironment.get(thread.environmentId)
490 ?.threadPinReorder ||
491 capabilitiesByEnvironment.get(thread.environmentId)
492 ?.threadActiveReorder
493 )
494 }
495 sectionActions={sectionActions}
496 onSectionMove={(section) => {
497 void moveThread(thread, {
498 section,
499 targetId: null,
500 placement: "before",
501 });
502 }}
503 canMoveUp={planner?.(item.key, "up") != null}
504 canMoveDown={planner?.(item.key, "down") != null}
505 onStep={(direction) => {
506 void moveThread(thread, direction);
507 }}
508 onStart={() => start(item)}
509 onMove={update}
510 onEnd={(cancelled) => {
511 const current = drag.current;
512 if (cancelled || !current?.destination) {
513 stop();
514 return;
515 }
516 if (frame.current !== null) cancelAnimationFrame(frame.current);
517 frame.current = null;
518 drag.current = null;
519 // Retain the gap until the saved order arrives, avoiding a flash back.
520 void moveThread(current.thread, current.destination).finally(stop);
521 }}
522 />
523 </>
524 ) : (
525 <Pressable
526 disabled={item.section === "pinned" || item.section === "active"}
527 className="flex-1 justify-center self-stretch"
528 onPress={() => {
529 const section = item.section;
530 if (section === "snoozed" || section === "settled")
531 setExpanded((value) => ({ ...value, [section]: !value[section] }));
532 }}
533 >
534 <Text className="text-sm font-t3-semibold text-foreground-muted">
535 {item.section[0]!.toUpperCase() + item.section.slice(1)} (
536 {sections[item.section].length})
537 </Text>
538 </Pressable>
539 )}
540 </ArrangementRow>
541 );
542 }}
543 />
544 {visiblePreview ? (
545 <Animated.View
546 pointerEvents="none"
547 className="absolute left-5 right-5 justify-center rounded-xl border border-border bg-screen px-4"
548 style={{ top: 0, height: ROW_HEIGHT, transform: [{ translateY }] }}
549 >
550 <Text
551 numberOfLines={visiblePreview.destination?.section ? 1 : 2}
552 className="text-base font-t3-medium"
553 >
554 {visiblePreview.thread.title}
555 </Text>
556 {visiblePreview.destination?.section ? (
557 <Text className="text-xs text-foreground-muted">
558 {threadDragAction(
559 visiblePreview.sourceSection,
560 visiblePreview.destination.section,
561 )}
562 </Text>
563 ) : null}
564 </Animated.View>
565 ) : null}
566 </View>
567 </View>
568 </GestureHandlerRootView>
569 </Modal>
570 );
571}
572
573export function ThreadArrangementHost() {
574 const open = useAtomValue(threadArrangementOpenAtom);
575 return open ? (
576 <ThreadArrangementSheet onClose={() => appAtomRegistry.set(threadArrangementOpenAtom, false)} />
577 ) : null;
578}