| ... | @@ -20,7 +20,7 @@ import type { | ... | @@ -20,7 +20,7 @@ import type { |
| 20 | StyleProp, | 20 | StyleProp, |
| 21 | ViewStyle, | 21 | ViewStyle, |
| 22 | } from "react-native"; | 22 | } from "react-native"; |
| 23 | import { Alert, Pressable, View } from "react-native"; | 23 | import { Pressable, View } from "react-native"; |
| 24 | import ReanimatedSwipeable, { | 24 | import ReanimatedSwipeable, { |
| 25 | type SwipeableMethods, | 25 | type SwipeableMethods, |
| 26 | } from "react-native-gesture-handler/ReanimatedSwipeable"; | 26 | } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| ... | @@ -40,6 +40,7 @@ import Animated, { | ... | @@ -40,6 +40,7 @@ import Animated, { |
| 40 | } from "react-native-reanimated"; | 40 | } from "react-native-reanimated"; |
| 41 | | 41 | |
| 42 | import { AppText as Text } from "../../components/AppText"; | 42 | import { AppText as Text } from "../../components/AppText"; |
| | 43 | import { registerThreadDismissal } from "./thread-dismissal"; |
| 43 | | 44 | |
| 44 | // Wide enough for the longest action label ("Unarchive"). | 45 | // Wide enough for the longest action label ("Unarchive"). |
| 45 | const ACTION_ITEM_WIDTH = 58; | 46 | const ACTION_ITEM_WIDTH = 58; |
| ... | @@ -68,13 +69,6 @@ interface ThreadSwipeAction { | ... | @@ -68,13 +69,6 @@ interface ThreadSwipeAction { |
| 68 | readonly onPress: () => void; | 69 | readonly onPress: () => void; |
| 69 | } | 70 | } |
| 70 | | 71 | |
| 71 | /** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */ | | |
| 72 | type ThreadSwipePrimaryAction = Omit<ThreadSwipeAction, "onPress"> & | | |
| 73 | ( | | |
| 74 | | { readonly dismissOnPress: true; readonly onPress: () => Promise<boolean> } | | |
| 75 | | { readonly dismissOnPress?: false; readonly onPress: () => void } | | |
| 76 | ); | | |
| 77 | | | |
| 78 | interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { | 72 | interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { |
| 79 | readonly tone: "primary" | "secondary" | "danger"; | 73 | readonly tone: "primary" | "secondary" | "danger"; |
| 80 | } | 74 | } |
| ... | @@ -252,7 +246,8 @@ interface ThreadSwipeableProps { | ... | @@ -252,7 +246,8 @@ interface ThreadSwipeableProps { |
| 252 | readonly onDelete: () => void; | 246 | readonly onDelete: () => void; |
| 253 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; | 247 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; |
| 254 | readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; | 248 | readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; |
| 255 | readonly primaryAction: ThreadSwipePrimaryAction; | 249 | readonly primaryAction: ThreadSwipeAction; |
| | 250 | readonly threadKey: string; |
| 256 | /** | 251 | /** |
| 257 | * Omitted keeps the v1 destructive Delete action. Explicit null opts out of | 252 | * Omitted keeps the v1 destructive Delete action. Explicit null opts out of |
| 258 | * a secondary action entirely so a gated Snooze can never fall back to an | 253 | * a secondary action entirely so a gated Snooze can never fall back to an |
| ... | @@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { | ... | @@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 288 | const close = useCallback(() => swipeableRef.current?.close(), []); | 283 | const close = useCallback(() => swipeableRef.current?.close(), []); |
| 289 | const gateEnabled = use(SwipeableScrollGateContext); | 284 | const gateEnabled = use(SwipeableScrollGateContext); |
| 290 | const mountedRef = useRef(true); | 285 | const mountedRef = useRef(true); |
| 291 | const pendingDismissRef = useRef<(() => Promise<boolean>) | null>(null); | 286 | const dismissalRef = useRef<{ finished: Promise<void>; restore: () => void } | null>(null); |
| | 287 | const pendingDismissRef = useRef<(() => void) | null>(null); |
| 292 | const activeTranslationRef = useRef<SharedValue<number> | null>(null); | 288 | const activeTranslationRef = useRef<SharedValue<number> | null>(null); |
| 293 | const [isDismissing, setIsDismissing] = useState(false); | 289 | const [isDismissing, setIsDismissing] = useState(false); |
| 294 | const dismissing = useSharedValue(false); | 290 | const dismissing = useSharedValue(false); |
| 295 | const rowHeight = useSharedValue(0); | 291 | const rowHeight = useSharedValue(0); |
| 296 | const rowWidth = useSharedValue(props.fullSwipeWidth); | 292 | const rowWidth = useSharedValue(props.fullSwipeWidth); |
| 297 | const collapse = useSharedValue(0); | 293 | const collapse = useSharedValue(0); |
| | 294 | const fallbackTranslation = useSharedValue(0); |
| 298 | const actionOpacity = useSharedValue(1); | 295 | const actionOpacity = useSharedValue(1); |
| 299 | const primaryAction = props.primaryAction; | 296 | const primaryAction = props.primaryAction; |
| 300 | const onSwipeableClose = props.onSwipeableClose; | 297 | const onSwipeableClose = props.onSwipeableClose; |
| 301 | | 298 | |
| 302 | const restoreRow = useCallback(() => { | 299 | const restoreRow = useCallback(() => { |
| 303 | swipeableRef.current?.close(); | 300 | if (!mountedRef.current) return; |
| | 301 | dismissalRef.current = null; |
| | 302 | swipeableRef.current?.reset(); |
| | 303 | fallbackTranslation.set(0); |
| 304 | collapse.set(0); | 304 | collapse.set(0); |
| 305 | actionOpacity.set(1); | 305 | actionOpacity.set(1); |
| 306 | dismissing.set(false); | 306 | dismissing.set(false); |
| 307 | setIsDismissing(false); | 307 | setIsDismissing(false); |
| 308 | }, [actionOpacity, collapse, dismissing]); | 308 | }, [actionOpacity, collapse, dismissing, fallbackTranslation]); |
| 309 | | 309 | |
| 310 | const finishDismiss = useCallback(async () => { | 310 | const finishDismiss = useCallback(() => { |
| 311 | const action = pendingDismissRef.current; | 311 | const finish = pendingDismissRef.current; |
| 312 | if (!action) return; | | |
| 313 | pendingDismissRef.current = null; | 312 | pendingDismissRef.current = null; |
| 314 | try { | 313 | finish?.(); |
| 315 | const succeeded = await action(); | 314 | }, []); |
| 316 | if (!succeeded && mountedRef.current) restoreRow(); | | |
| 317 | } catch (error) { | | |
| 318 | if (mountedRef.current) restoreRow(); | | |
| 319 | Alert.alert( | | |
| 320 | "Could not settle thread", | | |
| 321 | error instanceof Error ? error.message : "The thread could not be settled.", | | |
| 322 | ); | | |
| 323 | } | | |
| 324 | }, [restoreRow]); | | |
| 325 | | 315 | |
| 326 | useLayoutEffect(() => { | 316 | useLayoutEffect(() => { |
| 327 | mountedRef.current = true; | 317 | mountedRef.current = true; |
| ... | @@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { | ... | @@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 329 | mountedRef.current = false; | 319 | mountedRef.current = false; |
| 330 | cancelAnimation(collapse); | 320 | cancelAnimation(collapse); |
| 331 | cancelAnimation(actionOpacity); | 321 | cancelAnimation(actionOpacity); |
| | 322 | cancelAnimation(fallbackTranslation); |
| 332 | if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); | 323 | if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); |
| 333 | // Scrolling a committed row out of the recycled list must still settle it. | 324 | // Recycling a row must not prevent the waiting action from running. |
| 334 | void finishDismiss(); | 325 | finishDismiss(); |
| 335 | }; | 326 | }; |
| 336 | }, [actionOpacity, collapse, finishDismiss]); | 327 | }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); |
| 337 | | | |
| 338 | const beginDismiss = useCallback( | | |
| 339 | (translation: SharedValue<number>) => { | | |
| 340 | if (!primaryAction.dismissOnPress) return; | | |
| 341 | pendingDismissRef.current = primaryAction.onPress; | | |
| 342 | activeTranslationRef.current = translation; | | |
| 343 | fullSwipeArmedRef.current = false; | | |
| 344 | if (!mountedRef.current) { | | |
| 345 | void finishDismiss(); | | |
| 346 | return; | | |
| 347 | } | | |
| 348 | setIsDismissing(true); | | |
| 349 | if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); | | |
| 350 | }, | | |
| 351 | [finishDismiss, primaryAction, onSwipeableClose], | | |
| 352 | ); | | |
| 353 | | 328 | |
| 354 | const dismiss = useCallback( | 329 | const dismiss = useCallback( |
| 355 | (translation: SharedValue<number>) => { | 330 | (translation: SharedValue<number>) => { |
| 356 | "worklet"; | 331 | "worklet"; |
| 357 | if (dismissing.value) return; | 332 | if (dismissing.value) return; |
| 358 | dismissing.set(true); | 333 | dismissing.set(true); |
| 359 | runOnJS(beginDismiss)(translation); | | |
| 360 | const timing = { | 334 | const timing = { |
| 361 | duration: 220, | 335 | duration: 220, |
| 362 | easing: Easing.out(Easing.cubic), | 336 | easing: Easing.out(Easing.cubic), |
| ... | @@ -375,30 +349,46 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { | ... | @@ -375,30 +349,46 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 375 | }), | 349 | }), |
| 376 | ); | 350 | ); |
| 377 | }, | 351 | }, |
| 378 | [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth], | 352 | [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], |
| | 353 | ); |
| | 354 | useLayoutEffect( |
| | 355 | () => |
| | 356 | registerThreadDismissal(props.threadKey, () => { |
| | 357 | if (dismissalRef.current) return dismissalRef.current; |
| | 358 | const finished = new Promise<void>((resolve) => { |
| | 359 | pendingDismissRef.current = resolve; |
| | 360 | }); |
| | 361 | fullSwipeArmedRef.current = false; |
| | 362 | setIsDismissing(true); |
| | 363 | if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); |
| | 364 | runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); |
| | 365 | dismissalRef.current = { finished, restore: restoreRow }; |
| | 366 | return dismissalRef.current; |
| | 367 | }), |
| | 368 | [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow], |
| 379 | ); | 369 | ); |
| 380 | const dismissStyle = useAnimatedStyle(() => ({ | 370 | const dismissStyle = useAnimatedStyle(() => ({ |
| 381 | height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, | 371 | height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, |
| 382 | pointerEvents: dismissing.value ? "none" : "auto", | 372 | pointerEvents: dismissing.value ? "none" : "auto", |
| 383 | overflow: "hidden", | 373 | overflow: "hidden", |
| | 374 | transform: [{ translateX: fallbackTranslation.value }], |
| 384 | })); | 375 | })); |
| 385 | const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" })); | 376 | const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" })); |
| 386 | const dismissOnPress = primaryAction.dismissOnPress === true; | 377 | const commitPrimaryAction = useCallback(() => { |
| | 378 | primaryAction.onPress(); |
| | 379 | if (!pendingDismissRef.current) swipeableRef.current?.close(); |
| | 380 | }, [primaryAction]); |
| 387 | const handleRelease = useCallback( | 381 | const handleRelease = useCallback( |
| 388 | (translation: SharedValue<number>) => { | 382 | (translation: SharedValue<number>) => { |
| 389 | "worklet"; | 383 | "worklet"; |
| 390 | if (dismissing.value) return true; | 384 | if (dismissing.value) return true; |
| 391 | if ( | 385 | if (fullSwipeAction === "primary" && -translation.value >= fullSwipeThreshold) { |
| 392 | dismissOnPress && | 386 | runOnJS(commitPrimaryAction)(); |
| 393 | fullSwipeAction === "primary" && | | |
| 394 | -translation.value >= fullSwipeThreshold | | |
| 395 | ) { | | |
| 396 | dismiss(translation); | | |
| 397 | return true; | 387 | return true; |
| 398 | } | 388 | } |
| 399 | return false; | 389 | return false; |
| 400 | }, | 390 | }, |
| 401 | [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold], | 391 | [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold], |
| 402 | ); | 392 | ); |
| 403 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { | 393 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { |
| 404 | if (armed && !fullSwipeArmedRef.current) { | 394 | if (armed && !fullSwipeArmedRef.current) { |
| ... | @@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { | ... | @@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 448 | } | 438 | } |
| 449 | | 439 | |
| 450 | props.onSwipeableWillOpen?.(methods); | 440 | props.onSwipeableWillOpen?.(methods); |
| 451 | if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) { | 441 | if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") { |
| 452 | fullSwipeArmedRef.current = false; | 442 | fullSwipeArmedRef.current = false; |
| 453 | methods.close(); | 443 | methods.close(); |
| 454 | if (fullSwipeAction === "primary") { | 444 | props.onDelete(); |
| 455 | props.primaryAction.onPress(); | | |
| 456 | } else { | | |
| 457 | props.onDelete(); | | |
| 458 | } | | |
| 459 | } | 445 | } |
| 460 | }} | 446 | }} |
| 461 | overshootFriction={1} | 447 | overshootFriction={1} |
| 462 | overshootRight | 448 | overshootRight |
| 463 | renderRightActions={(_progress, translation, methods) => ( | 449 | renderRightActions={(_progress, translation, methods) => ( |
| 464 | <Animated.View style={actionStyle}> | 450 | <Animated.View |
| | 451 | ref={() => { |
| | 452 | activeTranslationRef.current = translation; |
| | 453 | }} |
| | 454 | style={actionStyle} |
| | 455 | > |
| 465 | <ThreadSwipeActions | 456 | <ThreadSwipeActions |
| 466 | backgroundColor={props.backgroundColor} | 457 | backgroundColor={props.backgroundColor} |
| 467 | compact={props.compactActions === true} | 458 | compact={props.compactActions === true} |
| ... | @@ -470,14 +461,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { | ... | @@ -470,14 +461,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 470 | onFullSwipeArmedChange={handleFullSwipeArmedChange} | 461 | onFullSwipeArmedChange={handleFullSwipeArmedChange} |
| 471 | primaryAction={{ | 462 | primaryAction={{ |
| 472 | ...primaryAction, | 463 | ...primaryAction, |
| 473 | onPress: () => { | 464 | onPress: commitPrimaryAction, |
| 474 | if (primaryAction.dismissOnPress) { | | |
| 475 | runOnUI(dismiss)(translation); | | |
| 476 | } else { | | |
| 477 | methods.close(); | | |
| 478 | primaryAction.onPress(); | | |
| 479 | } | | |
| 480 | }, | | |
| 481 | }} | 465 | }} |
| 482 | secondaryAction={resolveSecondaryAction({ | 466 | secondaryAction={resolveSecondaryAction({ |
| 483 | close: () => methods.close(), | 467 | close: () => methods.close(), |