1import { SymbolView } from "../../components/AppSymbol";
2import { ControlPillMenu } from "../../components/ControlPill";
3import type { MenuAction } from "@react-native-menu/menu";
4import * as Haptics from "expo-haptics";
5import {
6 createContext,
7 use,
8 useCallback,
9 useEffect,
10 useLayoutEffect,
11 useRef,
12 useState,
13 type ComponentProps,
14 type ReactNode,
15} from "react";
16import type {
17 ColorValue,
18 NativeScrollEvent,
19 NativeSyntheticEvent,
20 StyleProp,
21 ViewStyle,
22} from "react-native";
23import { Pressable, View } from "react-native";
24import ReanimatedSwipeable, {
25 type SwipeableMethods,
26} from "react-native-gesture-handler/ReanimatedSwipeable";
27import Animated, {
28 cancelAnimation,
29 Easing,
30 Extrapolation,
31 ReduceMotion,
32 interpolate,
33 runOnJS,
34 runOnUI,
35 type SharedValue,
36 useAnimatedReaction,
37 useAnimatedStyle,
38 useSharedValue,
39 withTiming,
40} from "react-native-reanimated";
41
42import { AppText as Text } from "../../components/AppText";
43import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation";
44import { registerThreadDismissal } from "./thread-dismissal";
45
46// Wide enough for the longest action label ("Unarchive").
47const ACTION_ITEM_WIDTH = 58;
48const ACTION_CIRCLE_SIZE = 36;
49const ACTION_ICON_SIZE = 15;
50const COMPACT_ACTION_CIRCLE_SIZE = 28;
51const COMPACT_ACTION_ICON_SIZE = 13;
52
53export const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;
54export const THREAD_SWIPE_SPRING = {
55 damping: 26,
56 mass: 0.7,
57 overshootClamping: true,
58 stiffness: 330,
59};
60
61interface ThreadSwipeAction {
62 readonly accessibilityLabel: string;
63 readonly icon: ComponentProps<typeof SymbolView>["name"];
64 readonly label: string;
65 readonly menu?: {
66 readonly actions: MenuAction[];
67 readonly onPressAction: NonNullable<ComponentProps<typeof ControlPillMenu>["onPressAction"]>;
68 readonly title?: string;
69 };
70 readonly onPress: () => void;
71}
72
73interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
74 readonly tone: "primary" | "secondary" | "danger";
75}
76
77function swipeActionsWidth(hasSecondaryAction: boolean) {
78 return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH;
79}
80
81/** `undefined` keeps the v1 Delete default; `null` means one action only. */
82function resolveSecondaryAction(input: {
83 readonly close: () => void;
84 readonly onDelete: () => void;
85 readonly secondaryAction: ThreadSwipeAction | null | undefined;
86 readonly threadTitle: string;
87}): ThreadSwipeSecondaryAction | null {
88 if (input.secondaryAction === null) return null;
89 if (input.secondaryAction === undefined) {
90 return {
91 accessibilityLabel: `Delete ${input.threadTitle}`,
92 tone: "danger",
93 icon: "trash",
94 label: "Delete",
95 onPress: () => {
96 input.close();
97 input.onDelete();
98 },
99 };
100 }
101 const action = input.secondaryAction;
102 return {
103 ...action,
104 tone: "secondary",
105 menu:
106 action.menu === undefined
107 ? undefined
108 : {
109 ...action.menu,
110 onPressAction: (event) => {
111 input.close();
112 action.menu?.onPressAction(event);
113 },
114 },
115 onPress: () => {
116 input.close();
117 action.onPress();
118 },
119 };
120}
121
122/**
123 * Delivers the scroll gate to swipeables via context so that flipping it does
124 * NOT re-render whole rows: putting the flag in list extraData/renderItem deps
125 * re-rendered every visible row (hooks, subscriptions and all) exactly at
126 * scroll start — peak frame pressure. As a context value only the
127 * ThreadSwipeable consumers re-render.
128 */
129const SwipeableScrollGateContext = createContext(true);
130
131export function SwipeableScrollGateProvider(props: {
132 readonly enabled: boolean;
133 readonly activation?: SwipeRowActivation;
134 readonly children: ReactNode;
135}) {
136 return (
137 <SwipeableScrollGateContext.Provider value={props.enabled}>
138 <SwipeRowActivationContext value={props.activation ?? null}>
139 {props.children}
140 </SwipeRowActivationContext>
141 </SwipeableScrollGateContext.Provider>
142 );
143}
144
145/**
146 * Gates row swipes on list scroll activity, mirroring UIKit's own swipe
147 * actions (`!isDragging && !isDecelerating`). failOffsetY on the swipe pan
148 * covers the first pan of a scroll, but trackpad scroll sessions spawn fresh
149 * gesture sessions (momentum catch, direction changes) whose reset
150 * translation can re-activate a swipe mid-scroll — so while the list has
151 * moved vertically during an active drag/momentum phase, row swipes are
152 * disabled entirely.
153 *
154 * Spread the returned handlers onto the list and pass `swipeEnabled` to rows.
155 */
156export function useSwipeableScrollGate(options?: {
157 readonly onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
158 readonly onScrollBeginDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
159}) {
160 const [gateActive, setGateActive] = useState(false);
161 const gateActiveRef = useRef(false);
162 const draggingRef = useRef(false);
163 const dragStartYRef = useRef(0);
164 const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
165 const externalOnScroll = options?.onScroll;
166 const externalOnScrollBeginDrag = options?.onScrollBeginDrag;
167
168 const update = useCallback((next: boolean) => {
169 if (gateActiveRef.current !== next) {
170 gateActiveRef.current = next;
171 setGateActive(next);
172 }
173 }, []);
174 const clearSettle = useCallback(() => {
175 if (settleTimerRef.current !== null) {
176 clearTimeout(settleTimerRef.current);
177 settleTimerRef.current = null;
178 }
179 }, []);
180 useEffect(() => clearSettle, [clearSettle]);
181
182 const onScrollBeginDrag = useCallback(
183 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
184 draggingRef.current = true;
185 dragStartYRef.current = event.nativeEvent.contentOffset.y;
186 clearSettle();
187 externalOnScrollBeginDrag?.(event);
188 },
189 [clearSettle, externalOnScrollBeginDrag],
190 );
191 const onScroll = useCallback(
192 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
193 // Only vertical movement during a user drag arms the gate — a purely
194 // horizontal row swipe never moves contentOffset.y, and inset-driven
195 // offset changes at mount happen outside a drag.
196 if (
197 draggingRef.current &&
198 !gateActiveRef.current &&
199 Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4
200 ) {
201 update(true);
202 }
203 externalOnScroll?.(event);
204 },
205 [externalOnScroll, update],
206 );
207 const onScrollEndDrag = useCallback(() => {
208 draggingRef.current = false;
209 clearSettle();
210 // If momentum follows, onMomentumScrollBegin cancels this and the gate
211 // stays armed until the deceleration finishes.
212 settleTimerRef.current = setTimeout(() => update(false), 160);
213 }, [clearSettle, update]);
214 const onMomentumScrollBegin = useCallback(() => {
215 clearSettle();
216 }, [clearSettle]);
217 const onMomentumScrollEnd = useCallback(() => {
218 update(false);
219 }, [update]);
220
221 return {
222 swipeEnabled: !gateActive,
223 scrollGateHandlers: {
224 onScroll,
225 onScrollBeginDrag,
226 onScrollEndDrag,
227 onMomentumScrollBegin,
228 onMomentumScrollEnd,
229 },
230 };
231}
232
233interface ThreadSwipeableProps {
234 readonly backgroundColor: ColorValue;
235 readonly children: (close: () => void) => ReactNode;
236 /** Uses action visuals that fit inside compact 44pt rows. The press target
237 * still spans the row's full height and width. */
238 readonly compactActions?: boolean;
239 readonly containerStyle?: StyleProp<ViewStyle>;
240 /** Disables NEW swipe activations (e.g. while the list scrolls). */
241 readonly enabled?: boolean;
242 readonly enableTrackpadSwipe?: boolean;
243 /**
244 * What a full swipe commits. Omitted keeps the v1 Delete behavior only when
245 * the built-in Delete secondary action is in use; custom or absent
246 * secondary actions default to the advertised primary action.
247 */
248 readonly fullSwipeAction?: "delete" | "primary";
249 readonly fullSwipeWidth: number;
250 readonly onDelete: () => void;
251 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
252 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
253 readonly primaryAction: ThreadSwipeAction;
254 readonly threadKey: string;
255 /**
256 * Omitted keeps the v1 destructive Delete action. Explicit null opts out of
257 * a secondary action entirely so a gated Snooze can never fall back to an
258 * unadvertised Delete.
259 */
260 readonly secondaryAction?: ThreadSwipeAction | null;
261 /**
262 * Identity of the content being wrapped. When a recycled list reuses this
263 * component for a different item, the swipeable snaps back to closed so an
264 * open/mid-drag state can't leak onto another row.
265 */
266 readonly resetKey?: string;
267 /** Paints the row without swipe machinery; see swipe-row-activation. */
268 readonly dormant?: boolean;
269 readonly simultaneousWith?: ComponentProps<typeof ReanimatedSwipeable>["simultaneousWith"];
270 readonly threadTitle: string;
271}
272
273const closeDormant = () => {};
274
275export function ThreadSwipeable(props: ThreadSwipeableProps) {
276 if (props.dormant) {
277 // Mirrors ReanimatedSwipeable's container and children views.
278 return (
279 <View
280 style={[
281 { overflow: "hidden", backgroundColor: props.backgroundColor },
282 props.containerStyle,
283 ]}
284 >
285 <View style={{ backgroundColor: props.backgroundColor }}>
286 {props.children(closeDormant)}
287 </View>
288 </View>
289 );
290 }
291 // Recycled content gets fresh native and animation state. Late callbacks
292 // from the previous row retain its action, never the replacement's action.
293 return <ThreadSwipeableRow key={props.resetKey} {...props} />;
294}
295
296function ThreadSwipeableRow(props: ThreadSwipeableProps) {
297 const swipeableRef = useRef<SwipeableMethods | null>(null);
298 const fullSwipeArmedRef = useRef(false);
299 const hasSecondaryAction = props.secondaryAction !== null;
300 const actionsWidth = swipeActionsWidth(hasSecondaryAction);
301 const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58);
302 const fullSwipeAction =
303 props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary");
304 const close = useCallback(() => swipeableRef.current?.close(), []);
305 const gateEnabled = use(SwipeableScrollGateContext);
306 const mountedRef = useRef(true);
307 const dismissalRef = useRef<{ finished: Promise<void>; restore: () => void } | null>(null);
308 const pendingDismissRef = useRef<(() => void) | null>(null);
309 const activeTranslationRef = useRef<SharedValue<number> | null>(null);
310 const [isDismissing, setIsDismissing] = useState(false);
311 const dismissing = useSharedValue(false);
312 const rowHeight = useSharedValue(0);
313 const rowWidth = useSharedValue(props.fullSwipeWidth);
314 const collapse = useSharedValue(0);
315 const fallbackTranslation = useSharedValue(0);
316 const actionOpacity = useSharedValue(1);
317 const primaryAction = props.primaryAction;
318 const onSwipeableClose = props.onSwipeableClose;
319
320 const restoreRow = useCallback(() => {
321 if (!mountedRef.current) return;
322 dismissalRef.current = null;
323 swipeableRef.current?.reset();
324 fallbackTranslation.set(0);
325 collapse.set(0);
326 actionOpacity.set(1);
327 dismissing.set(false);
328 setIsDismissing(false);
329 }, [actionOpacity, collapse, dismissing, fallbackTranslation]);
330
331 const finishDismiss = useCallback(() => {
332 const finish = pendingDismissRef.current;
333 pendingDismissRef.current = null;
334 finish?.();
335 }, []);
336
337 useLayoutEffect(() => {
338 mountedRef.current = true;
339 return () => {
340 mountedRef.current = false;
341 cancelAnimation(collapse);
342 cancelAnimation(actionOpacity);
343 cancelAnimation(fallbackTranslation);
344 if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current);
345 // Recycling a row must not prevent the waiting action from running.
346 finishDismiss();
347 };
348 }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]);
349
350 const dismiss = useCallback(
351 (translation: SharedValue<number>) => {
352 "worklet";
353 if (dismissing.value) return;
354 dismissing.set(true);
355 const timing = {
356 duration: 220,
357 easing: Easing.out(Easing.cubic),
358 reduceMotion: ReduceMotion.System,
359 };
360 actionOpacity.set(withTiming(0, timing));
361 // Never reverse a swipe that already carried the row beyond its width.
362 translation.set(
363 withTiming(Math.min(translation.value, -rowWidth.value), timing, (finished) => {
364 if (!finished) return;
365 collapse.set(
366 withTiming(1, { ...timing, duration: 180 }, (collapsed) => {
367 if (collapsed) runOnJS(finishDismiss)();
368 }),
369 );
370 }),
371 );
372 },
373 [actionOpacity, collapse, dismissing, finishDismiss, rowWidth],
374 );
375 useLayoutEffect(
376 () =>
377 registerThreadDismissal(props.threadKey, () => {
378 if (dismissalRef.current) return dismissalRef.current;
379 const finished = new Promise<void>((resolve) => {
380 pendingDismissRef.current = resolve;
381 });
382 fullSwipeArmedRef.current = false;
383 setIsDismissing(true);
384 if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current);
385 runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation);
386 dismissalRef.current = { finished, restore: restoreRow };
387 return dismissalRef.current;
388 }),
389 [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow],
390 );
391 const dismissStyle = useAnimatedStyle(() => ({
392 height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined,
393 pointerEvents: dismissing.value ? "none" : "auto",
394 overflow: "hidden",
395 transform: [{ translateX: fallbackTranslation.value }],
396 }));
397 const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" }));
398 const commitPrimaryAction = useCallback(() => {
399 primaryAction.onPress();
400 if (!pendingDismissRef.current) swipeableRef.current?.close();
401 }, [primaryAction]);
402 const handleRelease = useCallback(
403 (translation: SharedValue<number>) => {
404 "worklet";
405 if (dismissing.value) return true;
406 if (fullSwipeAction === "primary" && -translation.value >= fullSwipeThreshold) {
407 runOnJS(commitPrimaryAction)();
408 return true;
409 }
410 return false;
411 },
412 [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold],
413 );
414 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
415 if (armed && !fullSwipeArmedRef.current) {
416 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
417 }
418 fullSwipeArmedRef.current = armed;
419 }, []);
420
421 return (
422 <Animated.View style={dismissStyle}>
423 <View
424 onLayout={({ nativeEvent: { layout } }) => {
425 rowHeight.set(layout.height);
426 rowWidth.set(layout.width);
427 }}
428 >
429 <ReanimatedSwipeable
430 ref={swipeableRef}
431 animationOptions={THREAD_SWIPE_SPRING}
432 childrenContainerStyle={{ backgroundColor: props.backgroundColor }}
433 containerStyle={[{ backgroundColor: props.backgroundColor }, props.containerStyle]}
434 dragOffsetFromRight={-8}
435 enabled={!isDismissing && props.enabled !== false && gateEnabled}
436 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
437 // Fail the swipe once the pan is vertically dominant (patched-in RNGH
438 // prop) — otherwise trackpad scrolls with ~8px of horizontal drift
439 // start opening rows because the swipe pan runs simultaneously with
440 // the list scroll gesture and never gets disqualified by Y movement.
441 failOffsetY={[-10, 10]}
442 friction={1}
443 onSwipeableClose={() => {
444 fullSwipeArmedRef.current = false;
445 if (swipeableRef.current) {
446 props.onSwipeableClose?.(swipeableRef.current);
447 }
448 }}
449 onSwipeableRelease={handleRelease}
450 onSwipeableOpenStartDrag={() => {
451 if (swipeableRef.current) {
452 props.onSwipeableWillOpen?.(swipeableRef.current);
453 }
454 }}
455 onSwipeableWillOpen={() => {
456 const methods = swipeableRef.current;
457 if (!methods) {
458 return;
459 }
460
461 props.onSwipeableWillOpen?.(methods);
462 if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") {
463 fullSwipeArmedRef.current = false;
464 methods.close();
465 props.onDelete();
466 }
467 }}
468 overshootFriction={1}
469 overshootRight
470 renderRightActions={(_progress, translation, methods) => (
471 <Animated.View
472 ref={() => {
473 activeTranslationRef.current = translation;
474 }}
475 style={actionStyle}
476 >
477 <ThreadSwipeActions
478 backgroundColor={props.backgroundColor}
479 compact={props.compactActions === true}
480 fullSwipeAction={fullSwipeAction}
481 fullSwipeThreshold={fullSwipeThreshold}
482 onFullSwipeArmedChange={handleFullSwipeArmedChange}
483 primaryAction={{
484 ...primaryAction,
485 onPress: commitPrimaryAction,
486 }}
487 secondaryAction={resolveSecondaryAction({
488 close: () => methods.close(),
489 onDelete: props.onDelete,
490 secondaryAction: props.secondaryAction,
491 threadTitle: props.threadTitle,
492 })}
493 translation={translation}
494 />
495 </Animated.View>
496 )}
497 rightThreshold={actionsWidth * 0.42}
498 simultaneousWith={props.simultaneousWith}
499 >
500 {props.children(close)}
501 </ReanimatedSwipeable>
502 </View>
503 </Animated.View>
504 );
505}
506
507function SwipeActionButton(props: {
508 readonly accessibilityLabel: string;
509 readonly actionsWidth: number;
510 readonly tone: "primary" | "secondary" | "danger";
511 readonly compact: boolean;
512 readonly entryRange: readonly [number, number];
513 readonly fullSwipeThreshold: number;
514 readonly icon: ComponentProps<typeof SymbolView>["name"];
515 readonly label: string;
516 readonly menu?: ThreadSwipeAction["menu"];
517 readonly onPress: () => void;
518 readonly stretchesOnFullSwipe: boolean;
519 readonly translation: SharedValue<number>;
520}) {
521 const {
522 actionsWidth,
523 entryRange: [entryRangeStart, entryRangeEnd],
524 fullSwipeThreshold,
525 stretchesOnFullSwipe,
526 translation,
527 } = props;
528 const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE;
529 const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE;
530 const actionStyle = useAnimatedStyle(() => {
531 const reveal = Math.max(-translation.value, 0);
532 const entryProgress = interpolate(
533 reveal,
534 [entryRangeStart, entryRangeEnd],
535 [0, 1],
536 Extrapolation.CLAMP,
537 );
538 const stretch = Math.max(reveal - actionsWidth, 0);
539 const fullSwipeProgress = interpolate(
540 reveal,
541 [actionsWidth, fullSwipeThreshold + 20],
542 [0, 1],
543 Extrapolation.CLAMP,
544 );
545
546 return {
547 opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress),
548 transform: [
549 {
550 translateX:
551 interpolate(entryProgress, [0, 1], [22, 0]) - (stretchesOnFullSwipe ? 0 : stretch),
552 },
553 { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) },
554 ],
555 };
556 });
557 const circleStyle = useAnimatedStyle(() => {
558 const reveal = Math.max(-translation.value, 0);
559 const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0;
560
561 return {
562 transform: [{ translateX: -stretch }],
563 width: circleSize + stretch,
564 };
565 });
566 const iconStyle = useAnimatedStyle(() => {
567 const reveal = Math.max(-translation.value, 0);
568 const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0;
569 const armedProgress = interpolate(
570 reveal,
571 [fullSwipeThreshold, fullSwipeThreshold + 20],
572 [0, 1],
573 Extrapolation.CLAMP,
574 );
575
576 return {
577 transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }],
578 };
579 });
580 const labelStyle = useAnimatedStyle(() => {
581 if (!stretchesOnFullSwipe) {
582 return { opacity: 1 };
583 }
584
585 const reveal = Math.max(-translation.value, 0);
586 const stretch = Math.max(reveal - actionsWidth, 0);
587 return {
588 opacity: interpolate(
589 reveal,
590 [fullSwipeThreshold - 24, fullSwipeThreshold],
591 [1, 0],
592 Extrapolation.CLAMP,
593 ),
594 transform: [{ translateX: -stretch * 0.5 }],
595 };
596 });
597
598 const button = (
599 <Pressable
600 accessibilityLabel={props.accessibilityLabel}
601 accessibilityRole="button"
602 onPress={props.menu === undefined ? props.onPress : undefined}
603 style={({ pressed }) => ({
604 alignItems: "center",
605 height: "100%",
606 justifyContent: "center",
607 opacity: pressed ? 0.72 : 1,
608 width: "100%",
609 })}
610 >
611 <View style={{ height: circleSize, width: circleSize }}>
612 <Animated.View
613 className={
614 props.tone === "danger"
615 ? "bg-danger"
616 : props.tone === "secondary"
617 ? "bg-secondary"
618 : "bg-primary"
619 }
620 style={[
621 {
622 borderRadius: 999,
623 height: circleSize,
624 left: 0,
625 position: "absolute",
626 top: 0,
627 },
628 circleStyle,
629 ]}
630 />
631 <Animated.View
632 style={[
633 {
634 alignItems: "center",
635 height: circleSize,
636 justifyContent: "center",
637 left: 0,
638 position: "absolute",
639 top: 0,
640 width: circleSize,
641 },
642 iconStyle,
643 ]}
644 >
645 <SymbolView
646 name={props.icon}
647 size={iconSize}
648 tintColorClassName={
649 props.tone === "danger"
650 ? "accent-danger-foreground"
651 : props.tone === "secondary"
652 ? "accent-secondary-foreground"
653 : "accent-primary-foreground"
654 }
655 type="monochrome"
656 />
657 </Animated.View>
658 </View>
659 <Animated.View
660 style={[
661 { height: 14, justifyContent: "center", paddingTop: props.compact ? 0 : 2 },
662 labelStyle,
663 ]}
664 >
665 <Text className="text-3xs font-t3-medium text-foreground-muted" numberOfLines={1}>
666 {props.label}
667 </Text>
668 </Animated.View>
669 </Pressable>
670 );
671
672 return (
673 <Animated.View
674 style={[
675 {
676 alignItems: "center",
677 height: "100%",
678 justifyContent: "center",
679 width: ACTION_ITEM_WIDTH,
680 zIndex: props.stretchesOnFullSwipe ? 2 : 1,
681 },
682 actionStyle,
683 ]}
684 >
685 {props.menu === undefined ? (
686 button
687 ) : (
688 <ControlPillMenu
689 actions={props.menu.actions}
690 onPressAction={props.menu.onPressAction}
691 title={props.menu.title}
692 style={{ height: "100%", width: "100%" }}
693 >
694 {button}
695 </ControlPillMenu>
696 )}
697 </Animated.View>
698 );
699}
700
701export function ThreadSwipeActions(props: {
702 readonly backgroundColor: ColorValue;
703 readonly compact: boolean;
704 readonly fullSwipeAction?: "delete" | "primary";
705 readonly fullSwipeThreshold: number;
706 readonly onFullSwipeArmedChange: (armed: boolean) => void;
707 readonly primaryAction: ThreadSwipeAction;
708 readonly secondaryAction: ThreadSwipeSecondaryAction | null;
709 readonly translation: SharedValue<number>;
710}) {
711 const { fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = props;
712 const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null;
713 const actionsWidth = swipeActionsWidth(secondaryAction !== null);
714 useAnimatedReaction(
715 () => -translation.value >= fullSwipeThreshold,
716 (armed, previous) => {
717 if (armed !== previous) {
718 runOnJS(onFullSwipeArmedChange)(armed);
719 }
720 },
721 [fullSwipeThreshold, onFullSwipeArmedChange, translation],
722 );
723
724 return (
725 <View
726 style={{
727 backgroundColor: props.backgroundColor,
728 flexDirection: "row",
729 height: "100%",
730 width: actionsWidth,
731 }}
732 >
733 <SwipeActionButton
734 accessibilityLabel={props.primaryAction.accessibilityLabel}
735 actionsWidth={actionsWidth}
736 tone="primary"
737 compact={props.compact}
738 entryRange={
739 secondaryAction === null
740 ? [8, ACTION_ITEM_WIDTH * 0.72]
741 : [ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]
742 }
743 fullSwipeThreshold={props.fullSwipeThreshold}
744 icon={props.primaryAction.icon}
745 label={props.primaryAction.label}
746 onPress={props.primaryAction.onPress}
747 stretchesOnFullSwipe={fullSwipeIsPrimary}
748 translation={props.translation}
749 />
750 {secondaryAction === null ? null : (
751 <SwipeActionButton
752 accessibilityLabel={secondaryAction.accessibilityLabel}
753 actionsWidth={actionsWidth}
754 tone={secondaryAction.tone}
755 compact={props.compact}
756 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
757 fullSwipeThreshold={props.fullSwipeThreshold}
758 icon={secondaryAction.icon}
759 label={secondaryAction.label}
760 menu={secondaryAction.menu}
761 onPress={secondaryAction.onPress}
762 stretchesOnFullSwipe={!fullSwipeIsPrimary}
763 translation={props.translation}
764 />
765 )}
766 </View>
767 );
768}