| 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 | } |