| author | |
| committer | |
| log | 525af2d1ad4b6de847ea658c37a081fd7afe8ef6 |
| tree | 5494516b467caf85598d877065ebb51a23e4e88f |
| parent | ed809f7ad274ebccdfbf029ccfd7ff2582fd486a |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 217 insertions(+), 5 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+58-4| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; |
| 2 | import type { ThreadMoveDestination } from "../threads/threadOrder"; | 2 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 3 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; | 3 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; |
| 4 | import { LegendList } from "@legendapp/list/react-native"; | 4 | import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; |
| 5 | import { | 5 | import { |
| 6 | type EnvironmentProject, | 6 | type EnvironmentProject, |
| 7 | type EnvironmentThreadShell, | 7 | type EnvironmentThreadShell, |
| ... | @@ -18,7 +18,14 @@ import { | ... | @@ -18,7 +18,14 @@ import { |
| 18 | import { useAtomValue } from "@effect/atom-react"; | 18 | import { useAtomValue } from "@effect/atom-react"; |
| 19 | import { useFocusEffect } from "@react-navigation/native"; | 19 | import { useFocusEffect } from "@react-navigation/native"; |
| 20 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 20 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 21 | import { ActivityIndicator, Platform, View } from "react-native"; | 21 | import { |
| 22 | ActivityIndicator, | ||
| 23 | Platform, | ||
| 24 | View, | ||
| 25 | type GestureResponderEvent, | ||
| 26 | type NativeScrollEvent, | ||
| 27 | type NativeSyntheticEvent, | ||
| 28 | } from "react-native"; | ||
| 22 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 29 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 30 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 31 | ||
| ... | @@ -59,6 +66,7 @@ import { | ... | @@ -59,6 +66,7 @@ import { |
| 59 | sortHomeProjectScopes, | 66 | sortHomeProjectScopes, |
| 60 | type HomeProjectSortOrder, | 67 | type HomeProjectSortOrder, |
| 61 | } from "./homeThreadList"; | 68 | } from "./homeThreadList"; |
| 69 | import { createSwipeRowActivation } from "./swipe-row-activation"; | ||
| 62 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; | 70 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 63 | import { useMaterialFabScroll } from "./MaterialFabScrollContext"; | 71 | import { useMaterialFabScroll } from "./MaterialFabScrollContext"; |
| 64 | 72 | ||
| ... | @@ -266,8 +274,45 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -266,8 +274,45 @@ export function HomeScreen(props: HomeScreenProps) { |
| 266 | openSwipeableRef.current?.close(); | 274 | openSwipeableRef.current?.close(); |
| 267 | }, []); | 275 | }, []); |
| 268 | const onMaterialFabScroll = useMaterialFabScroll(); | 276 | const onMaterialFabScroll = useMaterialFabScroll(); |
| 277 | const listRef = useRef<LegendListRef>(null); | ||
| 278 | const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); | ||
| 279 | const activateVisibleRows = useCallback( | ||
| 280 | (rows: ReadonlyArray<ThreadListV2ListItem>) => { | ||
| 281 | const state = listRef.current?.getState(); | ||
| 282 | if (state === undefined || !(state.end >= 0)) return; | ||
| 283 | swipeRowActivation.activate( | ||
| 284 | rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), | ||
| 285 | ); | ||
| 286 | }, | ||
| 287 | [swipeRowActivation], | ||
| 288 | ); | ||
| 289 | // Status-bar, accessibility and programmatic scrolls never arm the scroll | ||
| 290 | // gate, so every scroll also activates the visible rows once it settles. | ||
| 291 | const activationTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); | ||
| 292 | useEffect(() => () => clearTimeout(activationTimerRef.current), []); | ||
| 293 | const handleListScroll = useCallback( | ||
| 294 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 295 | onMaterialFabScroll?.(event); | ||
| 296 | clearTimeout(activationTimerRef.current); | ||
| 297 | activationTimerRef.current = setTimeout( | ||
| 298 | () => activateVisibleRows(listRef.current?.getState().data ?? []), | ||
| 299 | 200, | ||
| 300 | ); | ||
| 301 | }, | ||
| 302 | [activateVisibleRows, onMaterialFabScroll], | ||
| 303 | ); | ||
| 304 | const trackListTouches = useCallback( | ||
| 305 | (event: GestureResponderEvent, started: boolean) => { | ||
| 306 | const { changedTouches, touches } = event.nativeEvent; | ||
| 307 | swipeRowActivation.trackTouches( | ||
| 308 | started ? changedTouches.map((touch) => touch.identifier) : [], | ||
| 309 | touches.map((touch) => touch.identifier), | ||
| 310 | ); | ||
| 311 | }, | ||
| 312 | [swipeRowActivation], | ||
| 313 | ); | ||
| 269 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ | 314 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ |
| 270 | onScroll: onMaterialFabScroll, | 315 | onScroll: handleListScroll, |
| 271 | onScrollBeginDrag: handleScrollBeginDrag, | 316 | onScrollBeginDrag: handleScrollBeginDrag, |
| 272 | }); | 317 | }); |
| 273 | 318 | ||
| ... | @@ -657,6 +702,9 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -657,6 +702,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 657 | ); | 702 | ); |
| 658 | 703 | ||
| 659 | useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); | 704 | useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); |
| 705 | useEffect(() => { | ||
| 706 | if (swipeEnabled) activateVisibleRows(threadListV2Items); | ||
| 707 | }, [activateVisibleRows, swipeEnabled, threadListV2Items]); | ||
| 660 | 708 | ||
| 661 | const renderV2Item = useCallback( | 709 | const renderV2Item = useCallback( |
| 662 | ({ item }: { readonly item: ThreadListV2ListItem }) => { | 710 | ({ item }: { readonly item: ThreadListV2ListItem }) => { |
| ... | @@ -764,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -764,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 764 | onMoveThread={handleMoveThread} | 812 | onMoveThread={handleMoveThread} |
| 765 | onSwipeableClose={handleSwipeableClose} | 813 | onSwipeableClose={handleSwipeableClose} |
| 766 | onSwipeableWillOpen={handleSwipeableWillOpen} | 814 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| 815 | activationKey={item.key} | ||
| 767 | /> | 816 | /> |
| 768 | ); | 817 | ); |
| 769 | }, | 818 | }, |
| ... | @@ -952,8 +1001,13 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -952,8 +1001,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 952 | {/* Shared with the iPad sidebar: cells are reused across data | 1001 | {/* Shared with the iPad sidebar: cells are reused across data |
| 953 | rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated | 1002 | rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated |
| 954 | shell update) from re-rendering untouched rows. */} | 1003 | shell update) from re-rendering untouched rows. */} |
| 955 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | 1004 | <SwipeableScrollGateProvider enabled={swipeEnabled} activation={swipeRowActivation}> |
| 956 | <LegendList | 1005 | <LegendList |
| 1006 | ref={listRef} | ||
| 1007 | onLoad={() => activateVisibleRows(threadListV2Items)} | ||
| 1008 | onTouchStart={(event) => trackListTouches(event, true)} | ||
| 1009 | onTouchEnd={(event) => trackListTouches(event, false)} | ||
| 1010 | onTouchCancel={(event) => trackListTouches(event, false)} | ||
| 957 | data={threadListV2Items} | 1011 | data={threadListV2Items} |
| 958 | renderItem={renderV2Item} | 1012 | renderItem={renderV2Item} |
| 959 | keyExtractor={v2KeyExtractor} | 1013 | keyExtractor={v2KeyExtractor} |
apps/mobile/src/features/home/swipe-row-activation.test.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { createSwipeRowActivation } from "./swipe-row-activation"; | ||
| 4 | |||
| 5 | describe("createSwipeRowActivation", () => { | ||
| 6 | it("activates exactly the requested rows and notifies only on change", () => { | ||
| 7 | const activation = createSwipeRowActivation(); | ||
| 8 | const listener = vi.fn(); | ||
| 9 | activation.subscribe(listener); | ||
| 10 | |||
| 11 | activation.activate(["a", "b"]); | ||
| 12 | activation.activate(["b", "a"]); | ||
| 13 | |||
| 14 | expect(activation.isActive("a")).toBe(true); | ||
| 15 | expect(activation.isActive("c")).toBe(false); | ||
| 16 | expect(listener).toHaveBeenCalledTimes(1); | ||
| 17 | |||
| 18 | activation.activate(["c"]); | ||
| 19 | expect(activation.isActive("a")).toBe(false); | ||
| 20 | expect(activation.isActive("c")).toBe(true); | ||
| 21 | expect(listener).toHaveBeenCalledTimes(2); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("defers changes while a finger is on the list so a press is never remounted", () => { | ||
| 25 | const activation = createSwipeRowActivation(); | ||
| 26 | activation.activate(["a"]); | ||
| 27 | |||
| 28 | activation.trackTouches(["1"], ["1"]); | ||
| 29 | activation.activate(["b"]); | ||
| 30 | activation.activate(["c"]); | ||
| 31 | expect(activation.isActive("a")).toBe(true); | ||
| 32 | expect(activation.isActive("c")).toBe(false); | ||
| 33 | |||
| 34 | activation.trackTouches([], []); | ||
| 35 | expect(activation.isActive("a")).toBe(false); | ||
| 36 | expect(activation.isActive("b")).toBe(false); | ||
| 37 | expect(activation.isActive("c")).toBe(true); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("ignores fingers that did not start on the list", () => { | ||
| 41 | const activation = createSwipeRowActivation(); | ||
| 42 | activation.trackTouches(["1"], ["1", "2"]); | ||
| 43 | activation.activate(["a"]); | ||
| 44 | |||
| 45 | // The list finger lifts while finger 2 stays on another control. | ||
| 46 | activation.trackTouches([], ["2"]); | ||
| 47 | expect(activation.isActive("a")).toBe(true); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("drops a list finger whose end event never arrived", () => { | ||
| 51 | const activation = createSwipeRowActivation(); | ||
| 52 | activation.trackTouches(["1"], ["1"]); | ||
| 53 | activation.activate(["a"]); | ||
| 54 | |||
| 55 | activation.trackTouches(["2"], ["2"]); | ||
| 56 | activation.trackTouches([], []); | ||
| 57 | expect(activation.isActive("a")).toBe(true); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("stops notifying after unsubscribe", () => { | ||
| 61 | const activation = createSwipeRowActivation(); | ||
| 62 | const listener = vi.fn(); | ||
| 63 | const unsubscribe = activation.subscribe(listener); | ||
| 64 | unsubscribe(); | ||
| 65 | activation.activate(["a"]); | ||
| 66 | expect(listener).not.toHaveBeenCalled(); | ||
| 67 | }); | ||
| 68 | }); | ||
apps/mobile/src/features/home/swipe-row-activation.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { createContext, use, useSyncExternalStore } from "react"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Full swipe rows (pan gesture, animated actions, hidden action buttons) only | ||
| 5 | * exist around the viewport. Every other Home row renders a dormant frame that | ||
| 6 | * paints the same content with a fraction of the native views, so a row the | ||
| 7 | * list rebuilds while scrolling is cheap. The scroll gate already disables | ||
| 8 | * swipes while the list moves, so rows are activated once it rests. | ||
| 9 | */ | ||
| 10 | export function createSwipeRowActivation() { | ||
| 11 | let activeKeys = new Set<string>(); | ||
| 12 | // Swapping a row's frame remounts it, which would cancel a press or long | ||
| 13 | // press in progress, so changes wait until every finger that started on the | ||
| 14 | // list has lifted. | ||
| 15 | const listTouches = new Set<string>(); | ||
| 16 | let pendingKeys: ReadonlyArray<string> | null = null; | ||
| 17 | const listeners = new Set<() => void>(); | ||
| 18 | const apply = (keys: ReadonlyArray<string>) => { | ||
| 19 | if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; | ||
| 20 | activeKeys = new Set(keys); | ||
| 21 | for (const listener of listeners) listener(); | ||
| 22 | }; | ||
| 23 | return { | ||
| 24 | subscribe(listener: () => void) { | ||
| 25 | listeners.add(listener); | ||
| 26 | return () => void listeners.delete(listener); | ||
| 27 | }, | ||
| 28 | isActive: (key: string) => activeKeys.has(key), | ||
| 29 | activate(keys: ReadonlyArray<string>) { | ||
| 30 | if (listTouches.size > 0) pendingKeys = keys; | ||
| 31 | else apply(keys); | ||
| 32 | }, | ||
| 33 | /** | ||
| 34 | * `started` are touches that just began on the list; `onScreen` is every | ||
| 35 | * finger still down anywhere. A finger on another control never holds | ||
| 36 | * changes, and one whose end event went missing is dropped here. | ||
| 37 | */ | ||
| 38 | trackTouches(started: ReadonlyArray<string>, onScreen: ReadonlyArray<string>) { | ||
| 39 | for (const id of started) listTouches.add(id); | ||
| 40 | for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); | ||
| 41 | if (listTouches.size > 0 || pendingKeys === null) return; | ||
| 42 | const keys = pendingKeys; | ||
| 43 | pendingKeys = null; | ||
| 44 | apply(keys); | ||
| 45 | }, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | |||
| 49 | export type SwipeRowActivation = ReturnType<typeof createSwipeRowActivation>; | ||
| 50 | |||
| 51 | export const SwipeRowActivationContext = createContext<SwipeRowActivation | null>(null); | ||
| 52 | |||
| 53 | const subscribeNever = () => () => {}; | ||
| 54 | |||
| 55 | /** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ | ||
| 56 | export function useSwipeRowDormant(key: string | undefined): boolean { | ||
| 57 | const activation = use(SwipeRowActivationContext); | ||
| 58 | return useSyncExternalStore( | ||
| 59 | activation?.subscribe ?? subscribeNever, | ||
| 60 | () => activation !== null && key !== undefined && !activation.isActive(key), | ||
| 61 | ); | ||
| 62 | } | ||
apps/mobile/src/features/home/thread-swipe-actions.tsx+24-1| ... | @@ -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 { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; | ||
| 43 | import { registerThreadDismissal } from "./thread-dismissal"; | 44 | import { registerThreadDismissal } from "./thread-dismissal"; |
| 44 | 45 | ||
| 45 | // Wide enough for the longest action label ("Unarchive"). | 46 | // Wide enough for the longest action label ("Unarchive"). |
| ... | @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); | ... | @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); |
| 129 | 130 | ||
| 130 | export function SwipeableScrollGateProvider(props: { | 131 | export function SwipeableScrollGateProvider(props: { |
| 131 | readonly enabled: boolean; | 132 | readonly enabled: boolean; |
| 133 | readonly activation?: SwipeRowActivation; | ||
| 132 | readonly children: ReactNode; | 134 | readonly children: ReactNode; |
| 133 | }) { | 135 | }) { |
| 134 | return ( | 136 | return ( |
| 135 | <SwipeableScrollGateContext.Provider value={props.enabled}> | 137 | <SwipeableScrollGateContext.Provider value={props.enabled}> |
| 136 | {props.children} | 138 | <SwipeRowActivationContext value={props.activation ?? null}> |
| 139 | {props.children} | ||
| 140 | </SwipeRowActivationContext> | ||
| 137 | </SwipeableScrollGateContext.Provider> | 141 | </SwipeableScrollGateContext.Provider> |
| 138 | ); | 142 | ); |
| 139 | } | 143 | } |
| ... | @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { | ... | @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { |
| 260 | * open/mid-drag state can't leak onto another row. | 264 | * open/mid-drag state can't leak onto another row. |
| 261 | */ | 265 | */ |
| 262 | readonly resetKey?: string; | 266 | readonly resetKey?: string; |
| 267 | /** Paints the row without swipe machinery; see swipe-row-activation. */ | ||
| 268 | readonly dormant?: boolean; | ||
| 263 | readonly simultaneousWithExternalGesture?: ComponentProps< | 269 | readonly simultaneousWithExternalGesture?: ComponentProps< |
| 264 | typeof ReanimatedSwipeable | 270 | typeof ReanimatedSwipeable |
| 265 | >["simultaneousWithExternalGesture"]; | 271 | >["simultaneousWithExternalGesture"]; |
| 266 | readonly threadTitle: string; | 272 | readonly threadTitle: string; |
| 267 | } | 273 | } |
| 268 | 274 | ||
| 275 | const closeDormant = () => {}; | ||
| 276 | |||
| 269 | export function ThreadSwipeable(props: ThreadSwipeableProps) { | 277 | export function ThreadSwipeable(props: ThreadSwipeableProps) { |
| 278 | if (props.dormant) { | ||
| 279 | // Mirrors ReanimatedSwipeable's container and children views. | ||
| 280 | return ( | ||
| 281 | <View | ||
| 282 | style={[ | ||
| 283 | { overflow: "hidden", backgroundColor: props.backgroundColor }, | ||
| 284 | props.containerStyle, | ||
| 285 | ]} | ||
| 286 | > | ||
| 287 | <View style={{ backgroundColor: props.backgroundColor }}> | ||
| 288 | {props.children(closeDormant)} | ||
| 289 | </View> | ||
| 290 | </View> | ||
| 291 | ); | ||
| 292 | } | ||
| 270 | // Recycled content gets fresh native and animation state. Late callbacks | 293 | // Recycled content gets fresh native and animation state. Late callbacks |
| 271 | // from the previous row retain its action, never the replacement's action. | 294 | // from the previous row retain its action, never the replacement's action. |
| 272 | return <ThreadSwipeableRow key={props.resetKey} {...props} />; | 295 | return <ThreadSwipeableRow key={props.resetKey} {...props} />; |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+5| ... | @@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ... | @@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 35 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 35 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 36 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 36 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 37 | import { useThreadPr } from "../../state/use-thread-pr"; | 37 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 38 | import { useSwipeRowDormant } from "../home/swipe-row-activation"; | ||
| 38 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; | 39 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 39 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; | 40 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 40 | import { | 41 | import { |
| ... | @@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 518 | readonly canMoveDown?: boolean; | 519 | readonly canMoveDown?: boolean; |
| 519 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; | 520 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 520 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; | 521 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 522 | /** List key checked against the Home swipe row activation. */ | ||
| 523 | readonly activationKey?: string; | ||
| 521 | readonly searchMatch?: EnvironmentThreadSearchMatch; | 524 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 522 | readonly searchQuery?: string; | 525 | readonly searchQuery?: string; |
| 523 | readonly simultaneousSwipeGesture?: ComponentProps< | 526 | readonly simultaneousSwipeGesture?: ComponentProps< |
| ... | @@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 545 | } = props; | 548 | } = props; |
| 546 | const snoozedRow = props.snoozed === true; | 549 | const snoozedRow = props.snoozed === true; |
| 547 | const pinnedRow = props.pinned === true; | 550 | const pinnedRow = props.pinned === true; |
| 551 | const dormant = useSwipeRowDormant(props.activationKey); | ||
| 548 | 552 | ||
| 549 | const pr = useThreadPr(thread); | 553 | const pr = useThreadPr(thread); |
| 550 | 554 | ||
| ... | @@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1180 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> | 1184 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> |
| 1181 | )} | 1185 | )} |
| 1182 | <ThreadSwipeable | 1186 | <ThreadSwipeable |
| 1187 | dormant={dormant} | ||
| 1183 | threadKey={`${thread.environmentId}:${thread.id}`} | 1188 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 1184 | backgroundColor={rowAppearance.swipeBackgroundColor} | 1189 | backgroundColor={rowAppearance.swipeBackgroundColor} |
| 1185 | compactActions={variant === "slim"} | 1190 | compactActions={variant === "slim"} |