Skip to content
Source
Sign inSign up

thread-swipe-actions.tsx

804 lines27.0 KB

1import { SymbolView } from "../../components/ui/AppSymbol";
2import { Menu } from "../../components/ui/Menu";
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 useMemo,
12 useRef,
13 useState,
14 type ComponentProps,
15 type ReactNode,
16} from "react";
17import type {
18 ColorValue,
19 NativeScrollEvent,
20 NativeSyntheticEvent,
21 StyleProp,
22 ViewStyle,
23} from "react-native";
24import { Platform, Pressable, View, useWindowDimensions } from "react-native";
25import ReanimatedSwipeable, {
26 type SwipeableMethods,
27} from "react-native-gesture-handler/ReanimatedSwipeable";
28import Animated, {
29 cancelAnimation,
30 Easing,
31 Extrapolation,
32 ReduceMotion,
33 interpolate,
34 runOnJS,
35 runOnUI,
36 type SharedValue,
37 useAnimatedReaction,
38 useAnimatedStyle,
39 useSharedValue,
40 withTiming,
41} from "react-native-reanimated";
42​
43import { AppText as Text } from "../../components/ui/AppText";
44import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
45import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation";
46import { registerThreadDismissal } from "./thread-dismissal";
47​
48const ACTION_ITEM_WIDTH = 58;
49const ACTION_CIRCLE_SIZE = 36;
50const ACTION_ICON_SIZE = 15;
51const COMPACT_ACTION_CIRCLE_SIZE = 28;
52const COMPACT_ACTION_ICON_SIZE = 13;
53// At the default app text size, "Unarchive", the longest label, fills its slot at about 1.12x.
54const ACTION_LABEL_MAX_FONT_SCALE = 1.1;
55​
56const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2;
57const THREAD_SWIPE_SPRING = {
58 damping: 26,
59 mass: 0.7,
60 overshootClamping: true,
61 stiffness: 330,
62};
63​
64interface ThreadSwipeAction {
65 readonly accessibilityLabel: string;
66 readonly icon: ComponentProps<typeof SymbolView>["name"];
67 readonly label: string;
68 readonly destructive?: boolean;
69 readonly menu?: {
70 readonly actions: MenuAction[];
71 readonly onPressAction: NonNullable<ComponentProps<typeof Menu>["onPressAction"]>;
72 readonly title?: string;
73 };
74 readonly onPress: () => void;
76​
77interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
78 readonly tone: "primary" | "secondary" | "danger";
80​
81function swipeActionsWidth(hasSecondaryAction: boolean) {
82 return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH;
84​
85function resolveSecondaryAction(input: {
86 readonly close: () => void;
87 readonly onDelete: () => void;
88 readonly secondaryAction: ThreadSwipeAction | null | undefined;
89 readonly threadTitle: string;
90}): ThreadSwipeSecondaryAction | null {
91 if (input.secondaryAction === null) return null;
92 if (input.secondaryAction === undefined) {
93 return {
94 accessibilityLabel: `Delete ${input.threadTitle}`,
95 tone: "danger",
96 icon: "trash",
97 label: "Delete",
98 onPress: () => {
99 input.close();
100 input.onDelete();
101 },
102 };
103 }
104 const action = input.secondaryAction;
105 return {
106 ...action,
107 tone: "secondary",
108 menu:
109 action.menu === undefined
110 ? undefined
111 : {
112 ...action.menu,
113 onPressAction: (event) => {
114 input.close();
115 action.menu?.onPressAction(event);
116 },
117 },
118 onPress: () => {
119 input.close();
120 action.onPress();
121 },
122 };
124​
125/** Keep the scroll gate in context so scroll start cannot re-render every row and its subscriptions. */
126const SwipeableScrollGateContext = createContext(true);
127// Swipeable rebuilds its native pan gesture whenever its config changes identity, so the props
128// that feed it stay stable across row re-renders such as selection.
129const FAIL_OFFSET_Y: [number, number] = [-10, 10];
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 );
144​
145/** Trackpads can start pans mid-momentum, bypassing failOffsetY; disable swipes during vertical scrolls. */
146export function useSwipeableScrollGate(options?: {
147 readonly onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
148 readonly onScrollBeginDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
149}) {
150 const [gateActive, setGateActive] = useState(false);
151 const gateActiveRef = useRef(false);
152 const draggingRef = useRef(false);
153 const dragStartYRef = useRef(0);
154 const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
155 const externalOnScroll = options?.onScroll;
156 const externalOnScrollBeginDrag = options?.onScrollBeginDrag;
157​
158 const update = useCallback((next: boolean) => {
159 if (gateActiveRef.current !== next) {
160 gateActiveRef.current = next;
161 setGateActive(next);
162 }
163 }, []);
164 const clearSettle = useCallback(() => {
165 if (settleTimerRef.current !== null) {
166 clearTimeout(settleTimerRef.current);
167 settleTimerRef.current = null;
168 }
169 }, []);
170 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the scroll settle timer on unmount
171 useEffect(() => clearSettle, [clearSettle]);
172​
173 const onScrollBeginDrag = useCallback(
174 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
175 draggingRef.current = true;
176 dragStartYRef.current = event.nativeEvent.contentOffset.y;
177 clearSettle();
178 externalOnScrollBeginDrag?.(event);
179 },
180 [clearSettle, externalOnScrollBeginDrag],
181 );
182 const onScroll = useCallback(
183 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
184 if (
185 draggingRef.current &&
186 !gateActiveRef.current &&
187 Math.abs(event.nativeEvent.contentOffset.y - dragStartYRef.current) > 4
188 ) {
189 update(true);
190 }
191 externalOnScroll?.(event);
192 },
193 [externalOnScroll, update],
194 );
195 const onScrollEndDrag = useCallback(() => {
196 draggingRef.current = false;
197 clearSettle();
198 settleTimerRef.current = setTimeout(() => update(false), 160);
199 }, [clearSettle, update]);
200 const onMomentumScrollBegin = useCallback(() => {
201 clearSettle();
202 }, [clearSettle]);
203 const onMomentumScrollEnd = useCallback(() => {
204 update(false);
205 }, [update]);
206​
207 return {
208 swipeEnabled: !gateActive,
209 scrollGateHandlers: {
210 onScroll,
211 onScrollBeginDrag,
212 onScrollEndDrag,
213 onMomentumScrollBegin,
214 onMomentumScrollEnd,
215 },
216 };
218​
219interface ThreadSwipeableProps {
220 readonly backgroundColor: ColorValue;
221 readonly children: (close: () => void) => ReactNode;
222 readonly compactActions?: boolean;
223 readonly containerStyle?: StyleProp<ViewStyle>;
224 readonly enabled?: boolean;
225 readonly enableTrackpadSwipe?: boolean;
226 /** Omitted uses Delete only with the built-in Delete secondary action; otherwise it uses primary. */
227 readonly fullSwipeAction?: "delete" | "primary";
228 readonly fullSwipeWidth: number;
229 readonly onDelete: () => void;
230 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
231 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
232 readonly primaryAction: ThreadSwipeAction;
233 readonly threadKey: string;
234 /** Omitted defaults to Delete; null removes it so gated Snooze cannot fall back to Delete. */
235 readonly secondaryAction?: ThreadSwipeAction | null;
236 readonly resetKey?: string;
237 readonly dormant?: boolean;
238 readonly simultaneousWith?: ComponentProps<typeof ReanimatedSwipeable>["simultaneousWith"];
239 readonly threadTitle: string;
241​
242export function ThreadSwipeable(props: ThreadSwipeableProps) {
243 const swipeableRef = useRef<SwipeableMethods | null>(null);
244 const hasSecondaryAction = props.secondaryAction !== null;
245 const actionsWidth = swipeActionsWidth(hasSecondaryAction);
246 const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58);
247 const fullSwipeAction =
248 props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary");
249 const close = useCallback(() => swipeableRef.current?.close(), []);
250 const gateEnabled = use(SwipeableScrollGateContext);
251 const mountedRef = useRef(true);
252 const dismissalRef = useRef<{ finished: Promise<void>; restore: () => void } | null>(null);
253 const pendingDismissRef = useRef<{ id: number; resolve: () => void } | null>(null);
254 const dismissalIdRef = useRef(0);
255 const activeTranslationRef = useRef<SharedValue<number> | null>(null);
256 const content = useMemo(
257 () => ({ resetKey: props.resetKey, threadKey: props.threadKey }),
258 [props.resetKey, props.threadKey],
259 );
260 const contentRef = useRef(content);
261 const [isDismissing, setIsDismissing] = useState(false);
262 const dismissing = useSharedValue(false);
263 const rowHeight = useSharedValue(0);
264 const rowWidth = useSharedValue(props.fullSwipeWidth);
265 const collapse = useSharedValue(0);
266 const fallbackTranslation = useSharedValue(0);
267 const actionOpacity = useSharedValue(1);
268 const primaryAction = props.primaryAction;
269 const onDelete = props.onDelete;
270 const onSwipeableClose = props.onSwipeableClose;
271​
272 const restoreRow = useCallback(() => {
273 if (!mountedRef.current) return;
274 dismissalRef.current = null;
275 // RN-to-UI jobs run in order, so this also resets a queued dismissal.
276 runOnUI((methods: SwipeableMethods | null) => {
277 "worklet";
278 methods?.reset();
279 // RNGH reset() leaves tap-to-close enabled; close() clears it.
280 methods?.close();
281 fallbackTranslation.set(0);
282 collapse.set(0);
283 actionOpacity.set(1);
284 dismissing.set(false);
285 })(swipeableRef.current);
286 setIsDismissing(false);
287 }, [actionOpacity, collapse, dismissing, fallbackTranslation]);
288​
289 const finishDismiss = useCallback((id?: number) => {
290 const pending = pendingDismissRef.current;
291 if (!pending || (id !== undefined && pending.id !== id)) return;
292 pendingDismissRef.current = null;
293 pending.resolve();
294 }, []);
295​
296 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the row's Reanimated animations and settles a pending dismissal on unmount
297 useLayoutEffect(() => {
298 mountedRef.current = true;
299 return () => {
300 mountedRef.current = false;
301 cancelAnimation(collapse);
302 cancelAnimation(actionOpacity);
303 cancelAnimation(fallbackTranslation);
304 if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current);
305 finishDismiss();
306 };
307 }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]);
308​
309 // Reset recycled rows in place so their child views stay mounted.
310 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the swipeable and its shared values when the list recycles this row for new content
311 useLayoutEffect(() => {
312 if (contentRef.current === content) return;
313 contentRef.current = content;
314 finishDismiss();
315 restoreRow();
316 }, [content, finishDismiss, restoreRow]);
317​
318 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes the open swipeable and resets its shared values when the row goes dormant
319 useLayoutEffect(() => {
320 if (props.dormant && activeTranslationRef.current && dismissalRef.current === null) {
321 restoreRow();
322 }
323 }, [props.dormant, restoreRow]);
324​
325 const dismiss = useCallback(
326 (translation: SharedValue<number>, id: number) => {
327 "worklet";
328 if (dismissing.value) return;
329 dismissing.set(true);
330 const timing = {
331 duration: 220,
332 easing: Easing.out(Easing.cubic),
333 reduceMotion: ReduceMotion.System,
334 };
335 actionOpacity.set(withTiming(0, timing));
336 translation.set(
337 withTiming(Math.min(translation.value, -rowWidth.value), timing, (finished) => {
338 if (!finished) return;
339 collapse.set(
340 withTiming(1, { ...timing, duration: 180 }, (collapsed) => {
341 if (collapsed) runOnJS(finishDismiss)(id);
342 }),
343 );
344 }),
345 );
346 },
347 [actionOpacity, collapse, dismissing, finishDismiss, rowWidth],
348 );
349 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this row's animated dismissal in the module-level thread dismissal registry
350 useLayoutEffect(
351 () =>
352 props.dormant
353 ? undefined
354 : registerThreadDismissal(props.threadKey, () => {
355 if (dismissalRef.current) return dismissalRef.current;
356 const id = ++dismissalIdRef.current;
357 const finished = new Promise<void>((resolve) => {
358 pendingDismissRef.current = { id, resolve };
359 });
360 setIsDismissing(true);
361 if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current);
362 runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation, id);
363 const dismissal = {
364 finished,
365 restore: () => {
366 if (dismissalRef.current === dismissal) restoreRow();
367 },
368 };
369 dismissalRef.current = dismissal;
370 return dismissal;
371 }),
372 [dismiss, fallbackTranslation, onSwipeableClose, props.dormant, props.threadKey, restoreRow],
373 );
374 const dismissStyle = useAnimatedStyle(() => ({
375 // Native prop updates omit undefined, leaving a collapsed height in place.
376 height: dismissing.value ? rowHeight.value * (1 - collapse.value) : "auto",
377 pointerEvents: dismissing.value ? "none" : "auto",
378 overflow: "hidden",
379 transform: [{ translateX: fallbackTranslation.value }],
380 }));
381 const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" }));
382 const commitPrimaryAction = useCallback(() => {
383 if (!mountedRef.current || contentRef.current !== content) return;
384 primaryAction.onPress();
385 if (!pendingDismissRef.current) swipeableRef.current?.close();
386 }, [content, primaryAction]);
387 // RNGH lifecycle events call the latest handler, so commit from this release.
388 const commitFullSwipe = useCallback(() => {
389 if (!mountedRef.current || contentRef.current !== content) return;
390 if (fullSwipeAction === "primary") commitPrimaryAction();
391 else {
392 swipeableRef.current?.close();
393 onDelete();
394 }
395 }, [commitPrimaryAction, content, fullSwipeAction, onDelete]);
396 const handleRelease = useCallback(
397 (translation: SharedValue<number>, canceled: boolean) => {
398 "worklet";
399 if (dismissing.value) return true;
400 if (!canceled && -translation.value >= fullSwipeThreshold) {
401 runOnJS(commitFullSwipe)();
402 return true;
403 }
404 return false;
405 },
406 [commitFullSwipe, dismissing, fullSwipeThreshold],
407 );
408 const handleFullSwipeArmedChange = useCallback(
409 (armed: boolean) => {
410 if (mountedRef.current && contentRef.current === content && armed) {
411 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
412 }
413 },
414 [content],
415 );
416 const { dormant, onSwipeableWillOpen, backgroundColor, containerStyle } = props;
417 const handleWillOpen = useCallback(() => {
418 if (!dormant && !dismissalRef.current && swipeableRef.current) {
419 onSwipeableWillOpen?.(swipeableRef.current);
420 }
421 }, [dormant, onSwipeableWillOpen]);
422 const handleSwipeableClose = useCallback(() => {
423 if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current);
424 }, [onSwipeableClose]);
425 const compactActions = props.compactActions === true;
426 const { secondaryAction, threadTitle } = props;
427 // Swipeable calls this on every render; returning the previous element while nothing it shows
428 // changed keeps a selection change from re-rendering the hidden actions.
429 const rightActions = useRef<{ deps: readonly unknown[]; element: ReactNode } | null>(null);
430 const renderRightActions = (
431 _progress: SharedValue<number>,
432 translation: SharedValue<number>,
433 methods: SwipeableMethods,
434 ) => {
435 const deps = [
436 dormant,
437 translation,
438 methods,
439 actionStyle,
440 backgroundColor,
441 compactActions,
442 fullSwipeAction,
443 fullSwipeThreshold,
444 handleFullSwipeArmedChange,
445 primaryAction,
446 commitPrimaryAction,
447 onDelete,
448 secondaryAction,
449 threadTitle,
450 ];
451 const cached = rightActions.current;
452 if (cached && deps.every((dep, index) => Object.is(dep, cached.deps[index]))) {
453 return cached.element;
454 }
455 const element = dormant ? null : (
456 <Animated.View
457 ref={() => {
458 activeTranslationRef.current = translation;
459 }}
460 style={actionStyle}
461 >
462 <ThreadSwipeActions
463 backgroundColor={backgroundColor}
464 compact={compactActions}
465 fullSwipeAction={fullSwipeAction}
466 fullSwipeThreshold={fullSwipeThreshold}
467 onFullSwipeArmedChange={handleFullSwipeArmedChange}
468 primaryAction={{ ...primaryAction, onPress: commitPrimaryAction }}
469 secondaryAction={resolveSecondaryAction({
470 close: () => methods.close(),
471 onDelete,
472 secondaryAction,
473 threadTitle,
474 })}
475 translation={translation}
476 />
477 </Animated.View>
478 );
479 rightActions.current = { deps, element };
480 return element;
481 };
482 const childrenContainerStyle = useMemo(() => ({ backgroundColor }), [backgroundColor]);
483 const swipeableContainerStyle = useMemo(
484 () => [{ backgroundColor }, containerStyle],
485 [backgroundColor, containerStyle],
486 );
487​
488 return (
489 <Animated.View style={dismissStyle}>
490 <View
491 onLayout={({ nativeEvent: { layout } }) => {
492 rowHeight.set(layout.height);
493 rowWidth.set(layout.width);
494 }}
495 >
496 <ReanimatedSwipeable
497 ref={swipeableRef}
498 animationOptions={THREAD_SWIPE_SPRING}
499 childrenContainerStyle={childrenContainerStyle}
500 containerStyle={swipeableContainerStyle}
501 dragOffsetFromRight={-8}
502 enabled={!props.dormant && !isDismissing && props.enabled !== false && gateEnabled}
503 enableTrackpadTwoFingerGesture={props.enableTrackpadSwipe ?? true}
504 // Reject vertical pans via patched RNGH failOffsetY; simultaneous trackpad scrolls otherwise open rows on horizontal drift.
505 failOffsetY={FAIL_OFFSET_Y}
506 friction={1}
507 onSwipeableClose={handleSwipeableClose}
508 onSwipeableRelease={handleRelease}
509 onSwipeableOpenStartDrag={handleWillOpen}
510 onSwipeableWillOpen={handleWillOpen}
511 overshootFriction={1}
512 overshootRight
513 renderRightActions={renderRightActions}
514 rightThreshold={actionsWidth * 0.42}
515 simultaneousWith={props.simultaneousWith}
516 >
517 {
518 /* oxlint-disable react/refs -- The render callback receives a close/open handler; only invoking that handler accesses the ref. */
519 props.children(close)
520 /* oxlint-enable react/refs */
521 }
522 </ReanimatedSwipeable>
523 </View>
524 </Animated.View>
525 );
527​
528function SwipeActionButton(props: {
529 readonly accessibilityLabel: string;
530 readonly actionsWidth: number;
531 readonly tone: "primary" | "secondary" | "danger";
532 readonly compact: boolean;
533 readonly entryRange: readonly [number, number];
534 readonly fullSwipeThreshold: number;
535 readonly icon: ComponentProps<typeof SymbolView>["name"];
536 readonly label: string;
537 readonly menu?: ThreadSwipeAction["menu"];
538 readonly onPress: () => void;
539 readonly stretchesOnFullSwipe: boolean;
540 readonly translation: SharedValue<number>;
541}) {
542 const {
543 actionsWidth,
544 entryRange: [entryRangeStart, entryRangeEnd],
545 fullSwipeThreshold,
546 stretchesOnFullSwipe,
547 translation,
548 } = props;
549 const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE;
550 const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE;
551 const { fontScale } = useWindowDimensions();
552 const labelLineHeight = useScaledTextRole("micro").lineHeight;
553 // Android scales lineHeight by the full font scale, past maxFontSizeMultiplier.
554 const labelTextStyle = {
555 lineHeight:
556 Platform.OS === "android" && fontScale > ACTION_LABEL_MAX_FONT_SCALE
557 ? (labelLineHeight * ACTION_LABEL_MAX_FONT_SCALE) / fontScale
558 : labelLineHeight,
559 };
560 const actionStyle = useAnimatedStyle(() => {
561 const reveal = Math.max(-translation.value, 0);
562 const entryProgress = interpolate(
563 reveal,
564 [entryRangeStart, entryRangeEnd],
565 [0, 1],
566 Extrapolation.CLAMP,
567 );
568 const stretch = Math.max(reveal - actionsWidth, 0);
569 const fullSwipeProgress = interpolate(
570 reveal,
571 [actionsWidth, fullSwipeThreshold + 20],
572 [0, 1],
573 Extrapolation.CLAMP,
574 );
575​
576 return {
577 opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress),
578 transform: [
579 {
580 translateX:
581 interpolate(entryProgress, [0, 1], [22, 0]) - (stretchesOnFullSwipe ? 0 : stretch),
582 },
583 { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) },
584 ],
585 };
586 });
587 const circleStyle = useAnimatedStyle(() => {
588 const reveal = Math.max(-translation.value, 0);
589 const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0;
590​
591 return {
592 transform: [{ translateX: -stretch }],
593 width: circleSize + stretch,
594 };
595 });
596 const iconStyle = useAnimatedStyle(() => {
597 const reveal = Math.max(-translation.value, 0);
598 const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0;
599 const armedProgress = interpolate(
600 reveal,
601 [fullSwipeThreshold, fullSwipeThreshold + 20],
602 [0, 1],
603 Extrapolation.CLAMP,
604 );
605​
606 return {
607 transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }],
608 };
609 });
610 const labelStyle = useAnimatedStyle(() => {
611 if (!stretchesOnFullSwipe) {
612 return { opacity: 1 };
613 }
614​
615 const reveal = Math.max(-translation.value, 0);
616 const stretch = Math.max(reveal - actionsWidth, 0);
617 return {
618 opacity: interpolate(
619 reveal,
620 [fullSwipeThreshold - 24, fullSwipeThreshold],
621 [1, 0],
622 Extrapolation.CLAMP,
623 ),
624 transform: [{ translateX: -stretch * 0.5 }],
625 };
626 });
627​
628 const button = (
629 <Pressable
630 accessibilityLabel={props.accessibilityLabel}
631 accessibilityRole="button"
632 onPress={props.menu === undefined ? props.onPress : undefined}
633 style={({ pressed }) => ({
634 alignItems: "center",
635 height: "100%",
636 justifyContent: "center",
637 opacity: pressed ? 0.72 : 1,
638 width: "100%",
639 })}
640 >
641 <View style={{ height: circleSize, width: circleSize }}>
642 <Animated.View
643 className={
644 props.tone === "danger"
645 ? "bg-danger"
646 : props.tone === "secondary"
647 ? "bg-secondary"
648 : "bg-primary"
649 }
650 style={[
651 {
652 borderRadius: 999,
653 height: circleSize,
654 left: 0,
655 position: "absolute",
656 top: 0,
657 },
658 circleStyle,
659 ]}
660 />
661 <Animated.View
662 style={[
663 {
664 alignItems: "center",
665 height: circleSize,
666 justifyContent: "center",
667 left: 0,
668 position: "absolute",
669 top: 0,
670 width: circleSize,
671 },
672 iconStyle,
673 ]}
674 >
675 <SymbolView
676 name={props.icon}
677 size={iconSize}
678 tintColorClassName={
679 props.tone === "danger"
680 ? "accent-danger-foreground"
681 : props.tone === "secondary"
682 ? "accent-secondary-foreground"
683 : "accent-primary-foreground"
684 }
685 type="monochrome"
686 />
687 </Animated.View>
688 </View>
689 {/* An offset, not padding: padding would grow the box and move the circle at 1x. */}
690 <Animated.View
691 style={[
692 { justifyContent: "center", minHeight: 14, top: props.compact ? 0 : 2 },
693 labelStyle,
694 ]}
695 >
696 <Text
697 className="text-3xs font-t3-medium text-foreground-muted"
698 maxFontSizeMultiplier={ACTION_LABEL_MAX_FONT_SCALE}
699 numberOfLines={1}
700 style={labelTextStyle}
701 >
702 {props.label}
703 </Text>
704 </Animated.View>
705 </Pressable>
706 );
707​
708 return (
709 <Animated.View
710 style={[
711 {
712 alignItems: "center",
713 height: "100%",
714 justifyContent: "center",
715 width: ACTION_ITEM_WIDTH,
716 zIndex: props.stretchesOnFullSwipe ? 2 : 1,
717 },
718 actionStyle,
719 ]}
720 >
721 {props.menu === undefined ? (
722 button
723 ) : (
724 <Menu
725 actions={props.menu.actions}
726 onPressAction={props.menu.onPressAction}
727 title={props.menu.title}
728 style={{ height: "100%", width: "100%" }}
729 >
730 {button}
731 </Menu>
732 )}
733 </Animated.View>
734 );
736​
737function ThreadSwipeActions(props: {
738 readonly backgroundColor: ColorValue;
739 readonly compact: boolean;
740 readonly fullSwipeAction?: "delete" | "primary";
741 readonly fullSwipeThreshold: number;
742 readonly onFullSwipeArmedChange: (armed: boolean) => void;
743 readonly primaryAction: ThreadSwipeAction;
744 readonly secondaryAction: ThreadSwipeSecondaryAction | null;
745 readonly translation: SharedValue<number>;
746}) {
747 const { fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = props;
748 const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null;
749 const actionsWidth = swipeActionsWidth(secondaryAction !== null);
750 useAnimatedReaction(
751 () => -translation.value >= fullSwipeThreshold,
752 (armed, previous) => {
753 if (armed !== previous) {
754 runOnJS(onFullSwipeArmedChange)(armed);
755 }
756 },
757 [fullSwipeThreshold, onFullSwipeArmedChange, translation],
758 );
759​
760 return (
761 <View
762 style={{
763 backgroundColor: props.backgroundColor,
764 flexDirection: "row",
765 height: "100%",
766 width: actionsWidth,
767 }}
768 >
769 <SwipeActionButton
770 accessibilityLabel={props.primaryAction.accessibilityLabel}
771 actionsWidth={actionsWidth}
772 tone={props.primaryAction.destructive === true ? "danger" : "primary"}
773 compact={props.compact}
774 entryRange={
775 secondaryAction === null
776 ? [8, ACTION_ITEM_WIDTH * 0.72]
777 : [ACTION_ITEM_WIDTH * 0.55, THREAD_SWIPE_ACTIONS_WIDTH * 0.85]
778 }
779 fullSwipeThreshold={props.fullSwipeThreshold}
780 icon={props.primaryAction.icon}
781 label={props.primaryAction.label}
782 onPress={props.primaryAction.onPress}
783 stretchesOnFullSwipe={fullSwipeIsPrimary}
784 translation={props.translation}
785 />
786 {secondaryAction === null ? null : (
787 <SwipeActionButton
788 accessibilityLabel={secondaryAction.accessibilityLabel}
789 actionsWidth={actionsWidth}
790 tone={secondaryAction.tone}
791 compact={props.compact}
792 entryRange={[8, ACTION_ITEM_WIDTH * 0.72]}
793 fullSwipeThreshold={props.fullSwipeThreshold}
794 icon={secondaryAction.icon}
795 label={secondaryAction.label}
796 menu={secondaryAction.menu}
797 onPress={secondaryAction.onPress}
798 stretchesOnFullSwipe={!fullSwipeIsPrimary}
799 translation={props.translation}
800 />
801 )}
802 </View>
803 );