@oxy.so/bloom 4.18.0 → 4.18.1

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 (88) hide show
  1. package/docs/adoption-matrix.mdx +5 -5
  2. package/docs/overlay.mdx +21 -0
  3. package/docs/select.mdx +10 -0
  4. package/lib/commonjs/chat-list/parts.js +1 -1
  5. package/lib/commonjs/chat-list/parts.js.map +1 -1
  6. package/lib/commonjs/chat-screen/ChatBackground.js +6 -6
  7. package/lib/commonjs/chat-screen/ChatBackground.js.map +1 -1
  8. package/lib/commonjs/dialog/Dialog.js +5 -0
  9. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  10. package/lib/commonjs/location-puck/LocationPuck.js +1 -1
  11. package/lib/commonjs/location-puck/LocationPuck.js.map +1 -1
  12. package/lib/commonjs/mail-list/parts.js +1 -1
  13. package/lib/commonjs/mail-list/parts.js.map +1 -1
  14. package/lib/commonjs/media-card/parts.js +1 -1
  15. package/lib/commonjs/media-card/parts.js.map +1 -1
  16. package/lib/commonjs/overlay/Overlay.js +19 -4
  17. package/lib/commonjs/overlay/Overlay.js.map +1 -1
  18. package/lib/commonjs/overlay/use-hardware-back.js +32 -0
  19. package/lib/commonjs/overlay/use-hardware-back.js.map +1 -0
  20. package/lib/commonjs/search/Search.js +36 -3
  21. package/lib/commonjs/search/Search.js.map +1 -1
  22. package/lib/commonjs/settings-modal/SettingsModal.js +19 -12
  23. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  24. package/lib/commonjs/teleport/PortalHost.js +1 -1
  25. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js +32 -12
  26. package/lib/commonjs/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -1
  27. package/lib/module/chat-list/parts.js +1 -1
  28. package/lib/module/chat-list/parts.js.map +1 -1
  29. package/lib/module/chat-screen/ChatBackground.js +6 -6
  30. package/lib/module/chat-screen/ChatBackground.js.map +1 -1
  31. package/lib/module/dialog/Dialog.js +5 -0
  32. package/lib/module/dialog/Dialog.js.map +1 -1
  33. package/lib/module/location-puck/LocationPuck.js +1 -1
  34. package/lib/module/location-puck/LocationPuck.js.map +1 -1
  35. package/lib/module/mail-list/parts.js +1 -1
  36. package/lib/module/mail-list/parts.js.map +1 -1
  37. package/lib/module/media-card/parts.js +1 -1
  38. package/lib/module/media-card/parts.js.map +1 -1
  39. package/lib/module/overlay/Overlay.js +19 -4
  40. package/lib/module/overlay/Overlay.js.map +1 -1
  41. package/lib/module/overlay/use-hardware-back.js +29 -0
  42. package/lib/module/overlay/use-hardware-back.js.map +1 -0
  43. package/lib/module/search/Search.js +37 -4
  44. package/lib/module/search/Search.js.map +1 -1
  45. package/lib/module/settings-modal/SettingsModal.js +20 -13
  46. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  47. package/lib/module/teleport/PortalHost.js +1 -1
  48. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js +32 -12
  49. package/lib/module/zoomable-media-gallery/ZoomableMediaGallery.js.map +1 -1
  50. package/lib/typescript/commonjs/overlay/Overlay.d.ts +1 -1
  51. package/lib/typescript/commonjs/overlay/Overlay.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/overlay/types.d.ts +18 -0
  53. package/lib/typescript/commonjs/overlay/types.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/overlay/use-hardware-back.d.ts +11 -0
  55. package/lib/typescript/commonjs/overlay/use-hardware-back.d.ts.map +1 -0
  56. package/lib/typescript/commonjs/search/Search.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/select/types.d.ts +7 -0
  58. package/lib/typescript/commonjs/select/types.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/teleport/PortalHost.d.ts +1 -1
  61. package/lib/typescript/commonjs/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -1
  62. package/lib/typescript/module/overlay/Overlay.d.ts +1 -1
  63. package/lib/typescript/module/overlay/Overlay.d.ts.map +1 -1
  64. package/lib/typescript/module/overlay/types.d.ts +18 -0
  65. package/lib/typescript/module/overlay/types.d.ts.map +1 -1
  66. package/lib/typescript/module/overlay/use-hardware-back.d.ts +11 -0
  67. package/lib/typescript/module/overlay/use-hardware-back.d.ts.map +1 -0
  68. package/lib/typescript/module/search/Search.d.ts.map +1 -1
  69. package/lib/typescript/module/select/types.d.ts +7 -0
  70. package/lib/typescript/module/select/types.d.ts.map +1 -1
  71. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  72. package/lib/typescript/module/teleport/PortalHost.d.ts +1 -1
  73. package/lib/typescript/module/zoomable-media-gallery/ZoomableMediaGallery.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/src/chat-list/parts.tsx +1 -1
  76. package/src/chat-screen/ChatBackground.tsx +6 -6
  77. package/src/dialog/Dialog.tsx +9 -1
  78. package/src/location-puck/LocationPuck.tsx +1 -1
  79. package/src/mail-list/parts.tsx +1 -1
  80. package/src/media-card/parts.tsx +1 -1
  81. package/src/overlay/Overlay.tsx +32 -14
  82. package/src/overlay/types.ts +18 -0
  83. package/src/overlay/use-hardware-back.ts +27 -0
  84. package/src/search/Search.tsx +42 -4
  85. package/src/select/types.ts +7 -0
  86. package/src/settings-modal/SettingsModal.tsx +20 -15
  87. package/src/teleport/PortalHost.tsx +1 -1
  88. package/src/zoomable-media-gallery/ZoomableMediaGallery.tsx +33 -12
@@ -44,9 +44,9 @@ function PatternLayer({
44
44
  <View
45
45
  pointerEvents="none"
46
46
  testID="chat-background-pattern"
47
- style={StyleSheet.absoluteFillObject}
47
+ style={StyleSheet.absoluteFill}
48
48
  >
49
- <Svg style={StyleSheet.absoluteFillObject} width="100%" height="100%">
49
+ <Svg style={StyleSheet.absoluteFill} width="100%" height="100%">
50
50
  <Defs>
51
51
  <Pattern
52
52
  id={id}
@@ -83,9 +83,9 @@ function GradientLayer({ colors }: { colors: string[] }) {
83
83
  <View
84
84
  pointerEvents="none"
85
85
  testID="chat-background-gradient"
86
- style={StyleSheet.absoluteFillObject}
86
+ style={StyleSheet.absoluteFill}
87
87
  >
88
- <Svg style={StyleSheet.absoluteFillObject} width="100%" height="100%">
88
+ <Svg style={StyleSheet.absoluteFill} width="100%" height="100%">
89
89
  <Defs>
90
90
  <LinearGradient id={id} x1="0" y1="0" x2="0.35" y2="1">
91
91
  {colors.map((color, i) => (
@@ -159,13 +159,13 @@ function ChatBackgroundComponent({
159
159
  accessibilityElementsHidden
160
160
  importantForAccessibility="no-hide-descendants"
161
161
  testID="chat-background-image"
162
- style={StyleSheet.absoluteFillObject}
162
+ style={StyleSheet.absoluteFill}
163
163
  />
164
164
  <View
165
165
  pointerEvents="none"
166
166
  testID="chat-background-dim"
167
167
  style={[
168
- StyleSheet.absoluteFillObject,
168
+ StyleSheet.absoluteFill,
169
169
  { backgroundColor: paint.imageDim, opacity: dim },
170
170
  ]}
171
171
  />
@@ -546,7 +546,15 @@ function SideSheet({
546
546
  return (
547
547
  // `OverlayRoot` takes this drawer's place in the open-order overlay stack.
548
548
  // It mounts here, past the `mounted` guard, so the rank tracks OPENING.
549
- <OverlayRoot style={[sideStyles.root, containerStyle]} className={containerClassName}>
549
+ // `onRequestClose`: the drawer is not an RN `Modal`, so Android back would
550
+ // otherwise pass straight to the screen behind it. A drawer that refuses
551
+ // backdrop dismissal still swallows the press, and stays open.
552
+ <OverlayRoot
553
+ style={[sideStyles.root, containerStyle]}
554
+ className={containerClassName}
555
+ onRequestClose={handleBackdropPress}
556
+ modal
557
+ >
550
558
  <Backdrop
551
559
  testID={testID ? `${testID}-backdrop` : DIALOG_SHEET_BACKDROP_TESTID}
552
560
  accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
@@ -81,7 +81,7 @@ const PULSE_SCALE = 0.18;
81
81
 
82
82
  /** One full-size layer that centres whatever it holds on the box's centre. */
83
83
  const layerStyle: ViewStyle = {
84
- ...StyleSheet.absoluteFillObject,
84
+ ...StyleSheet.absoluteFill,
85
85
  alignItems: 'center',
86
86
  justifyContent: 'center',
87
87
  };
@@ -77,7 +77,7 @@ export function MailRowLink({
77
77
  };
78
78
  const interactive = Boolean(onPress || href || onLongPress);
79
79
  const style: WebCssStyle = {
80
- ...StyleSheet.absoluteFillObject,
80
+ ...StyleSheet.absoluteFill,
81
81
  borderRadius: MAIL_ROW_RADIUS,
82
82
  '--bloom-mail-ring': paint.accent,
83
83
  };
@@ -287,7 +287,7 @@ export function CardLink({ name, onPress, href, onLongPress, selected = false, r
287
287
  if (!IS_WEB && href) void Linking.openURL(href).catch(() => undefined);
288
288
  };
289
289
  const style: WebCssStyle = {
290
- ...StyleSheet.absoluteFillObject,
290
+ ...StyleSheet.absoluteFill,
291
291
  borderRadius: radius,
292
292
  '--bloom-media-card-ring': paint.ring,
293
293
  };
@@ -57,6 +57,7 @@ import { WEB_POSITION_FIXED } from '../styles/web-view-style';
57
57
  import { StyledView } from '../styles/styled-primitives';
58
58
  import { SurfaceLevelProvider } from '../styles/surface-levels';
59
59
  import { layerForRank, type OverlayLayer } from './stack';
60
+ import { useHardwareBack } from './use-hardware-back';
60
61
  import { useOverlayLayer } from './use-overlay-layer';
61
62
  import type { OverlayRootProps, BackdropProps } from './types';
62
63
 
@@ -109,32 +110,43 @@ export const BACKDROP_DIM_OPACITY = 0.28;
109
110
  * every Bloom surface already puts it. Descendants that need to order
110
111
  * themselves within the surface read `useOverlayLayerContext()`.
111
112
  */
112
- export function OverlayRoot({ children, className, style, testID, zIndex }: OverlayRootProps) {
113
+ export function OverlayRoot({
114
+ children,
115
+ className,
116
+ style,
117
+ testID,
118
+ zIndex,
119
+ onRequestClose,
120
+ modal = false,
121
+ }: OverlayRootProps) {
122
+ // On the root rather than in each surface: every portaled surface mounts one
123
+ // inside its open guard, so this is the one place that cannot be forgotten.
124
+ // The gallery once forgot it and Android back finished the whole activity.
125
+ useHardwareBack(onRequestClose);
113
126
  // Split into two components rather than branching on the hook: a pinned root
114
127
  // must not CONSUME a rank either. The toast host is pinned and mounts for the
115
128
  // whole life of the app, so holding a rank would keep the live set permanently
116
129
  // non-empty — the counter would never reset and depths would climb for the
117
130
  // rest of the session.
118
131
  return zIndex === undefined ? (
119
- <StackedOverlayRoot className={className} style={style} testID={testID}>
132
+ <StackedOverlayRoot className={className} style={style} testID={testID} modal={modal}>
120
133
  {children}
121
134
  </StackedOverlayRoot>
122
135
  ) : (
123
- <PinnedOverlayRoot zIndex={zIndex} className={className} style={style} testID={testID}>
136
+ <PinnedOverlayRoot zIndex={zIndex} className={className} style={style} testID={testID} modal={modal}>
124
137
  {children}
125
138
  </PinnedOverlayRoot>
126
139
  );
127
140
  }
128
141
 
129
- function StackedOverlayRoot({
130
- children,
131
- className,
132
- style,
133
- testID,
134
- }: Omit<OverlayRootProps, 'zIndex'>) {
142
+ type OverlayRootViewProps = Pick<OverlayRootProps, 'children' | 'className' | 'style' | 'testID'> & {
143
+ modal: boolean;
144
+ };
145
+
146
+ function StackedOverlayRoot({ children, className, style, testID, modal }: OverlayRootViewProps) {
135
147
  const layer = useOverlayLayer();
136
148
  return (
137
- <OverlayRootView layer={layer} className={className} style={style} testID={testID}>
149
+ <OverlayRootView layer={layer} className={className} style={style} testID={testID} modal={modal}>
138
150
  {children}
139
151
  </OverlayRootView>
140
152
  );
@@ -146,7 +158,8 @@ function PinnedOverlayRoot({
146
158
  style,
147
159
  testID,
148
160
  zIndex,
149
- }: OverlayRootProps & { zIndex: number }) {
161
+ modal,
162
+ }: OverlayRootViewProps & { zIndex: number }) {
150
163
  // Outside the stack, so descendants must not read stack depths from it
151
164
  // either — every slot is the pinned depth.
152
165
  const layer = useMemo(
@@ -154,7 +167,7 @@ function PinnedOverlayRoot({
154
167
  [zIndex],
155
168
  );
156
169
  return (
157
- <OverlayRootView layer={layer} className={className} style={style} testID={testID}>
170
+ <OverlayRootView layer={layer} className={className} style={style} testID={testID} modal={modal}>
158
171
  {children}
159
172
  </OverlayRootView>
160
173
  );
@@ -166,14 +179,19 @@ function OverlayRootView({
166
179
  style,
167
180
  testID,
168
181
  layer,
169
- }: Omit<OverlayRootProps, 'zIndex'> & { layer: OverlayLayer }) {
182
+ modal,
183
+ }: OverlayRootViewProps & { layer: OverlayLayer }) {
170
184
  return (
171
185
  <OverlayLayerContext.Provider value={layer}>
172
186
  {/* `pointerEvents` stays a PROP: react-native-web resolves the RN-only
173
187
  `box-none` from the prop path only, and as a style entry it is silently
174
- dropped — which makes the whole portaled surface click-through. */}
188
+ dropped — which makes the whole portaled surface click-through.
189
+ `accessibilityViewIsModal` sits HERE, not on the panel: iOS hides only
190
+ the modal view's SIBLINGS, and at the native outlet this root is the
191
+ sibling of the app content, where the panel is not. */}
175
192
  <StyledView
176
193
  pointerEvents="box-none"
194
+ accessibilityViewIsModal={modal && Platform.OS !== 'web' ? true : undefined}
177
195
  className={className}
178
196
  style={[styles.root, { zIndex: layer.root }, style]}
179
197
  testID={testID}
@@ -23,6 +23,24 @@ export interface OverlayRootProps {
23
23
  * `./stack.ts` exists to remove.
24
24
  */
25
25
  zIndex?: number;
26
+ /**
27
+ * Android hardware back while this surface is open. A portaled surface is not
28
+ * an RN `Modal`, so nothing else consumes the press: without this the app's
29
+ * own back handling runs and, at the root of a stack, finishes the activity —
30
+ * the surface "closes" by closing the whole app. Called and consumed while the
31
+ * root is mounted; later-opened surfaces register later, and `BackHandler`
32
+ * runs the newest listener first, so the top surface closes first. A blocking
33
+ * surface still passes a handler (a no-op) so the press is swallowed rather
34
+ * than falling through to the screen underneath.
35
+ */
36
+ onRequestClose?: () => void;
37
+ /**
38
+ * The surface is modal: a screen reader must not wander into the app behind
39
+ * it. Native only — sets `accessibilityViewIsModal` on this root, which is the
40
+ * app content's sibling at the portal outlet (iOS confines VoiceOver to
41
+ * it). Web surfaces already carry `aria-modal` on their own dialog node.
42
+ */
43
+ modal?: boolean;
26
44
  }
27
45
 
28
46
  export interface BackdropProps {
@@ -0,0 +1,27 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { BackHandler, Platform } from 'react-native';
3
+
4
+ /**
5
+ * Consume Android's hardware back for as long as the caller is mounted with a
6
+ * handler, calling the CURRENT `onBack` (read through a ref, so a new closure
7
+ * each render does not re-register and lose its place in the LIFO order).
8
+ *
9
+ * `BackHandler` runs the newest listener first, so surfaces opened later close
10
+ * first. No-op off Android and when `BackHandler` is absent (web bundles, and
11
+ * some test environments' react-native mocks).
12
+ */
13
+ export function useHardwareBack(onBack: (() => void) | undefined): void {
14
+ const onBackRef = useRef(onBack);
15
+ onBackRef.current = onBack;
16
+ const active = onBack !== undefined;
17
+
18
+ useEffect(() => {
19
+ if (!active || Platform.OS !== 'android') return;
20
+ if (typeof BackHandler?.addEventListener !== 'function') return;
21
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
22
+ onBackRef.current?.();
23
+ return true;
24
+ });
25
+ return () => subscription.remove();
26
+ }, [active]);
27
+ }
@@ -1,6 +1,8 @@
1
- import { forwardRef } from 'react';
1
+ import { forwardRef, useCallback, useMemo, useRef } from 'react';
2
2
  import { Platform, type TextInput, View } from 'react-native';
3
3
 
4
+ import { mergeRefs } from '../hooks/merge-refs';
5
+
4
6
  import { useTheme } from '../theme/use-theme';
5
7
  import { atoms as a } from '../styles';
6
8
  import { borderRadius } from '../styles/tokens';
@@ -18,22 +20,56 @@ type SearchProps = Omit<TextFieldInputProps, 'label'> & {
18
20
  };
19
21
 
20
22
  export const Search = forwardRef<TextInput, SearchProps>(
21
- function Search({ value, label = 'Search', onClearText, ...rest }, ref) {
23
+ function Search({ value, label = 'Search', onClearText, onFocus, onPressIn, ...rest }, ref) {
22
24
  const theme = useTheme();
23
25
  const showClear = value != null && value.length > 0;
24
26
 
27
+ // Select-on-focus, done here rather than through RN's `selectTextOnFocus`.
28
+ // On Android that prop does not select on focus: it arms a one-shot flag and
29
+ // calls `selectAll()` on the input's NEXT layout while focused. An
30
+ // autofocused search lays out nothing after focusing until the first
31
+ // keystroke mounts the clear button and pads the input for it — and that
32
+ // layout selects everything, so the SECOND keystroke replaced the query.
33
+ // Typing "mention" in one burst left "on" (OxyHQ/Mention#1126). Now a
34
+ // press-driven focus selects the current query once, immediately, and a
35
+ // programmatic focus (autoFocus, `ref.focus()`) selects nothing. Web never
36
+ // selected on focus and still does not.
37
+ const inputRef = useRef<TextInput | null>(null);
38
+ const refs = useMemo(() => mergeRefs([inputRef, ref]), [ref]);
39
+ const pressedRef = useRef(false);
40
+ const valueRef = useRef(value);
41
+ valueRef.current = value;
42
+ const handlePressIn = useCallback<NonNullable<SearchProps['onPressIn']>>(
43
+ (event) => {
44
+ pressedRef.current = true;
45
+ onPressIn?.(event);
46
+ },
47
+ [onPressIn],
48
+ );
49
+ const handleFocus = useCallback<NonNullable<SearchProps['onFocus']>>(
50
+ (event) => {
51
+ onFocus?.(event);
52
+ const pressed = pressedRef.current;
53
+ pressedRef.current = false;
54
+ const length = valueRef.current?.length ?? 0;
55
+ if (pressed && Platform.OS !== 'web' && length > 0) {
56
+ inputRef.current?.setSelection(0, length);
57
+ }
58
+ },
59
+ [onFocus],
60
+ );
61
+
25
62
  return (
26
63
  <View style={[a.w_full, a.relative]}>
27
64
  <TextField radius={borderRadius.full}>
28
65
  <TextFieldIcon icon={MagnifyingGlassIcon} />
29
66
  <TextFieldInput
30
- inputRef={ref}
67
+ inputRef={refs}
31
68
  label={label}
32
69
  value={value}
33
70
  placeholder={label}
34
71
  returnKeyType="search"
35
72
  keyboardAppearance={theme.mode === 'light' ? 'light' : 'dark'}
36
- selectTextOnFocus={Platform.OS !== 'web'}
37
73
  autoFocus={false}
38
74
  accessibilityRole="search"
39
75
  autoCorrect={false}
@@ -41,6 +77,8 @@ export const Search = forwardRef<TextInput, SearchProps>(
41
77
  autoCapitalize="none"
42
78
  style={showClear ? { paddingRight: 24 } : undefined}
43
79
  {...rest}
80
+ onPressIn={handlePressIn}
81
+ onFocus={handleFocus}
44
82
  />
45
83
  </TextField>
46
84
 
@@ -37,6 +37,13 @@ export type SelectTriggerProps = {
37
37
  /**
38
38
  * Utility classes APPENDED to the part's own — never substituted for them, so
39
39
  * a single layout class cannot strip the chrome.
40
+ *
41
+ * Never pin a HEIGHT here (`h-8`). The value's line box is 20 × the system
42
+ * font scale, and on native Yoga measures the text at most as tall as the
43
+ * space left inside the field: a fixed height cuts the glyphs in half on
44
+ * Android (OxyHQ/Mention#1126 — `h-8` is 28dp there, leaving 15.5dp for a
45
+ * 20dp line). Size a compact trigger with its padding (`fieldStyle`) so the
46
+ * text sets the height.
40
47
  */
41
48
  className?: string;
42
49
  /**
@@ -6,7 +6,7 @@ import React, {
6
6
  useRef,
7
7
  useState,
8
8
  } from 'react';
9
- import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
9
+ import { Pressable, StyleSheet, View, useWindowDimensions } from 'react-native';
10
10
  import Animated, {
11
11
  Easing,
12
12
  useAnimatedStyle,
@@ -263,18 +263,13 @@ export function SettingsModal({
263
263
  const compactPageOpenRef = useRef(false);
264
264
  compactPageOpenRef.current = compactPageOpen && layoutRef.current === 'compact';
265
265
 
266
- // Android back closes, like a native modal.
267
- useEffect(() => {
268
- // `BackHandler` is absent from some test environments' react-native mocks.
269
- if (!mounted || IS_WEB || typeof BackHandler?.addEventListener !== 'function') return;
270
- const sub = BackHandler.addEventListener('hardwareBackPress', () => {
271
- // On a pushed page (compact), back returns to the section list first.
272
- if (compactPageOpenRef.current) requestNavigation();
273
- else requestClose();
274
- return true;
275
- });
276
- return () => sub.remove();
277
- }, [mounted, requestClose, requestNavigation]);
266
+ // Android back closes, like a native modal — handed to `OverlayRoot`, which
267
+ // owns hardware back for every portaled surface.
268
+ const onHardwareBack = useCallback(() => {
269
+ // On a pushed page (compact), back returns to the section list first.
270
+ if (compactPageOpenRef.current) requestNavigation();
271
+ else requestClose();
272
+ }, [requestClose, requestNavigation]);
278
273
 
279
274
  // ----- saved toast -------------------------------------------------------
280
275
  const [savedPhase, setSavedPhase] = useState<SavedPhase>('hidden');
@@ -351,7 +346,7 @@ export function SettingsModal({
351
346
  return (
352
347
  <ModalPortal>
353
348
  <SettingsModalContext.Provider value={context}>
354
- <OverlayRoot>
349
+ <OverlayRoot onRequestClose={onHardwareBack} modal>
355
350
  <Backdrop
356
351
  onPress={requestClose}
357
352
  progress={progress}
@@ -813,7 +808,17 @@ function SavedToast({
813
808
 
814
809
  const styles = StyleSheet.create({
815
810
  center: {
816
- ...StyleSheet.absoluteFillObject,
811
+ // Spelled out, not `StyleSheet.absoluteFillObject`: RN 0.85 removed it, and
812
+ // spreading the resulting `undefined` left this box in flow at ZERO height.
813
+ // The panel still painted (nothing clips it) and still took touches, but
814
+ // Android reports every view under a zero-area ancestor as not visible to
815
+ // the user, so TalkBack and UI Automator saw no modal content at all —
816
+ // only the backdrop's full-screen "Close".
817
+ position: 'absolute',
818
+ top: 0,
819
+ left: 0,
820
+ right: 0,
821
+ bottom: 0,
817
822
  alignItems: 'center',
818
823
  justifyContent: 'center',
819
824
  padding: VIEWPORT_GUTTER,
@@ -25,7 +25,7 @@ import PortalHost from "./PortalHostView";
25
25
  * export default function App() {
26
26
  * return (
27
27
  * <PortalProvider>
28
- * <PortalHost style={StyleSheet.absoluteFillObject} name="overlay" />
28
+ * <PortalHost style={StyleSheet.absoluteFill} name="overlay" />
29
29
  * </PortalProvider>
30
30
  * );
31
31
  * }
@@ -25,6 +25,7 @@ import {
25
25
  Gesture,
26
26
  GestureDetector,
27
27
  } from 'react-native-gesture-handler';
28
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
28
29
  import { borderRadius } from '../styles/tokens';
29
30
  import { Backdrop, OverlayRoot } from '../overlay';
30
31
  import { Portal } from '../portal';
@@ -109,6 +110,13 @@ function shareUri(item: GalleryMedia): string | undefined {
109
110
  // image. `Platform.select` keeps them off native. `cursor` is a real
110
111
  // `ViewStyle` key on RN 0.83; `userSelect` is declared on `TextStyle` only,
111
112
  // which is what `WebCssStyle` covers.
113
+ /** Share button inset from the top-right corner of the safe area. */
114
+ const CHROME_EDGE = 16;
115
+ /** Page indicator (and a lone caption) above the bottom of the safe area. */
116
+ const INDICATOR_BOTTOM = 48;
117
+ /** A caption lifted above the page counter + dots when the indicator shows. */
118
+ const CAPTION_BOTTOM_WITH_INDICATOR = 110;
119
+
112
120
  const WEB_POINTER: WebCssStyle = { userSelect: 'none', cursor: 'pointer' };
113
121
  const WEB_USER_SELECT_NONE: WebCssStyle = { userSelect: 'none' };
114
122
 
@@ -207,6 +215,9 @@ function resolveCornerRadius(cornerRadius: number | 'circle', fit: FittedSize):
207
215
  */
208
216
  const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, ZoomableMediaGalleryProps>(({ measureThumb, cornerRadius = DEFAULT_CORNER_RADIUS, indicatorVariant = 'dots', videoControls = false }, ref) => {
209
217
  const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = useWindowDimensions();
218
+ // The viewer is full-bleed, so its chrome must clear the status bar, the
219
+ // cutout and the home indicator itself — nothing above it applies them.
220
+ const insets = useSafeAreaInsets();
210
221
 
211
222
  const radiusFor = useCallback(
212
223
  (fit: FittedSize) => resolveCornerRadius(cornerRadius, fit),
@@ -928,7 +939,10 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
928
939
  }, [handleDismiss, isOpen, pageTo]);
929
940
 
930
941
  const renderContent = () => (
931
- <OverlayRoot style={styles.modalContainer}>
942
+ // `onRequestClose`: Android back flies the media home like every other
943
+ // dismiss. The viewer is a Portal, not an RN `Modal`, so without it nothing
944
+ // consumed the press and back finished the whole app.
945
+ <OverlayRoot style={styles.modalContainer} onRequestClose={handleDismiss} modal>
932
946
  <GestureHandlerRootView style={StyleSheet.absoluteFill}>
933
947
  {/* Shared `Backdrop` (a Pressable) rather than a `Gesture.Tap()`: the
934
948
  viewer renders through the web Portal, whose root is
@@ -1063,7 +1077,10 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
1063
1077
  )}
1064
1078
 
1065
1079
  {pagerReady && items.length > 1 && (
1066
- <Animated.View style={[styles.indicatorWrap, backdropStyle]} pointerEvents="box-none">
1080
+ <Animated.View
1081
+ style={[styles.indicatorWrap, { bottom: INDICATOR_BOTTOM + insets.bottom }, backdropStyle]}
1082
+ pointerEvents="box-none"
1083
+ >
1067
1084
  <View style={styles.counterPill} pointerEvents="none">
1068
1085
  <Text style={styles.counterText}>{`${activeIndex + 1} / ${items.length}`}</Text>
1069
1086
  </View>
@@ -1120,7 +1137,11 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
1120
1137
  accessibilityRole="button"
1121
1138
  accessibilityLabel="Share media"
1122
1139
  hitSlop={8}
1123
- style={[styles.shareButton, webPointerStyle]}
1140
+ style={[
1141
+ styles.shareButton,
1142
+ { top: CHROME_EDGE + insets.top, right: CHROME_EDGE + insets.right },
1143
+ webPointerStyle,
1144
+ ]}
1124
1145
  >
1125
1146
  <RiUpload2Line fill="#fff" size="md" />
1126
1147
  </Pressable>
@@ -1130,7 +1151,11 @@ const ZoomableMediaGalleryInner = React.forwardRef<ZoomableMediaGalleryHandle, Z
1130
1151
  <Animated.View
1131
1152
  style={[
1132
1153
  styles.altCaptionWrap,
1133
- items.length > 1 && styles.altCaptionWrapWithIndicator,
1154
+ {
1155
+ bottom:
1156
+ (items.length > 1 ? CAPTION_BOTTOM_WITH_INDICATOR : INDICATOR_BOTTOM) +
1157
+ insets.bottom,
1158
+ },
1134
1159
  backdropStyle,
1135
1160
  ]}
1136
1161
  pointerEvents="none"
@@ -1209,9 +1234,9 @@ const styles = StyleSheet.create({
1209
1234
  mediaBox: {
1210
1235
  overflow: 'hidden',
1211
1236
  },
1237
+ // `bottom` is inline: INDICATOR_BOTTOM above the bottom safe-area inset.
1212
1238
  indicatorWrap: {
1213
1239
  position: 'absolute',
1214
- bottom: 48,
1215
1240
  left: 0,
1216
1241
  right: 0,
1217
1242
  alignItems: 'center',
@@ -1298,10 +1323,10 @@ const styles = StyleSheet.create({
1298
1323
  backgroundColor: 'rgba(0,0,0,0.7)',
1299
1324
  transform: [{ translateY: -22 }, { scale: 1.08 }],
1300
1325
  },
1326
+ // `top`/`right` are inline: CHROME_EDGE inside the safe area. A bare 16
1327
+ // put the button under the Android status bar, over the battery icon.
1301
1328
  shareButton: {
1302
1329
  position: 'absolute',
1303
- top: 16,
1304
- right: 16,
1305
1330
  width: 40,
1306
1331
  height: 40,
1307
1332
  borderRadius: borderRadius.full,
@@ -1309,18 +1334,14 @@ const styles = StyleSheet.create({
1309
1334
  justifyContent: 'center',
1310
1335
  backgroundColor: 'rgba(0,0,0,0.45)',
1311
1336
  },
1337
+ // `bottom` is inline, above the indicator when there is one, inside the safe area.
1312
1338
  altCaptionWrap: {
1313
1339
  position: 'absolute',
1314
- bottom: 48,
1315
1340
  left: 0,
1316
1341
  right: 0,
1317
1342
  alignItems: 'center',
1318
1343
  paddingHorizontal: 24,
1319
1344
  },
1320
- // Lifted above the page counter + dots when the multi-image indicator shows.
1321
- altCaptionWrapWithIndicator: {
1322
- bottom: 110,
1323
- },
1324
1345
  altCaptionPill: {
1325
1346
  maxWidth: 600,
1326
1347
  paddingHorizontal: 14,