@oxy.so/bloom 4.24.0 → 4.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/docs/adoption-matrix.mdx +6 -6
  2. package/docs/ai-chat.mdx +1 -1
  3. package/docs/context-menu.mdx +2 -0
  4. package/lib/commonjs/ai-chat/AiChatContainer.js +60 -7
  5. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  6. package/lib/commonjs/ai-chat/use-panel-interaction.js +6 -1
  7. package/lib/commonjs/ai-chat/use-panel-interaction.js.map +1 -1
  8. package/lib/commonjs/bottom-bar/BottomBarBase.js +5 -2
  9. package/lib/commonjs/bottom-bar/BottomBarBase.js.map +1 -1
  10. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +6 -2
  11. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  12. package/lib/commonjs/bottom-sheet/context.js +29 -0
  13. package/lib/commonjs/bottom-sheet/context.js.map +1 -0
  14. package/lib/commonjs/composer-panel/ComposerPanelBase.js +32 -11
  15. package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
  16. package/lib/commonjs/composer-panel/ComposerPopover.js +13 -5
  17. package/lib/commonjs/composer-panel/ComposerPopover.js.map +1 -1
  18. package/lib/commonjs/composer-panel/ModelPickerBase.js +13 -2
  19. package/lib/commonjs/composer-panel/ModelPickerBase.js.map +1 -1
  20. package/lib/commonjs/context-menu/ContextMenu.js +10 -2
  21. package/lib/commonjs/context-menu/ContextMenu.js.map +1 -1
  22. package/lib/commonjs/dialog/DialogBottomSheet.js +27 -6
  23. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  24. package/lib/commonjs/floating/LongPressArea.js +107 -0
  25. package/lib/commonjs/floating/LongPressArea.js.map +1 -0
  26. package/lib/commonjs/settings-modal/SettingsModal.js +8 -1
  27. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  28. package/lib/commonjs/settings-modal/use-keyboard-reveal.js +76 -0
  29. package/lib/commonjs/settings-modal/use-keyboard-reveal.js.map +1 -0
  30. package/lib/commonjs/sidebar/Sidebar.js +6 -2
  31. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  32. package/lib/commonjs/sidebar/SidebarItem.js +5 -1
  33. package/lib/commonjs/sidebar/SidebarItem.js.map +1 -1
  34. package/lib/module/ai-chat/AiChatContainer.js +60 -7
  35. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  36. package/lib/module/ai-chat/use-panel-interaction.js +7 -2
  37. package/lib/module/ai-chat/use-panel-interaction.js.map +1 -1
  38. package/lib/module/bottom-bar/BottomBarBase.js +5 -2
  39. package/lib/module/bottom-bar/BottomBarBase.js.map +1 -1
  40. package/lib/module/bottom-sheet/BottomSheetBase.js +6 -2
  41. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  42. package/lib/module/bottom-sheet/context.js +25 -0
  43. package/lib/module/bottom-sheet/context.js.map +1 -0
  44. package/lib/module/composer-panel/ComposerPanelBase.js +33 -12
  45. package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
  46. package/lib/module/composer-panel/ComposerPopover.js +13 -5
  47. package/lib/module/composer-panel/ComposerPopover.js.map +1 -1
  48. package/lib/module/composer-panel/ModelPickerBase.js +13 -2
  49. package/lib/module/composer-panel/ModelPickerBase.js.map +1 -1
  50. package/lib/module/context-menu/ContextMenu.js +10 -2
  51. package/lib/module/context-menu/ContextMenu.js.map +1 -1
  52. package/lib/module/dialog/DialogBottomSheet.js +25 -4
  53. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  54. package/lib/module/floating/LongPressArea.js +100 -0
  55. package/lib/module/floating/LongPressArea.js.map +1 -0
  56. package/lib/module/settings-modal/SettingsModal.js +8 -1
  57. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  58. package/lib/module/settings-modal/use-keyboard-reveal.js +72 -0
  59. package/lib/module/settings-modal/use-keyboard-reveal.js.map +1 -0
  60. package/lib/module/sidebar/Sidebar.js +6 -2
  61. package/lib/module/sidebar/Sidebar.js.map +1 -1
  62. package/lib/module/sidebar/SidebarItem.js +5 -1
  63. package/lib/module/sidebar/SidebarItem.js.map +1 -1
  64. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts +5 -1
  65. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/bottom-bar/BottomBarBase.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/bottom-sheet/context.d.ts +5 -0
  69. package/lib/typescript/commonjs/bottom-sheet/context.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/composer-panel/ComposerPopover.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/composer-panel/ModelPickerBase.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/context-menu/ContextMenu.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/floating/LongPressArea.d.ts +34 -0
  76. package/lib/typescript/commonjs/floating/LongPressArea.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/settings-modal/use-keyboard-reveal.d.ts +21 -0
  79. package/lib/typescript/commonjs/settings-modal/use-keyboard-reveal.d.ts.map +1 -0
  80. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/sidebar/SidebarItem.d.ts.map +1 -1
  82. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts +5 -1
  83. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts.map +1 -1
  84. package/lib/typescript/module/bottom-bar/BottomBarBase.d.ts.map +1 -1
  85. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  86. package/lib/typescript/module/bottom-sheet/context.d.ts +5 -0
  87. package/lib/typescript/module/bottom-sheet/context.d.ts.map +1 -0
  88. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  89. package/lib/typescript/module/composer-panel/ComposerPopover.d.ts.map +1 -1
  90. package/lib/typescript/module/composer-panel/ModelPickerBase.d.ts.map +1 -1
  91. package/lib/typescript/module/context-menu/ContextMenu.d.ts.map +1 -1
  92. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  93. package/lib/typescript/module/floating/LongPressArea.d.ts +34 -0
  94. package/lib/typescript/module/floating/LongPressArea.d.ts.map +1 -0
  95. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  96. package/lib/typescript/module/settings-modal/use-keyboard-reveal.d.ts +21 -0
  97. package/lib/typescript/module/settings-modal/use-keyboard-reveal.d.ts.map +1 -0
  98. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  99. package/lib/typescript/module/sidebar/SidebarItem.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/ai-chat/AiChatContainer.tsx +44 -5
  102. package/src/ai-chat/use-panel-interaction.ts +7 -2
  103. package/src/bottom-bar/BottomBarBase.tsx +5 -2
  104. package/src/bottom-sheet/BottomSheetBase.tsx +4 -1
  105. package/src/bottom-sheet/context.ts +23 -0
  106. package/src/composer-panel/ComposerPanelBase.tsx +38 -13
  107. package/src/composer-panel/ComposerPopover.tsx +13 -5
  108. package/src/composer-panel/ModelPickerBase.tsx +11 -3
  109. package/src/context-menu/ContextMenu.tsx +7 -2
  110. package/src/dialog/DialogBottomSheet.tsx +16 -4
  111. package/src/floating/LongPressArea.tsx +114 -0
  112. package/src/settings-modal/SettingsModal.tsx +9 -2
  113. package/src/settings-modal/use-keyboard-reveal.ts +62 -0
  114. package/src/sidebar/Sidebar.tsx +6 -2
  115. package/src/sidebar/SidebarItem.tsx +5 -1
@@ -1,10 +1,10 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
+ Text as RNText,
3
4
  TextInput,
4
5
  View,
5
6
  type LayoutChangeEvent,
6
7
  type NativeSyntheticEvent,
7
- type TextInputContentSizeChangeEventData,
8
8
  type TextInputKeyPressEventData,
9
9
  } from 'react-native';
10
10
  import Animated, {
@@ -177,7 +177,11 @@ export function ComposerPanelBase({
177
177
 
178
178
  // The prompt grows with its text, one 20px line at a time, up to 200 before it
179
179
  // scrolls. Web measures the textarea, collapsing it only when the draft may
180
- // have got shorter (`useTextareaAutosize`); native reads the content size.
180
+ // have got shorter (`useTextareaAutosize`). Native lays the same text out in
181
+ // an invisible twin and takes ITS height: the field's own
182
+ // `onContentSizeChange` does not fire for a value set from outside until the
183
+ // field is focused (Android 16), so a question loaded to edit showed one line
184
+ // and a cancelled edit kept four.
181
185
  const [height, setHeight] = useState(PROMPT_LINE);
182
186
  useTextareaAutosize(fieldRef, text, {
183
187
  enabled: IS_WEB,
@@ -185,13 +189,9 @@ export function ComposerPanelBase({
185
189
  minHeight: PROMPT_LINE,
186
190
  onMeasure: (content) => setHeight(Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_LINE, content))),
187
191
  });
188
- const onContentSizeChange = useCallback(
189
- (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
190
- if (IS_WEB) return;
191
- setHeight(Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_LINE, event.nativeEvent.contentSize.height)));
192
- },
193
- [],
194
- );
192
+ const onTwinLayout = useCallback((event: LayoutChangeEvent) => {
193
+ setHeight(Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_LINE, event.nativeEvent.layout.height)));
194
+ }, []);
195
195
 
196
196
  const submit = useCallback(() => {
197
197
  if (disabled || busy) return;
@@ -253,7 +253,6 @@ export function ComposerPanelBase({
253
253
  value={text}
254
254
  onChangeText={setText}
255
255
  onKeyPress={onKeyPress}
256
- onContentSizeChange={onContentSizeChange}
257
256
  placeholder={placeholder}
258
257
  placeholderTextColor={palette.textTertiary}
259
258
  selectionColor={palette.accent500}
@@ -273,10 +272,36 @@ export function ComposerPanelBase({
273
272
  ...(IS_WEB ? { caretColor: palette.accent500 } : null),
274
273
  }}
275
274
  />
275
+ {IS_WEB ? null : (
276
+ <RNText
277
+ aria-hidden
278
+ accessibilityElementsHidden
279
+ importantForAccessibility="no-hide-descendants"
280
+ pointerEvents="none"
281
+ testID={testID ? `${testID}-input-twin` : undefined}
282
+ onLayout={onTwinLayout}
283
+ style={{
284
+ position: 'absolute',
285
+ top: 0,
286
+ left: 6,
287
+ right: 6,
288
+ opacity: 0,
289
+ ...TYPE_SCALE['body-regular'],
290
+ fontFamily: 'Inter',
291
+ }}>
292
+ {/* A trailing newline is a line of its own in the field; a
293
+ Text drops it unless something follows. */}
294
+ {text.endsWith('\n') || text === '' ? `${text} ` : text}
295
+ </RNText>
296
+ )}
276
297
  </View>
277
298
 
278
299
  <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
279
- <View style={{ minWidth: 0, flexShrink: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
300
+ {/* The actions never give way: + and the permission chip on the
301
+ left, voice and send on the right keep their size, and the
302
+ model chip between them is what truncates (a long model name
303
+ once covered + and pushed voice off the card). */}
304
+ <View style={{ flexShrink: 0, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
280
305
  {addMenu.length > 0 ? (
281
306
  <AddMenu
282
307
  palette={palette}
@@ -299,7 +324,7 @@ export function ComposerPanelBase({
299
324
  />
300
325
  ) : null}
301
326
  </View>
302
- <View style={{ flexShrink: 0, flexDirection: 'row', alignItems: 'center', gap: 16 }}>
327
+ <View style={{ minWidth: 0, flexShrink: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 16 }}>
303
328
  {providers && providers.length > 0 ? (
304
329
  <ModelPickerBase
305
330
  providers={providers}
@@ -314,7 +339,7 @@ export function ComposerPanelBase({
314
339
  testID={testID ? `${testID}-model` : undefined}
315
340
  />
316
341
  ) : null}
317
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
342
+ <View style={{ flexShrink: 0, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
318
343
  <MicButton
319
344
  listening={isListening}
320
345
  onToggle={() => setListening(!isListening)}
@@ -7,7 +7,9 @@
7
7
  * sheet is not something `BottomSheet` stacks. So each panel is a transparent
8
8
  * `Modal` holding a dismiss layer and the panel, placed against the trigger's
9
9
  * `measureInWindow` box: on the requested side when it fits, flipped when it
10
- * does not, clamped 8px inside the window. Nested `Modal`s stack on both
10
+ * does not, clamped 8px inside the window's SAFE area. The Modal draws edge to
11
+ * edge, so a clamp to the bare window put the effort panel under Android's
12
+ * gesture bar (Pixel 8a, Android 16). Nested `Modal`s stack on both
11
13
  * platforms, so the effort panel opens above the picker.
12
14
  *
13
15
  * Motion is the popover entry without the blur (no filter on native):
@@ -30,6 +32,7 @@ import Animated, {
30
32
  useSharedValue,
31
33
  withTiming,
32
34
  } from 'react-native-reanimated';
35
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
33
36
 
34
37
  import { TRANSITION_MS } from './shared';
35
38
  import type { ComposerPopoverProps } from './types';
@@ -56,6 +59,7 @@ export function ComposerPopover({
56
59
  children,
57
60
  }: ComposerPopoverProps) {
58
61
  const window = useWindowDimensions();
62
+ const insets = useSafeAreaInsets();
59
63
  const reducedMotion = useReducedMotion();
60
64
  const [mounted, setMounted] = useState(open);
61
65
  const [anchor, setAnchor] = useState<Box | null>(null);
@@ -112,14 +116,18 @@ export function ComposerPopover({
112
116
  let left = GUTTER;
113
117
  let top = GUTTER;
114
118
  if (anchor && size) {
115
- left = Math.min(Math.max(anchor.x, GUTTER), window.width - size.width - GUTTER);
119
+ const minLeft = insets.left + GUTTER;
120
+ const maxRight = window.width - insets.right - GUTTER;
121
+ const minTop = insets.top + GUTTER;
122
+ const maxBottom = window.height - insets.bottom - GUTTER;
123
+ left = Math.max(minLeft, Math.min(anchor.x, maxRight - size.width));
116
124
  const above = anchor.y - sideOffset - size.height;
117
125
  const below = anchor.y + anchor.height + sideOffset;
118
- const fitsAbove = above >= GUTTER;
119
- const fitsBelow = below + size.height <= window.height - GUTTER;
126
+ const fitsAbove = above >= minTop;
127
+ const fitsBelow = below + size.height <= maxBottom;
120
128
  const preferAbove = side === 'top' ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
121
129
  top = preferAbove ? above : below;
122
- top = Math.min(Math.max(top, GUTTER), window.height - size.height - GUTTER);
130
+ top = Math.max(minTop, Math.min(top, maxBottom - size.height));
123
131
  }
124
132
 
125
133
  return (
@@ -48,6 +48,8 @@ const RAIL_PADDING = 3;
48
48
  const MARK_HEIGHT = 30;
49
49
  const MARK_GAP = 6;
50
50
  const ROW_HEIGHT = 36;
51
+ /** The model chip's widest: past this a name truncates even with room to spare. */
52
+ const MODEL_TRIGGER_MAX_WIDTH = 240;
51
53
 
52
54
  const DEFAULT_LABELS: Required<ModelPickerLabels> = {
53
55
  models: 'Models',
@@ -546,8 +548,14 @@ export function ModelPickerBase({
546
548
  const rows: Match[] = results ?? (activeProvider ? activeProvider.models.map((model) => ({ provider: activeProvider, model })) : []);
547
549
 
548
550
  const triggerStyle: WebCssStyle = {
549
- height: 32,
550
- flexShrink: 0,
551
+ // At least 32, never exactly: at the largest system font the name is taller
552
+ // than 32 and a fixed height clipped it. It shrinks (with an ellipsis) to
553
+ // leave the composer's own buttons their room, and stops at a width that
554
+ // still reads as a chip.
555
+ minHeight: 32,
556
+ minWidth: 0,
557
+ maxWidth: MODEL_TRIGGER_MAX_WIDTH,
558
+ flexShrink: 1,
551
559
  flexDirection: 'row',
552
560
  alignItems: 'center',
553
561
  justifyContent: 'center',
@@ -590,7 +598,7 @@ export function ModelPickerBase({
590
598
  onHoverIn={() => setHovered(true)}
591
599
  onHoverOut={() => setHovered(false)}
592
600
  style={[triggerStyle, style]}>
593
- <Text variant="body-medium" numberOfLines={1} style={{ paddingLeft: 2, paddingRight: 2, color: palette.textSecondary }}>
601
+ <Text variant="body-medium" numberOfLines={1} ellipsizeMode="tail" style={{ flexShrink: 1, minWidth: 0, paddingLeft: 2, paddingRight: 2, color: palette.textSecondary }}>
594
602
  {selected?.model.name ?? ''}
595
603
  </Text>
596
604
  <TurningChevron degrees={open ? 180 : 0} color={palette.iconSecondary} />
@@ -14,6 +14,7 @@ import { MENU_TRIGGER_POPUP } from '../floating/constants';
14
14
  import { MenuSurfaceProvider, type MenuSurfaceContextValue } from '../floating/context';
15
15
  import { createMenuRows } from '../floating/menu-rows';
16
16
  import { createInlineMenuSub } from '../floating/menu-sub-inline';
17
+ import { LongPressArea } from '../floating/LongPressArea';
17
18
  import { TriggerSlot } from '../floating/TriggerSlot';
18
19
  import { useSheetOpenBridge } from '../floating/use-sheet-open-bridge';
19
20
  import { ContextMenuProvider, useContextMenu } from './context';
@@ -57,9 +58,13 @@ export function ContextMenuTrigger({
57
58
  }: ContextMenuTriggerProps) {
58
59
  const menu = useContextMenu();
59
60
 
61
+ // Without `asChild` the trigger is Bloom's own, and it is a `LongPressArea`
62
+ // rather than TriggerSlot's `Pressable`: a Pressable holds the touch
63
+ // responder, which on Android keeps a horizontal ScrollView inside the
64
+ // trigger (a code block in a chat turn) from ever scrolling.
60
65
  return (
61
66
  <TriggerSlot
62
- asChild={asChild}
67
+ asChild
63
68
  className={className}
64
69
  style={style}
65
70
  testID={testID}
@@ -76,7 +81,7 @@ export function ContextMenuTrigger({
76
81
  'aria-haspopup': MENU_TRIGGER_POPUP,
77
82
  'aria-expanded': menu.open,
78
83
  }}>
79
- {children}
84
+ {asChild ? children : <LongPressArea>{children}</LongPressArea>}
80
85
  </TriggerSlot>
81
86
  );
82
87
  }
@@ -12,10 +12,11 @@ import {
12
12
  type StyleProp,
13
13
  type ViewStyle,
14
14
  } from 'react-native';
15
- import { type AnimatedStyle } from 'react-native-reanimated';
15
+ import Animated, { useAnimatedStyle, type AnimatedStyle } from 'react-native-reanimated';
16
16
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
17
17
 
18
18
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
19
+ import { useSheetKeyboardHeight } from '../bottom-sheet/context';
19
20
  import { useTheme } from '../theme/use-theme';
20
21
  import { StyledView } from '../styles/styled-primitives';
21
22
  import { Context } from './context';
@@ -345,9 +346,7 @@ export function DialogBottomSheet({
345
346
  rather than a padding: the content box keeps the caller's
346
347
  `padding`/`contentPadding` untouched on both platforms, and the
347
348
  morph layer above still measures the content alone. */}
348
- {bottomInset > 0 ? (
349
- <View testID={SAFE_AREA_SPACER_TESTID} style={{ height: bottomInset }} />
350
- ) : null}
349
+ {bottomInset > 0 ? <SafeAreaSpacer testID={SAFE_AREA_SPACER_TESTID} inset={bottomInset} /> : null}
351
350
  </StyledView>
352
351
  </Context.Provider>
353
352
  </BottomSheet>
@@ -357,6 +356,19 @@ export function DialogBottomSheet({
357
356
  /** Test handle for the bottom safe-area spacer. */
358
357
  const SAFE_AREA_SPACER_TESTID = 'dialog-sheet-safe-area';
359
358
 
359
+ /**
360
+ * The gesture bar's height under the content — folded while the keyboard is
361
+ * up, because the keyboard covers the bar: the sheet already rides on top of
362
+ * the keyboard, and a spacer left standing floated its buttons a bar's height
363
+ * above it (Pixel 8a, Android 16). A child of the sheet, so it reads the
364
+ * sheet's own keyboard value.
365
+ */
366
+ function SafeAreaSpacer({ inset, testID }: { inset: number; testID: string }) {
367
+ const keyboard = useSheetKeyboardHeight();
368
+ const style = useAnimatedStyle(() => ({ height: keyboard.value > 0 ? 0 : inset }), [keyboard, inset]);
369
+ return <Animated.View testID={testID} style={style} />;
370
+ }
371
+
360
372
  /** Passes a bounded height down to a `scrollable={false}` body. */
361
373
  const FILL_BOUNDED = { flex: 1, minHeight: 0 } as const;
362
374
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * A long-press target that never becomes the touch RESPONDER — native only,
3
+ * the trigger `ContextMenuTrigger` renders around content it does not own.
4
+ *
5
+ * ── WHY NOT A `Pressable` ───────────────────────────────────────────────────
6
+ *
7
+ * On Android the view holding the JS responder intercepts every later MOVE of
8
+ * that touch natively (`JSResponderHandler.onInterceptTouchEvent` returns true
9
+ * for the responder's own id). A `Pressable` claims the responder on touch
10
+ * start, so a horizontal `ScrollView` INSIDE it never sees the drag: a turn
11
+ * wrapped in the context menu's trigger could not scroll its code block
12
+ * sideways (Pixel 8a, Android 16; the same block scrolled without the wrapper).
13
+ * A vertical scroller ABOVE the trigger kept working, because a parent
14
+ * intercepts before its child — which is why only the inner one broke.
15
+ *
16
+ * So the hold is timed off the RAW touch events, which every ancestor receives
17
+ * without claiming anything: a scroller that takes the drag cancels the touch,
18
+ * and a finger that travels past the slop or lifts early cancels the timer.
19
+ * Taps keep going to whatever the content itself makes pressable.
20
+ *
21
+ * Assistive tech reaches the menu through the `longpress` accessibility action
22
+ * (TalkBack's double-tap-and-hold, VoiceOver's actions rotor).
23
+ */
24
+ import React, { useCallback, useEffect, useRef } from 'react';
25
+ import {
26
+ View,
27
+ type AccessibilityActionEvent,
28
+ type GestureResponderEvent,
29
+ } from 'react-native';
30
+
31
+ import type { TriggerHandleProps } from './types';
32
+
33
+ /** `Pressable`'s own default, so a context menu opens on the platform's usual hold. */
34
+ export const LONG_PRESS_MS = 500;
35
+ /** How far the finger may drift and still be holding, in dp (`Pressability`'s deactivation distance). */
36
+ export const LONG_PRESS_SLOP = 10;
37
+
38
+ export type LongPressAreaProps = Partial<TriggerHandleProps> & {
39
+ children?: React.ReactNode;
40
+ };
41
+
42
+ export function LongPressArea({
43
+ children,
44
+ onLongPress,
45
+ // A press is the content's own business: the area never claims one.
46
+ onPress: _onPress,
47
+ disabled,
48
+ accessibilityLabel,
49
+ accessibilityRole,
50
+ ...aria
51
+ }: LongPressAreaProps) {
52
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+ const origin = useRef<{ x: number; y: number } | null>(null);
54
+ const onLongPressRef = useRef(onLongPress);
55
+ onLongPressRef.current = onLongPress;
56
+
57
+ const cancel = useCallback(() => {
58
+ if (timer.current !== null) clearTimeout(timer.current);
59
+ timer.current = null;
60
+ origin.current = null;
61
+ }, []);
62
+ useEffect(() => cancel, [cancel]);
63
+
64
+ const onTouchStart = useCallback(
65
+ (event: GestureResponderEvent) => {
66
+ cancel();
67
+ // A second finger is a pinch or a scroll, not a hold.
68
+ if (disabled || !onLongPressRef.current || event.nativeEvent.touches?.length > 1) return;
69
+ origin.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
70
+ timer.current = setTimeout(() => {
71
+ timer.current = null;
72
+ origin.current = null;
73
+ onLongPressRef.current?.(event);
74
+ }, LONG_PRESS_MS);
75
+ },
76
+ [cancel, disabled],
77
+ );
78
+ const onTouchMove = useCallback(
79
+ (event: GestureResponderEvent) => {
80
+ const start = origin.current;
81
+ if (!start) return;
82
+ const dx = event.nativeEvent.pageX - start.x;
83
+ const dy = event.nativeEvent.pageY - start.y;
84
+ if (dx * dx + dy * dy > LONG_PRESS_SLOP * LONG_PRESS_SLOP) cancel();
85
+ },
86
+ [cancel],
87
+ );
88
+ const onAccessibilityAction = useCallback(
89
+ (event: AccessibilityActionEvent) => {
90
+ if (event.nativeEvent.actionName === 'longpress' && !disabled) {
91
+ onLongPressRef.current?.(event as unknown as GestureResponderEvent);
92
+ }
93
+ },
94
+ [disabled],
95
+ );
96
+
97
+ return (
98
+ <View
99
+ {...aria}
100
+ // One node, as the `Pressable` it replaces was: the hold is its action.
101
+ accessible
102
+ accessibilityLabel={accessibilityLabel}
103
+ accessibilityRole={accessibilityRole}
104
+ accessibilityState={{ disabled: !!disabled }}
105
+ accessibilityActions={disabled ? undefined : [{ name: 'longpress', label: accessibilityLabel }]}
106
+ onAccessibilityAction={onAccessibilityAction}
107
+ onTouchStart={onTouchStart}
108
+ onTouchMove={onTouchMove}
109
+ onTouchEnd={cancel}
110
+ onTouchCancel={cancel}>
111
+ {children}
112
+ </View>
113
+ );
114
+ }
@@ -6,7 +6,7 @@ import React, {
6
6
  useRef,
7
7
  useState,
8
8
  } from 'react';
9
- import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
9
+ import { Pressable, StyleSheet, View, useWindowDimensions, type ScrollView } from 'react-native';
10
10
  import Animated, {
11
11
  Easing,
12
12
  useAnimatedStyle,
@@ -37,6 +37,7 @@ import {
37
37
  import { ModalPortal } from './modal-portal';
38
38
  import type { SettingsPalette } from './palette';
39
39
  import type { SettingsModalProps, SettingsNavGroup, SettingsNavItem } from './types';
40
+ import { useKeyboardReveal } from './use-keyboard-reveal';
40
41
  import { IS_WEB, useSettingsWebCss } from './web-css';
41
42
 
42
43
  /**
@@ -722,9 +723,15 @@ function PageScroller({
722
723
  const onScroll = useAnimatedScrollHandler((event) => {
723
724
  scrollY.value = Math.max(0, event.contentOffset.y);
724
725
  }, [scrollY]);
726
+ const scrollRef = useRef<ScrollView>(null);
727
+ // The keyboard slides over an edge-to-edge modal: pad by it, and bring the
728
+ // focused field up above it.
729
+ const keyboardHeight = useKeyboardReveal(scrollRef, scrollY, !IS_WEB);
725
730
  return (
726
731
  <Animated.ScrollView
732
+ ref={scrollRef}
727
733
  style={styles.scroll}
734
+ keyboardShouldPersistTaps="handled"
728
735
  stickyHeaderIndices={IS_WEB ? undefined : [0]}
729
736
  onScroll={onScroll}
730
737
  scrollEventThrottle={16}
@@ -737,7 +744,7 @@ function PageScroller({
737
744
  style={{
738
745
  paddingLeft: inset,
739
746
  paddingRight: inset,
740
- paddingBottom: inset,
747
+ paddingBottom: inset + keyboardHeight,
741
748
  paddingTop: insetTop,
742
749
  }}
743
750
  >
@@ -0,0 +1,62 @@
1
+ import { useEffect, useRef, useState, type RefObject } from 'react';
2
+ import { Keyboard, Platform, TextInput, type KeyboardEvent, type ScrollView } from 'react-native';
3
+
4
+ /** Clearance kept between the focused field and the keyboard's top, px. */
5
+ export const KEYBOARD_REVEAL_MARGIN = 16;
6
+
7
+ type Measurable = { measureInWindow?: (cb: (x: number, y: number, width: number, height: number) => void) => void };
8
+
9
+ /**
10
+ * Keep a settings page's focused field above the software keyboard — native
11
+ * only; on web the browser scrolls a focused field into view itself.
12
+ *
13
+ * The settings modal draws edge to edge, so the keyboard slides OVER it rather
14
+ * than resizing it: a field low on a page (Personalization's last input, a
15
+ * Pixel 8a at Android 16) stayed behind the keyboard with nothing to scroll it
16
+ * out. This returns the keyboard's height for the page to pad its content by —
17
+ * so there is room to scroll — and, when the keyboard shows, scrolls the page
18
+ * by exactly what the focused field sits below the keyboard's top.
19
+ *
20
+ * `offset` is the page's current scroll offset (its scroll handler keeps it).
21
+ */
22
+ export function useKeyboardReveal(
23
+ scrollRef: RefObject<ScrollView | null>,
24
+ offset: { value: number },
25
+ enabled: boolean,
26
+ ): number {
27
+ const [keyboard, setKeyboard] = useState<{ height: number; overlap: number }>({ height: 0, overlap: 0 });
28
+ const offsetRef = useRef(offset);
29
+ offsetRef.current = offset;
30
+
31
+ useEffect(() => {
32
+ if (!enabled || typeof Keyboard?.addListener !== 'function') return;
33
+ const reveal = (event: KeyboardEvent) => {
34
+ const { height, screenY } = event.endCoordinates;
35
+ const field = TextInput.State?.currentlyFocusedInput?.() as Measurable | null | undefined;
36
+ if (!field || typeof field.measureInWindow !== 'function') {
37
+ setKeyboard({ height, overlap: 0 });
38
+ return;
39
+ }
40
+ field.measureInWindow((_x, y, _width, fieldHeight) => {
41
+ setKeyboard({ height, overlap: Math.max(0, y + fieldHeight + KEYBOARD_REVEAL_MARGIN - screenY) });
42
+ });
43
+ };
44
+ // iOS announces the keyboard before it moves; Android only once it is up.
45
+ const show = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', reveal);
46
+ const hide = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () =>
47
+ setKeyboard({ height: 0, overlap: 0 }),
48
+ );
49
+ return () => {
50
+ show.remove();
51
+ hide.remove();
52
+ };
53
+ }, [enabled]);
54
+
55
+ // After the commit that pads the page by the keyboard: before it, the scroll
56
+ // range ends where the keyboard begins and the scroll would be clamped short.
57
+ useEffect(() => {
58
+ if (keyboard.overlap > 0) scrollRef.current?.scrollTo({ y: offsetRef.current.value + keyboard.overlap, animated: true });
59
+ }, [keyboard, scrollRef]);
60
+
61
+ return enabled ? keyboard.height : 0;
62
+ }
@@ -254,11 +254,15 @@ const SidebarPanel: React.FC<SidebarProps> = ({
254
254
  };
255
255
  }, [progress, expandedWidth, fluid, collapsedWidth, expandedPadding, collapsedPadding]);
256
256
  const [contentHeight, setContentHeight] = useState(0);
257
+ // A worklet copies what it reads to the UI runtime, and `tree` carries the
258
+ // app's rows, elements and data — a `Date` among them crashes native on the
259
+ // copy. The mapper reads only whether there is one (`worklet-captures.test.ts`).
260
+ const treeShown = Boolean(tree);
257
261
  const contentMorph = useAnimatedStyle(() => ({
258
262
  height: contentHeight > 0 ? contentHeight * (1 - progress.value) : collapsed ? 0 : undefined,
259
263
  opacity: 1 - progress.value,
260
- marginTop: tree ? 0 : 24 * (1 - progress.value),
261
- }), [contentHeight, collapsed, progress, tree]);
264
+ marginTop: treeShown ? 0 : 24 * (1 - progress.value),
265
+ }), [contentHeight, collapsed, progress, treeShown]);
262
266
  const contentWidth = useAnimatedStyle(() => ({ width: expandedWidth.value - expandedPadding * 2 }), [expandedWidth, expandedPadding]);
263
267
  const navInset = useAnimatedStyle(() => {
264
268
  const inset = expandedInset * (1 - progress.value);
@@ -67,7 +67,11 @@ const SidebarItemComponent: React.FC<SidebarItemProps> = ({
67
67
  // Not clipped: the collapsed badge hangs off the square's top-end corner.
68
68
  // The content clips itself instead, so the label can't spill mid-morph.
69
69
  overflow: 'visible',
70
- height: metrics.row.square,
70
+ // Collapsed it is a square. Expanded it is at LEAST the square: at the
71
+ // default type scale the label's line fits exactly, and at the largest
72
+ // system font a fixed height clipped it top and bottom (Android 16).
73
+ height: collapsed ? metrics.row.square : undefined,
74
+ minHeight: metrics.row.square,
71
75
  paddingLeft: metrics.row.padding,
72
76
  paddingRight: metrics.row.padding,
73
77
  paddingTop: metrics.row.padding,