diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index c2d5b6cf65ab..97f9c5b69d0f 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -422,6 +422,8 @@ function ArchivedThreadRow(props: { ); return ( { + it("waits for every visible copy before changing thread state", async () => { + const home = Promise.withResolvers(); + const sidebar = Promise.withResolvers(); + const restore = vi.fn(); + const unregisterHome = registerThreadDismissal("env:thread", () => ({ + finished: home.promise, + restore, + })); + const unregisterSidebar = registerThreadDismissal("env:thread", () => ({ + finished: sidebar.promise, + restore, + })); + const command = vi.fn(async () => true); + try { + const result = withThreadDismissal("env:thread", command, Boolean); + expect(command).not.toHaveBeenCalled(); + home.resolve(); + await home.promise; + expect(command).not.toHaveBeenCalled(); + sidebar.resolve(); + expect(await result).toBe(true); + expect(command).toHaveBeenCalledOnce(); + expect(restore).not.toHaveBeenCalled(); + } finally { + unregisterHome(); + unregisterSidebar(); + } + }); + + it.each([false, "throw"])( + "restores dismissed rows when the command returns %s", + async (outcome) => { + const restore = vi.fn(); + const unregister = registerThreadDismissal("env:thread", () => ({ + finished: Promise.resolve(), + restore, + })); + try { + const result = withThreadDismissal( + "env:thread", + async () => { + if (outcome === "throw") throw new Error("Disconnected"); + return outcome; + }, + Boolean, + ); + if (outcome === "throw") await expect(result).rejects.toThrow("Disconnected"); + else expect(await result).toBe(false); + expect(restore).toHaveBeenCalledOnce(); + } finally { + unregister(); + } + }, + ); + + it("does not animate another environment or a recycled row", async () => { + const dismiss = vi.fn(() => ({ finished: Promise.resolve(), restore: vi.fn() })); + const unregisterOther = registerThreadDismissal("other:thread", dismiss); + const unregisterRecycled = registerThreadDismissal("env:thread", dismiss); + unregisterRecycled(); + try { + expect(await withThreadDismissal("env:thread", async () => true, Boolean)).toBe(true); + expect(dismiss).not.toHaveBeenCalled(); + } finally { + unregisterOther(); + } + }); +}); diff --git a/apps/mobile/src/features/home/thread-dismissal.ts b/apps/mobile/src/features/home/thread-dismissal.ts new file mode 100644 index 000000000000..1e1561570d7a --- /dev/null +++ b/apps/mobile/src/features/home/thread-dismissal.ts @@ -0,0 +1,35 @@ +interface ThreadDismissal { + readonly finished: Promise; + readonly restore: () => void; +} + +const rows = new Map ThreadDismissal>>(); + +/** A thread can be visible in Home and the navigation sidebar at once. */ +export function registerThreadDismissal(key: string, dismiss: () => ThreadDismissal) { + const registrations = rows.get(key) ?? new Set(); + registrations.add(dismiss); + rows.set(key, registrations); + return () => { + registrations.delete(dismiss); + if (registrations.size === 0) rows.delete(key); + }; +} + +/** Finish the exit before mutating the list; failed commands put the rows back. */ +export async function withThreadDismissal( + key: string, + action: () => Promise, + succeeded: (result: T) => boolean, +): Promise { + const dismissals = Array.from(rows.get(key) ?? [], (dismiss) => dismiss()); + let committed = false; + try { + await Promise.all(dismissals.map(({ finished }) => finished)); + const result = await action(); + committed = succeeded(result); + return result; + } finally { + if (!committed) dismissals.forEach(({ restore }) => restore()); + } +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 93085fec6c22..44f3c36e6655 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -20,7 +20,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Alert, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). const ACTION_ITEM_WIDTH = 58; @@ -68,13 +69,6 @@ interface ThreadSwipeAction { readonly onPress: () => void; } -/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */ -type ThreadSwipePrimaryAction = Omit & - ( - | { readonly dismissOnPress: true; readonly onPress: () => Promise } - | { readonly dismissOnPress?: false; readonly onPress: () => void } - ); - interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { readonly tone: "primary" | "secondary" | "danger"; } @@ -252,7 +246,8 @@ interface ThreadSwipeableProps { readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; - readonly primaryAction: ThreadSwipePrimaryAction; + readonly primaryAction: ThreadSwipeAction; + readonly threadKey: string; /** * Omitted keeps the v1 destructive Delete action. Explicit null opts out of * a secondary action entirely so a gated Snooze can never fall back to an @@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const mountedRef = useRef(true); - const pendingDismissRef = useRef<(() => Promise) | null>(null); + const dismissalRef = useRef<{ finished: Promise; restore: () => void } | null>(null); + const pendingDismissRef = useRef<(() => void) | null>(null); const activeTranslationRef = useRef | null>(null); const [isDismissing, setIsDismissing] = useState(false); const dismissing = useSharedValue(false); const rowHeight = useSharedValue(0); const rowWidth = useSharedValue(props.fullSwipeWidth); const collapse = useSharedValue(0); + const fallbackTranslation = useSharedValue(0); const actionOpacity = useSharedValue(1); const primaryAction = props.primaryAction; const onSwipeableClose = props.onSwipeableClose; const restoreRow = useCallback(() => { - swipeableRef.current?.close(); + if (!mountedRef.current) return; + dismissalRef.current = null; + swipeableRef.current?.reset(); + fallbackTranslation.set(0); collapse.set(0); actionOpacity.set(1); dismissing.set(false); setIsDismissing(false); - }, [actionOpacity, collapse, dismissing]); + }, [actionOpacity, collapse, dismissing, fallbackTranslation]); - const finishDismiss = useCallback(async () => { - const action = pendingDismissRef.current; - if (!action) return; + const finishDismiss = useCallback(() => { + const finish = pendingDismissRef.current; pendingDismissRef.current = null; - try { - const succeeded = await action(); - if (!succeeded && mountedRef.current) restoreRow(); - } catch (error) { - if (mountedRef.current) restoreRow(); - Alert.alert( - "Could not settle thread", - error instanceof Error ? error.message : "The thread could not be settled.", - ); - } - }, [restoreRow]); + finish?.(); + }, []); useLayoutEffect(() => { mountedRef.current = true; @@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { mountedRef.current = false; cancelAnimation(collapse); cancelAnimation(actionOpacity); + cancelAnimation(fallbackTranslation); if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); - // Scrolling a committed row out of the recycled list must still settle it. - void finishDismiss(); + // Recycling a row must not prevent the waiting action from running. + finishDismiss(); }; - }, [actionOpacity, collapse, finishDismiss]); - - const beginDismiss = useCallback( - (translation: SharedValue) => { - if (!primaryAction.dismissOnPress) return; - pendingDismissRef.current = primaryAction.onPress; - activeTranslationRef.current = translation; - fullSwipeArmedRef.current = false; - if (!mountedRef.current) { - void finishDismiss(); - return; - } - setIsDismissing(true); - if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); - }, - [finishDismiss, primaryAction, onSwipeableClose], - ); + }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]); const dismiss = useCallback( (translation: SharedValue) => { "worklet"; if (dismissing.value) return; dismissing.set(true); - runOnJS(beginDismiss)(translation); const timing = { duration: 220, easing: Easing.out(Easing.cubic), @@ -375,30 +349,46 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { }), ); }, - [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth], + [actionOpacity, collapse, dismissing, finishDismiss, rowWidth], + ); + useLayoutEffect( + () => + registerThreadDismissal(props.threadKey, () => { + if (dismissalRef.current) return dismissalRef.current; + const finished = new Promise((resolve) => { + pendingDismissRef.current = resolve; + }); + fullSwipeArmedRef.current = false; + setIsDismissing(true); + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + runOnUI(dismiss)(activeTranslationRef.current ?? fallbackTranslation); + dismissalRef.current = { finished, restore: restoreRow }; + return dismissalRef.current; + }), + [dismiss, fallbackTranslation, onSwipeableClose, props.threadKey, restoreRow], ); const dismissStyle = useAnimatedStyle(() => ({ height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, pointerEvents: dismissing.value ? "none" : "auto", overflow: "hidden", + transform: [{ translateX: fallbackTranslation.value }], })); const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" })); - const dismissOnPress = primaryAction.dismissOnPress === true; + const commitPrimaryAction = useCallback(() => { + primaryAction.onPress(); + if (!pendingDismissRef.current) swipeableRef.current?.close(); + }, [primaryAction]); const handleRelease = useCallback( (translation: SharedValue) => { "worklet"; if (dismissing.value) return true; - if ( - dismissOnPress && - fullSwipeAction === "primary" && - -translation.value >= fullSwipeThreshold - ) { - dismiss(translation); + if (fullSwipeAction === "primary" && -translation.value >= fullSwipeThreshold) { + runOnJS(commitPrimaryAction)(); return true; } return false; }, - [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold], + [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold], ); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { if (armed && !fullSwipeArmedRef.current) { @@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { } props.onSwipeableWillOpen?.(methods); - if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) { + if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") { fullSwipeArmedRef.current = false; methods.close(); - if (fullSwipeAction === "primary") { - props.primaryAction.onPress(); - } else { - props.onDelete(); - } + props.onDelete(); } }} overshootFriction={1} overshootRight renderRightActions={(_progress, translation, methods) => ( - + { + activeTranslationRef.current = translation; + }} + style={actionStyle} + > { - if (primaryAction.dismissOnPress) { - runOnUI(dismiss)(translation); - } else { - methods.close(); - primaryAction.onPress(); - } - }, + onPress: commitPrimaryAction, }} secondaryAction={resolveSecondaryAction({ close: () => methods.close(), diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index f6983bd58d1b..2c08f2647569 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -5,6 +5,7 @@ import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; import { Alert } from "react-native"; +import { withThreadDismissal } from "./thread-dismissal"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; @@ -131,26 +132,30 @@ function useThreadActionExecutor( ); return false; } - const result = - action === "unsettle" - ? // reason "user" pins the thread active: auto-settle stays - // suppressed until real activity clears the pin server-side. - await unsettleMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }) - : await ( - action === "settle" - ? settleMutation - : action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation - )({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = await withThreadDismissal( + key, + async () => + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return false; @@ -275,13 +280,18 @@ export function useThreadListActions(): { } selectionHaptic(); - const result = await snoozeMutation({ - environmentId: thread.environmentId, - input: { - threadId: thread.id, - snoozedUntil, - }, - }); + const result = await withThreadDismissal( + key, + () => + snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Alert.alert( @@ -316,10 +326,15 @@ export function useThreadListActions(): { } selectionHaptic(); - const result = await unsnoozeMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }); + const result = await withThreadDismissal( + key, + () => + unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }), + (result) => result._tag === "Success", + ); if (result._tag === "Failure") { const error = Cause.squash(result.cause); Alert.alert( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 03443c9f7868..7a677a5ec797 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -725,6 +725,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { return (