| author | |
| committer | |
| log | e32dd42f8ba0daa2d0c6d6d2e474b7af6b3eb7b4 |
| tree | ac220230bae1cec9009a56dff6cbc8744ca9f175 |
| parent | c0d4e95c0d921b5960ad04c7f8abec459ee3c59e |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 213 insertions(+), 103 deletions(-)
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+2| ... | ... | @@ -422,6 +422,8 @@ function ArchivedThreadRow(props: { |
| 422 | 422 | ); |
| 423 | 423 | return ( |
| 424 | 424 | <ThreadSwipeable |
| 425 | resetKey={`${props.thread.environmentId}:${props.thread.id}`} | |
| 426 | threadKey={`${props.thread.environmentId}:${props.thread.id}`} | |
| 425 | 427 | backgroundColor={cardColor} |
| 426 | 428 | // Round + clip the swipeable container so the group's corners stay |
| 427 | 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 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { registerThreadDismissal, withThreadDismissal } from "./thread-dismissal"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | interface ThreadDismissal { | |
| 2 | readonly finished: Promise<void>; | |
| 3 | readonly restore: () => void; | |
| 4 | } | |
| 5 | ||
| 6 | const rows = new Map<string, Set<() => ThreadDismissal>>(); | |
| 7 | ||
| 8 | /** A thread can be visible in Home and the navigation sidebar at once. */ | |
| 9 | export 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. */ | |
| 20 | export 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 | 20 | StyleProp, |
| 21 | 21 | ViewStyle, |
| 22 | 22 | } from "react-native"; |
| 23 | import { Alert, Pressable, View } from "react-native"; | |
| 23 | import { Pressable, View } from "react-native"; | |
| 24 | 24 | import ReanimatedSwipeable, { |
| 25 | 25 | type SwipeableMethods, |
| 26 | 26 | } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| ... | ... | @@ -40,6 +40,7 @@ import Animated, { |
| 40 | 40 | } from "react-native-reanimated"; |
| 41 | 41 | |
| 42 | 42 | import { AppText as Text } from "../../components/AppText"; |
| 43 | import { registerThreadDismissal } from "./thread-dismissal"; | |
| 43 | 44 | |
| 44 | 45 | // Wide enough for the longest action label ("Unarchive"). |
| 45 | 46 | const ACTION_ITEM_WIDTH = 58; |
| ... | ... | @@ -68,13 +69,6 @@ interface ThreadSwipeAction { |
| 68 | 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 | 72 | interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { |
| 79 | 73 | readonly tone: "primary" | "secondary" | "danger"; |
| 80 | 74 | } |
| ... | ... | @@ -252,7 +246,8 @@ interface ThreadSwipeableProps { |
| 252 | 246 | readonly onDelete: () => void; |
| 253 | 247 | readonly onSwipeableClose?: (methods: SwipeableMethods) => void; |
| 254 | 248 | readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; |
| 255 | readonly primaryAction: ThreadSwipePrimaryAction; | |
| 249 | readonly primaryAction: ThreadSwipeAction; | |
| 250 | readonly threadKey: string; | |
| 256 | 251 | /** |
| 257 | 252 | * Omitted keeps the v1 destructive Delete action. Explicit null opts out of |
| 258 | 253 | * a secondary action entirely so a gated Snooze can never fall back to an |
| ... | ... | @@ -288,40 +283,35 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 288 | 283 | const close = useCallback(() => swipeableRef.current?.close(), []); |
| 289 | 284 | const gateEnabled = use(SwipeableScrollGateContext); |
| 290 | 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 | 288 | const activeTranslationRef = useRef<SharedValue<number> | null>(null); |
| 293 | 289 | const [isDismissing, setIsDismissing] = useState(false); |
| 294 | 290 | const dismissing = useSharedValue(false); |
| 295 | 291 | const rowHeight = useSharedValue(0); |
| 296 | 292 | const rowWidth = useSharedValue(props.fullSwipeWidth); |
| 297 | 293 | const collapse = useSharedValue(0); |
| 294 | const fallbackTranslation = useSharedValue(0); | |
| 298 | 295 | const actionOpacity = useSharedValue(1); |
| 299 | 296 | const primaryAction = props.primaryAction; |
| 300 | 297 | const onSwipeableClose = props.onSwipeableClose; |
| 301 | 298 | |
| 302 | 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 | 304 | collapse.set(0); |
| 305 | 305 | actionOpacity.set(1); |
| 306 | 306 | dismissing.set(false); |
| 307 | 307 | setIsDismissing(false); |
| 308 | }, [actionOpacity, collapse, dismissing]); | |
| 308 | }, [actionOpacity, collapse, dismissing, fallbackTranslation]); | |
| 309 | 309 | |
| 310 | const finishDismiss = useCallback(async () => { | |
| 311 | const action = pendingDismissRef.current; | |
| 312 | if (!action) return; | |
| 310 | const finishDismiss = useCallback(() => { | |
| 311 | const finish = pendingDismissRef.current; | |
| 313 | 312 | 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 | }, []); | |
| 325 | 315 | |
| 326 | 316 | useLayoutEffect(() => { |
| 327 | 317 | mountedRef.current = true; |
| ... | ... | @@ -329,34 +319,18 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 329 | 319 | mountedRef.current = false; |
| 330 | 320 | cancelAnimation(collapse); |
| 331 | 321 | cancelAnimation(actionOpacity); |
| 322 | cancelAnimation(fallbackTranslation); | |
| 332 | 323 | 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(); | |
| 335 | 326 | }; |
| 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]); | |
| 353 | 328 | |
| 354 | 329 | const dismiss = useCallback( |
| 355 | 330 | (translation: SharedValue<number>) => { |
| 356 | 331 | "worklet"; |
| 357 | 332 | if (dismissing.value) return; |
| 358 | 333 | dismissing.set(true); |
| 359 | runOnJS(beginDismiss)(translation); | |
| 360 | 334 | const timing = { |
| 361 | 335 | duration: 220, |
| 362 | 336 | easing: Easing.out(Easing.cubic), |
| ... | ... | @@ -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 | 370 | const dismissStyle = useAnimatedStyle(() => ({ |
| 381 | 371 | height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, |
| 382 | 372 | pointerEvents: dismissing.value ? "none" : "auto", |
| 383 | 373 | overflow: "hidden", |
| 374 | transform: [{ translateX: fallbackTranslation.value }], | |
| 384 | 375 | })); |
| 385 | 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 | 381 | const handleRelease = useCallback( |
| 388 | 382 | (translation: SharedValue<number>) => { |
| 389 | 383 | "worklet"; |
| 390 | 384 | 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)(); | |
| 397 | 387 | return true; |
| 398 | 388 | } |
| 399 | 389 | return false; |
| 400 | 390 | }, |
| 401 | [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold], | |
| 391 | [commitPrimaryAction, dismissing, fullSwipeAction, fullSwipeThreshold], | |
| 402 | 392 | ); |
| 403 | 393 | const handleFullSwipeArmedChange = useCallback((armed: boolean) => { |
| 404 | 394 | if (armed && !fullSwipeArmedRef.current) { |
| ... | ... | @@ -448,20 +438,21 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 448 | 438 | } |
| 449 | 439 | |
| 450 | 440 | props.onSwipeableWillOpen?.(methods); |
| 451 | if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) { | |
| 441 | if (fullSwipeArmedRef.current && fullSwipeAction !== "primary") { | |
| 452 | 442 | fullSwipeArmedRef.current = false; |
| 453 | 443 | methods.close(); |
| 454 | if (fullSwipeAction === "primary") { | |
| 455 | props.primaryAction.onPress(); | |
| 456 | } else { | |
| 457 | props.onDelete(); | |
| 458 | } | |
| 444 | props.onDelete(); | |
| 459 | 445 | } |
| 460 | 446 | }} |
| 461 | 447 | overshootFriction={1} |
| 462 | 448 | overshootRight |
| 463 | 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 | 456 | <ThreadSwipeActions |
| 466 | 457 | backgroundColor={props.backgroundColor} |
| 467 | 458 | compact={props.compactActions === true} |
| ... | ... | @@ -470,14 +461,7 @@ function ThreadSwipeableRow(props: ThreadSwipeableProps) { |
| 470 | 461 | onFullSwipeArmedChange={handleFullSwipeArmedChange} |
| 471 | 462 | primaryAction={{ |
| 472 | 463 | ...primaryAction, |
| 473 | onPress: () => { | |
| 474 | if (primaryAction.dismissOnPress) { | |
| 475 | runOnUI(dismiss)(translation); | |
| 476 | } else { | |
| 477 | methods.close(); | |
| 478 | primaryAction.onPress(); | |
| 479 | } | |
| 480 | }, | |
| 464 | onPress: commitPrimaryAction, | |
| 481 | 465 | }} |
| 482 | 466 | secondaryAction={resolveSecondaryAction({ |
| 483 | 467 | close: () => methods.close(), |
apps/mobile/src/features/home/useThreadListActions.ts+46-31| ... | ... | @@ -5,6 +5,7 @@ import * as Haptics from "expo-haptics"; |
| 5 | 5 | import { useCallback, useRef } from "react"; |
| 6 | 6 | import { Alert } from "react-native"; |
| 7 | 7 | |
| 8 | import { withThreadDismissal } from "./thread-dismissal"; | |
| 8 | 9 | import { showConfirmDialog } from "../../components/ConfirmDialogHost"; |
| 9 | 10 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 10 | 11 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; |
| ... | ... | @@ -131,26 +132,30 @@ function useThreadActionExecutor( |
| 131 | 132 | ); |
| 132 | 133 | return false; |
| 133 | 134 | } |
| 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 | ); | |
| 154 | 159 | if (result._tag === "Failure") { |
| 155 | 160 | Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); |
| 156 | 161 | return false; |
| ... | ... | @@ -275,13 +280,18 @@ export function useThreadListActions(): { |
| 275 | 280 | } |
| 276 | 281 | |
| 277 | 282 | 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 | ); | |
| 285 | 295 | if (result._tag === "Failure") { |
| 286 | 296 | const error = Cause.squash(result.cause); |
| 287 | 297 | Alert.alert( |
| ... | ... | @@ -316,10 +326,15 @@ export function useThreadListActions(): { |
| 316 | 326 | } |
| 317 | 327 | |
| 318 | 328 | 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 | ); | |
| 323 | 338 | if (result._tag === "Failure") { |
| 324 | 339 | const error = Cause.squash(result.cause); |
| 325 | 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 | 725 | |
| 726 | 726 | return ( |
| 727 | 727 | <ThreadSwipeable |
| 728 | threadKey={`${thread.environmentId}:${thread.id}`} | |
| 728 | 729 | backgroundColor={backgroundColor} |
| 729 | 730 | containerStyle={ |
| 730 | 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 | 661 | accessibilityLabel: `Settle ${thread.title}`, |
| 662 | 662 | icon: "checkmark" as const, |
| 663 | 663 | label: "Settle", |
| 664 | dismissOnPress: true as const, | |
| 665 | 664 | onPress: handleSettle, |
| 666 | 665 | }; |
| 667 | 666 | }, [ |
| ... | ... | @@ -963,6 +962,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 963 | 962 | return ( |
| 964 | 963 | <> |
| 965 | 964 | <ThreadSwipeable |
| 965 | threadKey={`${thread.environmentId}:${thread.id}`} | |
| 966 | 966 | backgroundColor={sidebarPane ? drawerColor : screenColor} |
| 967 | 967 | compactActions={variant === "slim"} |
| 968 | 968 | containerStyle={ |
| ... | ... | @@ -978,7 +978,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 978 | 978 | onSwipeableWillOpen={props.onSwipeableWillOpen} |
| 979 | 979 | primaryAction={primaryAction} |
| 980 | 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 | 982 | simultaneousWithExternalGesture={props.simultaneousSwipeGesture} |
| 983 | 983 | threadTitle={thread.title} |
| 984 | 984 | > |