| 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 | 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; |
| 2 | 2 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 3 | 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 | 5 | import { |
| 6 | 6 | type EnvironmentProject, |
| 7 | 7 | type EnvironmentThreadShell, |
| ... | ... | @@ -18,7 +18,14 @@ import { |
| 18 | 18 | import { useAtomValue } from "@effect/atom-react"; |
| 19 | 19 | import { useFocusEffect } from "@react-navigation/native"; |
| 20 | 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 | 29 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 23 | 30 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 31 | |
| ... | ... | @@ -59,6 +66,7 @@ import { |
| 59 | 66 | sortHomeProjectScopes, |
| 60 | 67 | type HomeProjectSortOrder, |
| 61 | 68 | } from "./homeThreadList"; |
| 69 | import { createSwipeRowActivation } from "./swipe-row-activation"; | |
| 62 | 70 | import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; |
| 63 | 71 | import { useMaterialFabScroll } from "./MaterialFabScrollContext"; |
| 64 | 72 | |
| ... | ... | @@ -266,8 +274,45 @@ export function HomeScreen(props: HomeScreenProps) { |
| 266 | 274 | openSwipeableRef.current?.close(); |
| 267 | 275 | }, []); |
| 268 | 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 | 314 | const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ |
| 270 | onScroll: onMaterialFabScroll, | |
| 315 | onScroll: handleListScroll, | |
| 271 | 316 | onScrollBeginDrag: handleScrollBeginDrag, |
| 272 | 317 | }); |
| 273 | 318 | |
| ... | ... | @@ -657,6 +702,9 @@ export function HomeScreen(props: HomeScreenProps) { |
| 657 | 702 | ); |
| 658 | 703 | |
| 659 | 704 | useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); |
| 705 | useEffect(() => { | |
| 706 | if (swipeEnabled) activateVisibleRows(threadListV2Items); | |
| 707 | }, [activateVisibleRows, swipeEnabled, threadListV2Items]); | |
| 660 | 708 | |
| 661 | 709 | const renderV2Item = useCallback( |
| 662 | 710 | ({ item }: { readonly item: ThreadListV2ListItem }) => { |
| ... | ... | @@ -764,6 +812,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 764 | 812 | onMoveThread={handleMoveThread} |
| 765 | 813 | onSwipeableClose={handleSwipeableClose} |
| 766 | 814 | onSwipeableWillOpen={handleSwipeableWillOpen} |
| 815 | activationKey={item.key} | |
| 767 | 816 | /> |
| 768 | 817 | ); |
| 769 | 818 | }, |
| ... | ... | @@ -952,8 +1001,13 @@ export function HomeScreen(props: HomeScreenProps) { |
| 952 | 1001 | {/* Shared with the iPad sidebar: cells are reused across data |
| 953 | 1002 | rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated |
| 954 | 1003 | shell update) from re-rendering untouched rows. */} |
| 955 | <SwipeableScrollGateProvider enabled={swipeEnabled}> | |
| 1004 | <SwipeableScrollGateProvider enabled={swipeEnabled} activation={swipeRowActivation}> | |
| 956 | 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 | 1011 | data={threadListV2Items} |
| 958 | 1012 | renderItem={renderV2Item} |
| 959 | 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 | 40 | } from "react-native-reanimated"; |
| 41 | 41 | |
| 42 | 42 | import { AppText as Text } from "../../components/AppText"; |
| 43 | import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; | |
| 43 | 44 | import { registerThreadDismissal } from "./thread-dismissal"; |
| 44 | 45 | |
| 45 | 46 | // Wide enough for the longest action label ("Unarchive"). |
| ... | ... | @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); |
| 129 | 130 | |
| 130 | 131 | export function SwipeableScrollGateProvider(props: { |
| 131 | 132 | readonly enabled: boolean; |
| 133 | readonly activation?: SwipeRowActivation; | |
| 132 | 134 | readonly children: ReactNode; |
| 133 | 135 | }) { |
| 134 | 136 | return ( |
| 135 | 137 | <SwipeableScrollGateContext.Provider value={props.enabled}> |
| 136 | {props.children} | |
| 138 | <SwipeRowActivationContext value={props.activation ?? null}> | |
| 139 | {props.children} | |
| 140 | </SwipeRowActivationContext> | |
| 137 | 141 | </SwipeableScrollGateContext.Provider> |
| 138 | 142 | ); |
| 139 | 143 | } |
| ... | ... | @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { |
| 260 | 264 | * open/mid-drag state can't leak onto another row. |
| 261 | 265 | */ |
| 262 | 266 | readonly resetKey?: string; |
| 267 | /** Paints the row without swipe machinery; see swipe-row-activation. */ | |
| 268 | readonly dormant?: boolean; | |
| 263 | 269 | readonly simultaneousWithExternalGesture?: ComponentProps< |
| 264 | 270 | typeof ReanimatedSwipeable |
| 265 | 271 | >["simultaneousWithExternalGesture"]; |
| 266 | 272 | readonly threadTitle: string; |
| 267 | 273 | } |
| 268 | 274 | |
| 275 | const closeDormant = () => {}; | |
| 276 | ||
| 269 | 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 | 293 | // Recycled content gets fresh native and animation state. Late callbacks |
| 271 | 294 | // from the previous row retain its action, never the replacement's action. |
| 272 | 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 | 35 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 36 | 36 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 37 | 37 | import { useThreadPr } from "../../state/use-thread-pr"; |
| 38 | import { useSwipeRowDormant } from "../home/swipe-row-activation"; | |
| 38 | 39 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 39 | 40 | import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; |
| 40 | 41 | import { |
| ... | ... | @@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 518 | 519 | readonly canMoveDown?: boolean; |
| 519 | 520 | readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; |
| 520 | 521 | readonly onSwipeableClose: (methods: SwipeableMethods) => void; |
| 522 | /** List key checked against the Home swipe row activation. */ | |
| 523 | readonly activationKey?: string; | |
| 521 | 524 | readonly searchMatch?: EnvironmentThreadSearchMatch; |
| 522 | 525 | readonly searchQuery?: string; |
| 523 | 526 | readonly simultaneousSwipeGesture?: ComponentProps< |
| ... | ... | @@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 545 | 548 | } = props; |
| 546 | 549 | const snoozedRow = props.snoozed === true; |
| 547 | 550 | const pinnedRow = props.pinned === true; |
| 551 | const dormant = useSwipeRowDormant(props.activationKey); | |
| 548 | 552 | |
| 549 | 553 | const pr = useThreadPr(thread); |
| 550 | 554 | |
| ... | ... | @@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1180 | 1184 | <CustomSnoozeSheet onClose={() => setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> |
| 1181 | 1185 | )} |
| 1182 | 1186 | <ThreadSwipeable |
| 1187 | dormant={dormant} | |
| 1183 | 1188 | threadKey={`${thread.environmentId}:${thread.id}`} |
| 1184 | 1189 | backgroundColor={rowAppearance.swipeBackgroundColor} |
| 1185 | 1190 | compactActions={variant === "slim"} |