authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 02:57:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 02:57:58-07:00
loge32dd42f8ba0daa2d0c6d6d2e474b7af6b3eb7b4
treeac220230bae1cec9009a56dff6cbc8744ca9f175
parentc0d4e95c0d921b5960ad04c7f8abec459ee3c59e
signature Signed by PGP key B5690EEEBB952194

fix(mobile): animate thread lifecycle transitions consistently (#10487)


7 files changed, 213 insertions(+), 103 deletions(-)

apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+2
......@@ -422,6 +422,8 @@ function ArchivedThreadRow(props: {
422422 );
423423 return (
424424 <ThreadSwipeable
425 resetKey={`${props.thread.environmentId}:${props.thread.id}`}
426 threadKey={`${props.thread.environmentId}:${props.thread.id}`}
425427 backgroundColor={cardColor}
426428 // Round + clip the swipeable container so the group's corners stay
427429 // rounded while rows swipe; the row itself stays square inside.
apps/mobile/src/features/home/thread-dismissal.test.ts created+73
......@@ -0,0 +1,73 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { registerThreadDismissal, withThreadDismissal } from "./thread-dismissal";
4
5describe("thread dismissal", () => {
6 it("waits for every visible copy before changing thread state", async () => {
7 const home = Promise.withResolvers<void>();
8 const sidebar = Promise.withResolvers<void>();
9 const restore = vi.fn();
10 const unregisterHome = registerThreadDismissal("env:thread", () => ({
11 finished: home.promise,
12 restore,
13 }));
14 const unregisterSidebar = registerThreadDismissal("env:thread", () => ({
15 finished: sidebar.promise,
16 restore,
17 }));
18 const command = vi.fn(async () => true);
19 try {
20 const result = withThreadDismissal("env:thread", command, Boolean);
21 expect(command).not.toHaveBeenCalled();
22 home.resolve();
23 await home.promise;
24 expect(command).not.toHaveBeenCalled();
25 sidebar.resolve();
26 expect(await result).toBe(true);
27 expect(command).toHaveBeenCalledOnce();
28 expect(restore).not.toHaveBeenCalled();
29 } finally {
30 unregisterHome();
31 unregisterSidebar();
32 }
33 });
34
35 it.each([false, "throw"])(
36 "restores dismissed rows when the command returns %s",
37 async (outcome) => {
38 const restore = vi.fn();
39 const unregister = registerThreadDismissal("env:thread", () => ({
40 finished: Promise.resolve(),
41 restore,
42 }));
43 try {
44 const result = withThreadDismissal(
45 "env:thread",
46 async () => {
47 if (outcome === "throw") throw new Error("Disconnected");
48 return outcome;
49 },
50 Boolean,
51 );
52 if (outcome === "throw") await expect(result).rejects.toThrow("Disconnected");
53 else expect(await result).toBe(false);
54 expect(restore).toHaveBeenCalledOnce();
55 } finally {
56 unregister();
57 }
58 },
59 );
60
61 it("does not animate another environment or a recycled row", async () => {
62 const dismiss = vi.fn(() => ({ finished: Promise.resolve(), restore: vi.fn() }));
63 const unregisterOther = registerThreadDismissal("other:thread", dismiss);
64 const unregisterRecycled = registerThreadDismissal("env:thread", dismiss);
65 unregisterRecycled();
66 try {
67 expect(await withThreadDismissal("env:thread", async () => true, Boolean)).toBe(true);
68 expect(dismiss).not.toHaveBeenCalled();
69 } finally {
70 unregisterOther();
71 }
72 });
73});
apps/mobile/src/features/home/thread-dismissal.ts created+35
......@@ -0,0 +1,35 @@
1interface ThreadDismissal {
2 readonly finished: Promise<void>;
3 readonly restore: () => void;
4}
5
6const rows = new Map<string, Set<() => ThreadDismissal>>();
7
8/** A thread can be visible in Home and the navigation sidebar at once. */
9export function registerThreadDismissal(key: string, dismiss: () => ThreadDismissal) {
10 const registrations = rows.get(key) ?? new Set();
11 registrations.add(dismiss);
12 rows.set(key, registrations);
13 return () => {
14 registrations.delete(dismiss);
15 if (registrations.size === 0) rows.delete(key);
16 };
17}
18
19/** Finish the exit before mutating the list; failed commands put the rows back. */
20export async function withThreadDismissal<T>(
21 key: string,
22 action: () => Promise<T>,
23 succeeded: (result: T) => boolean,
24): Promise<T> {
25 const dismissals = Array.from(rows.get(key) ?? [], (dismiss) => dismiss());
26 let committed = false;
27 try {
28 await Promise.all(dismissals.map(({ finished }) => finished));
29 const result = await action();
30 committed = succeeded(result);
31 return result;
32 } finally {
33 if (!committed) dismissals.forEach(({ restore }) => restore());
34 }
35}
apps/mobile/src/features/home/thread-swipe-actions.tsx+54-70
......@@ -20,7 +20,7 @@ import type {
2020 StyleProp,
2121 ViewStyle,
2222} from "react-native";
23import { Alert, Pressable, View } from "react-native";
23import { Pressable, View } from "react-native";
2424import ReanimatedSwipeable, {
2525 type SwipeableMethods,
2626} from "react-native-gesture-handler/ReanimatedSwipeable";
......@@ -40,6 +40,7 @@ import Animated, {
4040} from "react-native-reanimated";
4141
4242import { AppText as Text } from "../../components/AppText";
43import { registerThreadDismissal } from "./thread-dismissal";
4344
4445// Wide enough for the longest action label ("Unarchive").
4546const ACTION_ITEM_WIDTH = 58;
......@@ -68,13 +69,6 @@ interface ThreadSwipeAction {
6869 readonly onPress: () => void;
6970}
7071
71/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */
72type ThreadSwipePrimaryAction = Omit<ThreadSwipeAction, "onPress"> &
73 (
74 | { readonly dismissOnPress: true; readonly onPress: () => Promise<boolean> }
75 | { readonly dismissOnPress?: false; readonly onPress: () => void }
76 );
77
7872interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {
7973 readonly tone: "primary" | "secondary" | "danger";
8074}
......@@ -252,7 +246,8 @@ interface ThreadSwipeableProps {
252246 readonly onDelete: () => void;
253247 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
254248 readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void;
255 readonly primaryAction: ThreadSwipePrimaryAction;
249 readonly primaryAction: ThreadSwipeAction;
250 readonly threadKey: string;
256251 /**
257252 * Omitted keeps the v1 destructive Delete action. Explicit null opts out of
258253 * a secondary action entirely so a gated Snooze can never fall back to an
......@@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) {
288283 const close = useCallback(() => swipeableRef.current?.close(), []);
289284 const gateEnabled = use(SwipeableScrollGateContext);
290285 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);
292288 const activeTranslationRef = useRef<SharedValue<number> | null>(null);
293289 const [isDismissing, setIsDismissing] = useState(false);
294290 const dismissing = useSharedValue(false);
295291 const rowHeight = useSharedValue(0);
296292 const rowWidth = useSharedValue(props.fullSwipeWidth);
297293 const collapse = useSharedValue(0);
294 const fallbackTranslation = useSharedValue(0);
298295 const actionOpacity = useSharedValue(1);
299296 const primaryAction = props.primaryAction;
300297 const onSwipeableClose = props.onSwipeableClose;
301298
302299 const restoreRow = useCallback(() => {
303 swipeableRef.current?.close();
300 if (!mountedRef.current) return;
301 dismissalRef.current = null;
302 swipeableRef.current?.reset();
303 fallbackTranslation.set(0);
304304 collapse.set(0);
305305 actionOpacity.set(1);
306306 dismissing.set(false);
307307 setIsDismissing(false);
308 }, [actionOpacity, collapse, dismissing]);
308 }, [actionOpacity, collapse, dismissing, fallbackTranslation]);
309309
310 const finishDismiss = useCallback(async () => {
311 const action = pendingDismissRef.current;
312 if (!action) return;
310 const finishDismiss = useCallback(() => {
311 const finish = pendingDismissRef.current;
313312 pendingDismissRef.current = null;
314 try {
315 const succeeded = await action();
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]);
313 finish?.();
314 }, []);
325315
326316 useLayoutEffect(() => {
327317 mountedRef.current = true;
......@@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) {
329319 mountedRef.current = false;
330320 cancelAnimation(collapse);
331321 cancelAnimation(actionOpacity);
322 cancelAnimation(fallbackTranslation);
332323 if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current);
333 // Scrolling a committed row out of the recycled list must still settle it.
334 void finishDismiss();
324 // Recycling a row must not prevent the waiting action from running.
325 finishDismiss();
335326 };
336 }, [actionOpacity, collapse, 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 );
327 }, [actionOpacity, collapse, fallbackTranslation, finishDismiss]);
353328
354329 const dismiss = useCallback(
355330 (translation: SharedValue<number>) => {
356331 "worklet";
357332 if (dismissing.value) return;
358333 dismissing.set(true);
359 runOnJS(beginDismiss)(translation);
360334 const timing = {
361335 duration: 220,
362336 easing: Easing.out(Easing.cubic),
......@@ -375,30 +349,46 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) {
375349 }),
376350 );
377351 },
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],
379369 );
380370 const dismissStyle = useAnimatedStyle(() => ({
381371 height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined,
382372 pointerEvents: dismissing.value ? "none" : "auto",
383373 overflow: "hidden",
374 transform: [{ translateX: fallbackTranslation.value }],
384375 }));
385376 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]);
387381 const handleRelease = useCallback(
388382 (translation: SharedValue<number>) => {
389383 "worklet";
390384 if (dismissing.value) return true;
391 if (
392 dismissOnPress &&
393 fullSwipeAction === "primary" &&
394 -translation.value >= fullSwipeThreshold
395 ) {
396 dismiss(translation);
385 if (fullSwipeAction === "primary" && -translation.value >= fullSwipeThreshold) {
386 runOnJS(commitPrimaryAction)();
397387 return true;
398388 }
399389 return false;
400390 },
401 [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold],
391 [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold],
402392 );
403393 const handleFullSwipeArmedChange = useCallback((armed: boolean) => {
404394 if (armed && !fullSwipeArmedRef.current) {
......@@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) {
448438 }
449439
450440 props.onSwipeableWillOpen?.(methods);
451 if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) {
441 if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") {
452442 fullSwipeArmedRef.current = false;
453443 methods.close();
454 if (fullSwipeAction === "primary") {
455 props.primaryAction.onPress();
456 } else {
457 props.onDelete();
458 }
444 props.onDelete();
459445 }
460446 }}
461447 overshootFriction={1}
462448 overshootRight
463449 renderRightActions={(_progress, translation, methods) => (
464 <Animated.View style={actionStyle}>
450 <Animated.View
451 ref={() => {
452 activeTranslationRef.current = translation;
453 }}
454 style={actionStyle}
455 >
465456 <ThreadSwipeActions
466457 backgroundColor={props.backgroundColor}
467458 compact={props.compactActions === true}
......@@ -470,14 +461,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) {
470461 onFullSwipeArmedChange={handleFullSwipeArmedChange}
471462 primaryAction={{
472463 ...primaryAction,
473 onPress: () => {
474 if (primaryAction.dismissOnPress) {
475 runOnUI(dismiss)(translation);
476 } else {
477 methods.close();
478 primaryAction.onPress();
479 }
480 },
464 onPress: commitPrimaryAction,
481465 }}
482466 secondaryAction={resolveSecondaryAction({
483467 close: () => methods.close(),
apps/mobile/src/features/home/useThreadListActions.ts+46-31
......@@ -5,6 +5,7 @@ import * as Haptics from "expo-haptics";
55import { useCallback, useRef } from "react";
66import { Alert } from "react-native";
77
8import { withThreadDismissal } from "./thread-dismissal";
89import { showConfirmDialog } from "../../components/ConfirmDialogHost";
910import { scopedThreadKey } from "../../lib/scopedEntities";
1011import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
......@@ -131,26 +132,30 @@ function useThreadActionExecutor(
131132 );
132133 return false;
133134 }
134 const result =
135 action === "unsettle"
136 ? // reason "user" pins the thread active: auto-settle stays
137 // suppressed until real activity clears the pin server-side.
138 await unsettleMutation({
139 environmentId: thread.environmentId,
140 input: { threadId: thread.id, reason: "user" },
141 })
142 : await (
143 action === "settle"
144 ? settleMutation
145 : action === "archive"
146 ? archiveMutation
147 : action === "unarchive"
148 ? unarchiveMutation
149 : deleteMutation
150 )({
151 environmentId: thread.environmentId,
152 input: { threadId: thread.id },
153 });
135 const result = await withThreadDismissal(
136 key,
137 async () =>
138 action === "unsettle"
139 ? // reason "user" pins the thread active: auto-settle stays
140 // suppressed until real activity clears the pin server-side.
141 await unsettleMutation({
142 environmentId: thread.environmentId,
143 input: { threadId: thread.id, reason: "user" },
144 })
145 : await (
146 action === "settle"
147 ? settleMutation
148 : action === "archive"
149 ? archiveMutation
150 : action === "unarchive"
151 ? unarchiveMutation
152 : deleteMutation
153 )({
154 environmentId: thread.environmentId,
155 input: { threadId: thread.id },
156 }),
157 (result) => result._tag === "Success",
158 );
154159 if (result._tag === "Failure") {
155160 Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));
156161 return false;
......@@ -275,13 +280,18 @@ export function useThreadListActions(): {
275280 }
276281
277282 selectionHaptic();
278 const result = await snoozeMutation({
279 environmentId: thread.environmentId,
280 input: {
281 threadId: thread.id,
282 snoozedUntil,
283 },
284 });
283 const result = await withThreadDismissal(
284 key,
285 () =>
286 snoozeMutation({
287 environmentId: thread.environmentId,
288 input: {
289 threadId: thread.id,
290 snoozedUntil,
291 },
292 }),
293 (result) => result._tag === "Success",
294 );
285295 if (result._tag === "Failure") {
286296 const error = Cause.squash(result.cause);
287297 Alert.alert(
......@@ -316,10 +326,15 @@ export function useThreadListActions(): {
316326 }
317327
318328 selectionHaptic();
319 const result = await unsnoozeMutation({
320 environmentId: thread.environmentId,
321 input: { threadId: thread.id, reason: "user" },
322 });
329 const result = await withThreadDismissal(
330 key,
331 () =>
332 unsnoozeMutation({
333 environmentId: thread.environmentId,
334 input: { threadId: thread.id, reason: "user" },
335 }),
336 (result) => result._tag === "Success",
337 );
323338 if (result._tag === "Failure") {
324339 const error = Cause.squash(result.cause);
325340 Alert.alert(
apps/mobile/src/features/threads/thread-list-items.tsx+1
......@@ -725,6 +725,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
725725
726726 return (
727727 <ThreadSwipeable
728 threadKey={`${thread.environmentId}:${thread.id}`}
728729 backgroundColor={backgroundColor}
729730 containerStyle={
730731 compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
apps/mobile/src/features/threads/thread-list-v2-items.tsx+2-2
......@@ -661,7 +661,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
661661 accessibilityLabel: `Settle ${thread.title}`,
662662 icon: "checkmark" as const,
663663 label: "Settle",
664 dismissOnPress: true as const,
665664 onPress: handleSettle,
666665 };
667666 }, [
......@@ -963,6 +962,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
963962 return (
964963 <>
965964 <ThreadSwipeable
965 threadKey={`${thread.environmentId}:${thread.id}`}
966966 backgroundColor={sidebarPane ? drawerColor : screenColor}
967967 compactActions={variant === "slim"}
968968 containerStyle={
......@@ -978,7 +978,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
978978 onSwipeableWillOpen={props.onSwipeableWillOpen}
979979 primaryAction={primaryAction}
980980 secondaryAction={secondaryAction}
981 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}`}
981 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`}
982982 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
983983 threadTitle={thread.title}
984984 >