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: {...@@ -422,6 +422,8 @@ function ArchivedThreadRow(props: {
422 );422 );
423 return (423 return (
424 <ThreadSwipeable424 <ThreadSwipeable
425 resetKey={`${props.thread.environmentId}:${props.thread.id}`}
426 threadKey={`${props.thread.environmentId}:${props.thread.id}`}
425 backgroundColor={cardColor}427 backgroundColor={cardColor}
426 // Round + clip the swipeable container so the group's corners stay428 // Round + clip the swipeable container so the group's corners stay
427 // rounded while rows swipe; the row itself stays square inside.429 // 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 {...@@ -20,7 +20,7 @@ import type {
20 StyleProp,20 StyleProp,
21 ViewStyle,21 ViewStyle,
22} from "react-native";22} from "react-native";
23import { Alert, Pressable, View } from "react-native";23import { Pressable, View } from "react-native";
24import ReanimatedSwipeable, {24import 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";
4141
42import { AppText as Text } from "../../components/AppText";42import { AppText as Text } from "../../components/AppText";
43import { registerThreadDismissal } from "./thread-dismissal";
4344
44// Wide enough for the longest action label ("Unarchive").45// Wide enough for the longest action label ("Unarchive").
45const ACTION_ITEM_WIDTH = 58;46const 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}
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
78interface ThreadSwipeSecondaryAction extends ThreadSwipeAction {72interface 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 of252 * 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 an253 * 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;
301298
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]);
309309
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]);
325315
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 );
353328
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 }
449439
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 overshootRight448 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 <ThreadSwipeActions456 <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(),
apps/mobile/src/features/home/useThreadListActions.ts+46-31
...@@ -5,6 +5,7 @@ import * as Haptics from "expo-haptics";...@@ -5,6 +5,7 @@ import * as Haptics from "expo-haptics";
5import { useCallback, useRef } from "react";5import { useCallback, useRef } from "react";
6import { Alert } from "react-native";6import { Alert } from "react-native";
77
8import { withThreadDismissal } from "./thread-dismissal";
8import { showConfirmDialog } from "../../components/ConfirmDialogHost";9import { showConfirmDialog } from "../../components/ConfirmDialogHost";
9import { scopedThreadKey } from "../../lib/scopedEntities";10import { scopedThreadKey } from "../../lib/scopedEntities";
10import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";11import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
...@@ -131,26 +132,30 @@ function useThreadActionExecutor(...@@ -131,26 +132,30 @@ function useThreadActionExecutor(
131 );132 );
132 return false;133 return false;
133 }134 }
134 const result =135 const result = await withThreadDismissal(
135 action === "unsettle"136 key,
136 ? // reason "user" pins the thread active: auto-settle stays137 async () =>
137 // suppressed until real activity clears the pin server-side.138 action === "unsettle"
138 await unsettleMutation({139 ? // reason "user" pins the thread active: auto-settle stays
139 environmentId: thread.environmentId,140 // suppressed until real activity clears the pin server-side.
140 input: { threadId: thread.id, reason: "user" },141 await unsettleMutation({
141 })142 environmentId: thread.environmentId,
142 : await (143 input: { threadId: thread.id, reason: "user" },
143 action === "settle"144 })
144 ? settleMutation145 : await (
145 : action === "archive"146 action === "settle"
146 ? archiveMutation147 ? settleMutation
147 : action === "unarchive"148 : action === "archive"
148 ? unarchiveMutation149 ? archiveMutation
149 : deleteMutation150 : action === "unarchive"
150 )({151 ? unarchiveMutation
151 environmentId: thread.environmentId,152 : deleteMutation
152 input: { threadId: thread.id },153 )({
153 });154 environmentId: thread.environmentId,
155 input: { threadId: thread.id },
156 }),
157 (result) => result._tag === "Success",
158 );
154 if (result._tag === "Failure") {159 if (result._tag === "Failure") {
155 Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));160 Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));
156 return false;161 return false;
...@@ -275,13 +280,18 @@ export function useThreadListActions(): {...@@ -275,13 +280,18 @@ export function useThreadListActions(): {
275 }280 }
276281
277 selectionHaptic();282 selectionHaptic();
278 const result = await snoozeMutation({283 const result = await withThreadDismissal(
279 environmentId: thread.environmentId,284 key,
280 input: {285 () =>
281 threadId: thread.id,286 snoozeMutation({
282 snoozedUntil,287 environmentId: thread.environmentId,
283 },288 input: {
284 });289 threadId: thread.id,
290 snoozedUntil,
291 },
292 }),
293 (result) => result._tag === "Success",
294 );
285 if (result._tag === "Failure") {295 if (result._tag === "Failure") {
286 const error = Cause.squash(result.cause);296 const error = Cause.squash(result.cause);
287 Alert.alert(297 Alert.alert(
...@@ -316,10 +326,15 @@ export function useThreadListActions(): {...@@ -316,10 +326,15 @@ export function useThreadListActions(): {
316 }326 }
317327
318 selectionHaptic();328 selectionHaptic();
319 const result = await unsnoozeMutation({329 const result = await withThreadDismissal(
320 environmentId: thread.environmentId,330 key,
321 input: { threadId: thread.id, reason: "user" },331 () =>
322 });332 unsnoozeMutation({
333 environmentId: thread.environmentId,
334 input: { threadId: thread.id, reason: "user" },
335 }),
336 (result) => result._tag === "Success",
337 );
323 if (result._tag === "Failure") {338 if (result._tag === "Failure") {
324 const error = Cause.squash(result.cause);339 const error = Cause.squash(result.cause);
325 Alert.alert(340 Alert.alert(
apps/mobile/src/features/threads/thread-list-items.tsx+1
...@@ -725,6 +725,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {...@@ -725,6 +725,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
725725
726 return (726 return (
727 <ThreadSwipeable727 <ThreadSwipeable
728 threadKey={`${thread.environmentId}:${thread.id}`}
728 backgroundColor={backgroundColor}729 backgroundColor={backgroundColor}
729 containerStyle={730 containerStyle={
730 compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }731 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: {...@@ -661,7 +661,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
661 accessibilityLabel: `Settle ${thread.title}`,661 accessibilityLabel: `Settle ${thread.title}`,
662 icon: "checkmark" as const,662 icon: "checkmark" as const,
663 label: "Settle",663 label: "Settle",
664 dismissOnPress: true as const,
665 onPress: handleSettle,664 onPress: handleSettle,
666 };665 };
667 }, [666 }, [
...@@ -963,6 +962,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -963,6 +962,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
963 return (962 return (
964 <>963 <>
965 <ThreadSwipeable964 <ThreadSwipeable
965 threadKey={`${thread.environmentId}:${thread.id}`}
966 backgroundColor={sidebarPane ? drawerColor : screenColor}966 backgroundColor={sidebarPane ? drawerColor : screenColor}
967 compactActions={variant === "slim"}967 compactActions={variant === "slim"}
968 containerStyle={968 containerStyle={
...@@ -978,7 +978,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -978,7 +978,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
978 onSwipeableWillOpen={props.onSwipeableWillOpen}978 onSwipeableWillOpen={props.onSwipeableWillOpen}
979 primaryAction={primaryAction}979 primaryAction={primaryAction}
980 secondaryAction={secondaryAction}980 secondaryAction={secondaryAction}
981 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}`}981 resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}`}
982 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}982 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
983 threadTitle={thread.title}983 threadTitle={thread.title}
984 >984 >