@oxyhq/bloom 0.68.0 → 0.69.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 (116) hide show
  1. package/lib/commonjs/animated-check/AnimatedCheck.js +22 -93
  2. package/lib/commonjs/animated-check/AnimatedCheck.js.map +1 -1
  3. package/lib/commonjs/avatar/Avatar.js +6 -24
  4. package/lib/commonjs/avatar/Avatar.js.map +1 -1
  5. package/lib/commonjs/avatar/AvatarRing.js +14 -30
  6. package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
  7. package/lib/commonjs/bottom-sheet/index.js +9 -4
  8. package/lib/commonjs/bottom-sheet/index.js.map +1 -1
  9. package/lib/commonjs/connection-status/index.js +16 -7
  10. package/lib/commonjs/connection-status/index.js.map +1 -1
  11. package/lib/commonjs/connection-status/netinfo.js +119 -0
  12. package/lib/commonjs/connection-status/netinfo.js.map +1 -0
  13. package/lib/commonjs/hooks/haptics-module.js +109 -0
  14. package/lib/commonjs/hooks/haptics-module.js.map +1 -0
  15. package/lib/commonjs/hooks/useHaptics.js +6 -8
  16. package/lib/commonjs/hooks/useHaptics.js.map +1 -1
  17. package/lib/commonjs/loading/SpinnerIcon.js +22 -55
  18. package/lib/commonjs/loading/SpinnerIcon.js.map +1 -1
  19. package/lib/commonjs/theme/adaptive-colors.js +20 -4
  20. package/lib/commonjs/theme/adaptive-colors.js.map +1 -1
  21. package/lib/commonjs/theme/color-scope/style-builder.js +107 -6
  22. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  23. package/lib/commonjs/theme/init-css-interop.js +13 -11
  24. package/lib/commonjs/theme/init-css-interop.js.map +1 -1
  25. package/lib/module/animated-check/AnimatedCheck.js +22 -91
  26. package/lib/module/animated-check/AnimatedCheck.js.map +1 -1
  27. package/lib/module/avatar/Avatar.js +1 -19
  28. package/lib/module/avatar/Avatar.js.map +1 -1
  29. package/lib/module/avatar/AvatarRing.js +7 -23
  30. package/lib/module/avatar/AvatarRing.js.map +1 -1
  31. package/lib/module/bottom-sheet/index.js +9 -4
  32. package/lib/module/bottom-sheet/index.js.map +1 -1
  33. package/lib/module/connection-status/index.js +16 -6
  34. package/lib/module/connection-status/index.js.map +1 -1
  35. package/lib/module/connection-status/netinfo.js +114 -0
  36. package/lib/module/connection-status/netinfo.js.map +1 -0
  37. package/lib/module/hooks/haptics-module.js +104 -0
  38. package/lib/module/hooks/haptics-module.js.map +1 -0
  39. package/lib/module/hooks/useHaptics.js +7 -11
  40. package/lib/module/hooks/useHaptics.js.map +1 -1
  41. package/lib/module/loading/SpinnerIcon.js +13 -46
  42. package/lib/module/loading/SpinnerIcon.js.map +1 -1
  43. package/lib/module/loading/index.web.js +2 -2
  44. package/lib/module/theme/adaptive-colors.js +20 -4
  45. package/lib/module/theme/adaptive-colors.js.map +1 -1
  46. package/lib/module/theme/color-scope/style-builder.js +107 -6
  47. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  48. package/lib/module/theme/init-css-interop.js +13 -11
  49. package/lib/module/theme/init-css-interop.js.map +1 -1
  50. package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/connection-status/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/connection-status/netinfo.d.ts +69 -0
  56. package/lib/typescript/commonjs/connection-status/netinfo.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/hooks/haptics-module.d.ts +58 -0
  58. package/lib/typescript/commonjs/hooks/haptics-module.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts +9 -2
  61. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/theme/adaptive-colors.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/theme/init-css-interop.d.ts.map +1 -1
  65. package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -1
  66. package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
  67. package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
  68. package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
  69. package/lib/typescript/module/connection-status/index.d.ts.map +1 -1
  70. package/lib/typescript/module/connection-status/netinfo.d.ts +69 -0
  71. package/lib/typescript/module/connection-status/netinfo.d.ts.map +1 -0
  72. package/lib/typescript/module/hooks/haptics-module.d.ts +58 -0
  73. package/lib/typescript/module/hooks/haptics-module.d.ts.map +1 -0
  74. package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -1
  75. package/lib/typescript/module/loading/SpinnerIcon.d.ts +9 -2
  76. package/lib/typescript/module/loading/SpinnerIcon.d.ts.map +1 -1
  77. package/lib/typescript/module/theme/adaptive-colors.d.ts.map +1 -1
  78. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  79. package/lib/typescript/module/theme/init-css-interop.d.ts.map +1 -1
  80. package/package.json +2 -2
  81. package/src/__tests__/color-scope-nativewind.test.ts +135 -0
  82. package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
  83. package/src/__tests__/optional-peer-imports.test.ts +406 -0
  84. package/src/__tests__/svg-required-peer.test.tsx +75 -0
  85. package/src/__tests__/useHaptics.test.tsx +203 -16
  86. package/src/animated-check/AnimatedCheck.tsx +19 -86
  87. package/src/avatar/Avatar.tsx +1 -11
  88. package/src/avatar/AvatarRing.tsx +7 -15
  89. package/src/bottom-sheet/index.tsx +9 -4
  90. package/src/connection-status/index.tsx +16 -6
  91. package/src/connection-status/netinfo.ts +131 -0
  92. package/src/hooks/haptics-module.ts +118 -0
  93. package/src/hooks/useHaptics.tsx +6 -9
  94. package/src/loading/SpinnerIcon.tsx +13 -52
  95. package/src/loading/index.web.ts +2 -2
  96. package/src/theme/adaptive-colors.ts +20 -4
  97. package/src/theme/color-scope/style-builder.ts +118 -6
  98. package/src/theme/init-css-interop.ts +13 -11
  99. package/lib/commonjs/avatar/svg-module.js +0 -15
  100. package/lib/commonjs/avatar/svg-module.js.map +0 -1
  101. package/lib/commonjs/utils/lazy-require.js +0 -37
  102. package/lib/commonjs/utils/lazy-require.js.map +0 -1
  103. package/lib/module/avatar/svg-module.js +0 -11
  104. package/lib/module/avatar/svg-module.js.map +0 -1
  105. package/lib/module/utils/lazy-require.js +0 -33
  106. package/lib/module/utils/lazy-require.js.map +0 -1
  107. package/lib/typescript/commonjs/avatar/svg-module.d.ts +0 -8
  108. package/lib/typescript/commonjs/avatar/svg-module.d.ts.map +0 -1
  109. package/lib/typescript/commonjs/utils/lazy-require.d.ts +0 -15
  110. package/lib/typescript/commonjs/utils/lazy-require.d.ts.map +0 -1
  111. package/lib/typescript/module/avatar/svg-module.d.ts +0 -8
  112. package/lib/typescript/module/avatar/svg-module.d.ts.map +0 -1
  113. package/lib/typescript/module/utils/lazy-require.d.ts +0 -15
  114. package/lib/typescript/module/utils/lazy-require.d.ts.map +0 -1
  115. package/src/avatar/svg-module.ts +0 -11
  116. package/src/utils/lazy-require.ts +0 -31
@@ -1,58 +1,245 @@
1
1
  import React from 'react';
2
- import { Platform } from 'react-native';
2
+ import * as ReactNative from 'react-native';
3
3
  import { act, renderHook } from '@testing-library/react-native';
4
- import { ImpactFeedbackStyle, impactAsync } from 'expo-haptics';
4
+ import type * as ExpoHaptics from 'expo-haptics';
5
5
 
6
- import { BloomHapticsProvider, useHaptics } from '../hooks/useHaptics';
6
+ import type { HapticsLike } from '../hooks/haptics-module';
7
7
 
8
- const originalOS = Platform.OS;
8
+ /**
9
+ * THE OPTIONAL-PEER BOUNDARY for `expo-haptics`.
10
+ *
11
+ * What this suite can and cannot see is the whole point. The defect it was
12
+ * written against — the specifier passed to `require()` as a VARIABLE, which
13
+ * Metro rewrites into a thrower — is a STATIC property of the source, and jest
14
+ * is CommonJS with a real dynamic `require`, so it resolved the module happily
15
+ * and the old version of this file asserted `impactAsync` was called against
16
+ * code where it could never be called on a device. No runtime suite can
17
+ * distinguish the two shapes. `optional-peer-imports.test.ts` is the gate that
18
+ * can, and it is where the literal-specifier requirement is enforced.
19
+ *
20
+ * What IS testable here is everything the loader does once it has (or has not)
21
+ * got a module: the strength mapping, the platform clamps, and the degradation
22
+ * — inert, not a crash, and not silent.
23
+ *
24
+ * Every case that touches the loader goes through `jest.isolateModules` +
25
+ * `jest.doMock` because it caches its result and its warning flag in module
26
+ * state: without a fresh registry the first case to resolve expo-haptics would
27
+ * leave every later case asserting against a cache it can no longer influence.
28
+ */
9
29
 
10
- afterEach(() => {
11
- Platform.OS = originalOS;
12
- jest.clearAllMocks();
13
- });
30
+ const HAPTICS = 'expo-haptics';
31
+ const reactModule: unknown = jest.requireActual('react');
32
+
33
+ /**
34
+ * Compile-time half of the contract: the real expo-haptics surface must remain
35
+ * assignable to Bloom's hand-written `HapticsLike`. The interface is written by
36
+ * hand so expo-haptics' types never reach Bloom's emitted `.d.ts` (a consumer
37
+ * that skips the optional peer must not inherit a TS7016 from Bloom), which
38
+ * leaves this as the only place a shape change in expo-haptics can be caught.
39
+ *
40
+ * It is enforced by `bun run typescript`, NOT by jest: ts-jest transpiles
41
+ * without typechecking here, so a drifted type is green under `bunx jest` and
42
+ * red under `tsc` (verified by mutation — renaming `impactAsync` to
43
+ * `impactFeedback` in `HapticsLike` produces `TS2322: Type 'true' is not
44
+ * assignable to type 'never'` on the line below).
45
+ */
46
+ type RealHapticsIsCompatible = typeof ExpoHaptics extends HapticsLike ? true : never;
47
+ const HAPTICS_TYPES_STILL_MATCH: RealHapticsIsCompatible = true;
48
+
49
+ type Harness = {
50
+ useHaptics: typeof import('../hooks/useHaptics').useHaptics;
51
+ BloomHapticsProvider: typeof import('../hooks/useHaptics').BloomHapticsProvider;
52
+ impactAsync: jest.Mock;
53
+ styles: Record<'Light' | 'Medium' | 'Heavy', string>;
54
+ };
55
+
56
+ const STYLES = { Light: 'light', Medium: 'medium', Heavy: 'heavy' } as const;
57
+
58
+ /**
59
+ * Load `useHaptics` against a given expo-haptics module factory. Passing a
60
+ * factory that THROWS is how the absent-peer case is expressed: that is exactly
61
+ * what the `catch` sees when Metro stubs an unresolved optional dependency.
62
+ */
63
+ function loadWithHaptics(factory?: () => unknown): Harness {
64
+ const impactAsync = jest.fn(async () => {});
65
+ let harness: Harness | undefined;
66
+
67
+ jest.isolateModules(() => {
68
+ // NOT `{ virtual: true }`: expo-haptics is a real devDependency here (mapped
69
+ // to `__mocks__/expo-haptics.ts`), and a virtual mock is keyed by the bare
70
+ // name while the loader's `require` resolves through the same mapping — the
71
+ // mock would never be consulted and every case would run against the manual
72
+ // mock instead of the factory.
73
+ jest.doMock(
74
+ HAPTICS,
75
+ factory ?? (() => ({ ImpactFeedbackStyle: STYLES, impactAsync })),
76
+ );
77
+ // An isolated registry would otherwise build the hook against a SECOND copy
78
+ // of React, whose dispatcher is null under the outer renderer — every case
79
+ // would fail on `useContext` before reaching what it asserts. `react-native`
80
+ // is pinned for the same reason with a sharper edge: a second copy carries a
81
+ // second `Platform` object, so the `Platform.OS` each case sets would land
82
+ // on an instance the hook never reads — every platform case would then run
83
+ // on the default OS and pass or fail for the wrong reason.
84
+ jest.doMock('react', () => reactModule);
85
+ jest.doMock('react-native', () => ReactNative);
86
+
87
+ // A plain `require`, NOT `jest.requireActual`: the latter bypasses the mock
88
+ // registry for the whole dependency subtree, so the doMocks above would be
89
+ // ignored and the suite would silently exercise the real expo-haptics.
90
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
91
+ const mod = require('../hooks/useHaptics') as typeof import('../hooks/useHaptics');
92
+
93
+ harness = {
94
+ useHaptics: mod.useHaptics,
95
+ BloomHapticsProvider: mod.BloomHapticsProvider,
96
+ impactAsync,
97
+ styles: STYLES,
98
+ };
99
+ });
100
+
101
+ if (!harness) throw new Error('module isolation did not run');
102
+ return harness;
103
+ }
104
+
105
+ const originalOS = ReactNative.Platform.OS;
14
106
 
15
107
  describe('useHaptics', () => {
108
+ let warn: jest.SpyInstance<void, Parameters<typeof console.warn>>;
109
+
110
+ beforeEach(() => {
111
+ // `__mocks__/setup.ts` already silences console.warn; this re-spies so the
112
+ // calls are recorded and stay scoped to one test.
113
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
114
+ });
115
+
116
+ afterEach(() => {
117
+ ReactNative.Platform.OS = originalOS;
118
+ warn.mockRestore();
119
+ jest.resetModules();
120
+ jest.dontMock(HAPTICS);
121
+ jest.clearAllMocks();
122
+ });
123
+
124
+ it('keeps its own types in step with the real expo-haptics surface', () => {
125
+ // The real assertion is the type annotation above, checked by `tsc`. This
126
+ // only keeps the constant referenced so it cannot be dropped as unused.
127
+ expect(HAPTICS_TYPES_STILL_MATCH).toBe(true);
128
+ });
129
+
16
130
  it('returns a callback', () => {
131
+ const { useHaptics } = loadWithHaptics();
17
132
  const { result } = renderHook(() => useHaptics());
18
133
  expect(typeof result.current).toBe('function');
19
134
  });
20
135
 
21
136
  it('fires the mapped impact style on iOS', () => {
22
- Platform.OS = 'ios';
137
+ ReactNative.Platform.OS = 'ios';
138
+ const { useHaptics, impactAsync, styles } = loadWithHaptics();
23
139
  const { result } = renderHook(() => useHaptics());
24
140
  act(() => result.current('heavy'));
25
- expect(impactAsync).toHaveBeenCalledWith(ImpactFeedbackStyle.Heavy);
141
+ // Asserted against the value the MODULE exposes, not a literal: the hook's
142
+ // contract is that it hands back what `ImpactFeedbackStyle` gave it.
143
+ expect(impactAsync).toHaveBeenCalledWith(styles.Heavy);
144
+ expect(warn).not.toHaveBeenCalled();
26
145
  });
27
146
 
28
147
  it('defaults to the light impact', () => {
29
- Platform.OS = 'ios';
148
+ ReactNative.Platform.OS = 'ios';
149
+ const { useHaptics, impactAsync, styles } = loadWithHaptics();
30
150
  const { result } = renderHook(() => useHaptics());
31
151
  act(() => result.current());
32
- expect(impactAsync).toHaveBeenCalledWith(ImpactFeedbackStyle.Light);
152
+ expect(impactAsync).toHaveBeenCalledWith(styles.Light);
33
153
  });
34
154
 
35
155
  it('clamps to the light impact on Android regardless of strength', () => {
36
- Platform.OS = 'android';
156
+ ReactNative.Platform.OS = 'android';
157
+ const { useHaptics, impactAsync, styles } = loadWithHaptics();
37
158
  const { result } = renderHook(() => useHaptics());
38
159
  act(() => result.current('heavy'));
39
- expect(impactAsync).toHaveBeenCalledWith(ImpactFeedbackStyle.Light);
160
+ expect(impactAsync).toHaveBeenCalledWith(styles.Light);
40
161
  });
41
162
 
42
163
  it('no-ops on web', () => {
43
- Platform.OS = 'web';
164
+ ReactNative.Platform.OS = 'web';
165
+ const { useHaptics, impactAsync } = loadWithHaptics();
44
166
  const { result } = renderHook(() => useHaptics());
45
167
  act(() => result.current('medium'));
46
168
  expect(impactAsync).not.toHaveBeenCalled();
169
+ // Web is a deliberate no-op, not a missing peer — warning here would fire on
170
+ // every web app that renders a Bloom button.
171
+ expect(warn).not.toHaveBeenCalled();
47
172
  });
48
173
 
49
174
  it('no-ops when disabled via BloomHapticsProvider', () => {
50
- Platform.OS = 'ios';
175
+ ReactNative.Platform.OS = 'ios';
176
+ const { useHaptics, BloomHapticsProvider, impactAsync } = loadWithHaptics();
51
177
  const wrapper = ({ children }: { children: React.ReactNode }) => (
52
178
  <BloomHapticsProvider enabled={false}>{children}</BloomHapticsProvider>
53
179
  );
54
180
  const { result } = renderHook(() => useHaptics(), { wrapper });
55
181
  act(() => result.current('medium'));
56
182
  expect(impactAsync).not.toHaveBeenCalled();
183
+ expect(warn).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('degrades to a no-op instead of throwing when the peer is absent', () => {
187
+ ReactNative.Platform.OS = 'ios';
188
+ const { useHaptics } = loadWithHaptics(() => {
189
+ throw new Error(`Cannot find module '${HAPTICS}'`);
190
+ });
191
+ const { result } = renderHook(() => useHaptics());
192
+ expect(() => act(() => result.current('heavy'))).not.toThrow();
193
+ });
194
+
195
+ it('names the missing package once, not once per call', () => {
196
+ ReactNative.Platform.OS = 'ios';
197
+ const { useHaptics } = loadWithHaptics(() => {
198
+ throw new Error(`Cannot find module '${HAPTICS}'`);
199
+ });
200
+ const { result } = renderHook(() => useHaptics());
201
+
202
+ act(() => result.current('heavy'));
203
+ act(() => result.current('light'));
204
+
205
+ expect(warn).toHaveBeenCalledTimes(1);
206
+ const [message] = warn.mock.calls[0] as [string];
207
+ // An actionable warning has to carry all three: what broke, which package,
208
+ // and how to fix it. A bare "haptics missing" sends the reader to Bloom.
209
+ expect(message).toContain(HAPTICS);
210
+ expect(message).toContain('inert');
211
+ expect(message).toContain('expo install');
212
+ // …and the underlying resolution error, so a shape/version failure is not
213
+ // reported as if the package were simply not installed.
214
+ expect(message).toContain('Cannot find module');
215
+ });
216
+
217
+ it('warns when the peer resolves but has no impactAsync', () => {
218
+ ReactNative.Platform.OS = 'ios';
219
+ const { useHaptics } = loadWithHaptics(() => ({ notificationAsync: jest.fn() }));
220
+ const { result } = renderHook(() => useHaptics());
221
+
222
+ act(() => result.current());
223
+
224
+ expect(warn).toHaveBeenCalledTimes(1);
225
+ expect((warn.mock.calls[0] as [string])[0]).toContain('without an `impactAsync`');
226
+ });
227
+
228
+ it('stays silent in production', () => {
229
+ ReactNative.Platform.OS = 'ios';
230
+ const previous = process.env.NODE_ENV;
231
+ // Metro and Vite fold this branch away entirely; jest cannot, so the guard
232
+ // is asserted at runtime instead.
233
+ process.env.NODE_ENV = 'production';
234
+ try {
235
+ const { useHaptics } = loadWithHaptics(() => {
236
+ throw new Error(`Cannot find module '${HAPTICS}'`);
237
+ });
238
+ const { result } = renderHook(() => useHaptics());
239
+ act(() => result.current());
240
+ expect(warn).not.toHaveBeenCalled();
241
+ } finally {
242
+ process.env.NODE_ENV = previous;
243
+ }
57
244
  });
58
245
  });
@@ -1,5 +1,4 @@
1
- import React, { forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
2
- import { StyleSheet, Text } from 'react-native';
1
+ import React, { forwardRef, useCallback, useImperativeHandle } from 'react';
3
2
  import Animated, {
4
3
  Easing,
5
4
  useAnimatedProps,
@@ -7,13 +6,10 @@ import Animated, {
7
6
  withDelay,
8
7
  withTiming,
9
8
  } from 'react-native-reanimated';
9
+ import Svg, { Circle, Path } from 'react-native-svg';
10
10
 
11
- import { getSvgModule } from '../avatar/svg-module';
12
11
  import { useTheme } from '../theme';
13
12
 
14
- /** The resolved `react-native-svg` module (non-null branch). */
15
- type SvgModule = NonNullable<ReturnType<typeof getSvgModule>>;
16
-
17
13
  /** Square viewBox the ring + check are drawn on. */
18
14
  const VIEW_BOX = 52;
19
15
  /** Ring radius within the viewBox. */
@@ -43,34 +39,20 @@ export interface AnimatedCheckProps {
43
39
  }
44
40
 
45
41
  // `Animated.createAnimatedComponent` must be called once per underlying
46
- // component (calling it per-render would remount and break the animation).
47
- // react-native-svg is a lazy/optional peer, so we build the animated wrappers
48
- // the first time it resolves and cache them at module scope.
49
- function buildAnimatedSvg(svg: SvgModule) {
50
- return {
51
- Circle: Animated.createAnimatedComponent(svg.Circle),
52
- Path: Animated.createAnimatedComponent(svg.Path),
53
- };
54
- }
55
-
56
- let animatedSvgCache: ReturnType<typeof buildAnimatedSvg> | null = null;
57
-
58
- function getAnimatedSvg(svg: SvgModule): ReturnType<typeof buildAnimatedSvg> {
59
- if (!animatedSvgCache) animatedSvgCache = buildAnimatedSvg(svg);
60
- return animatedSvgCache;
61
- }
42
+ // component calling it per render would remount the element and break the
43
+ // animation so the wrappers are built once at module scope.
44
+ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
45
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
62
46
 
63
- interface AnimatedCheckSvgProps {
64
- size: number;
65
- color: string;
66
- svg: SvgModule;
67
- }
68
-
69
- /** The animated SVG implementation, rendered only when react-native-svg exists. */
70
- const AnimatedCheckSvg = forwardRef<AnimatedCheckRef, AnimatedCheckSvgProps>(
71
- function AnimatedCheckSvg({ size, color, svg }, ref) {
72
- const { default: Svg } = svg;
73
- const { Circle, Path } = getAnimatedSvg(svg);
47
+ /**
48
+ * A draw-on checkmark: a ring strokes on, then the check strokes in. Expose a
49
+ * ref and call `ref.current?.play()` to (re)play — e.g. on a successful submit.
50
+ * Ported from Bluesky's `AnimatedCheck`.
51
+ */
52
+ export const AnimatedCheck = forwardRef<AnimatedCheckRef, AnimatedCheckProps>(
53
+ function AnimatedCheck({ size = 24, color }, ref) {
54
+ const theme = useTheme();
55
+ const resolvedColor = color ?? theme.colors.success;
74
56
 
75
57
  const circleProgress = useSharedValue(0);
76
58
  const checkProgress = useSharedValue(0);
@@ -104,20 +86,20 @@ const AnimatedCheckSvg = forwardRef<AnimatedCheckRef, AnimatedCheckSvgProps>(
104
86
 
105
87
  return (
106
88
  <Svg fill="none" viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`} width={size} height={size}>
107
- <Circle
89
+ <AnimatedCircle
108
90
  animatedProps={circleAnimatedProps}
109
91
  cx={VIEW_BOX / 2}
110
92
  cy={VIEW_BOX / 2}
111
93
  r={CIRCLE_RADIUS}
112
94
  fill="none"
113
- stroke={color}
95
+ stroke={resolvedColor}
114
96
  strokeWidth={STROKE_WIDTH}
115
97
  strokeDasharray={CIRCLE_LENGTH}
116
98
  />
117
- <Path
99
+ <AnimatedPath
118
100
  animatedProps={checkAnimatedProps}
119
101
  d={CHECK_PATH}
120
- stroke={color}
102
+ stroke={resolvedColor}
121
103
  strokeWidth={STROKE_WIDTH}
122
104
  strokeDasharray={CHECK_LENGTH}
123
105
  />
@@ -126,53 +108,4 @@ const AnimatedCheckSvg = forwardRef<AnimatedCheckRef, AnimatedCheckSvgProps>(
126
108
  },
127
109
  );
128
110
 
129
- interface StaticCheckProps {
130
- size: number;
131
- color: string;
132
- }
133
-
134
- /**
135
- * Static fallback rendered when react-native-svg is unavailable (e.g. a web
136
- * bundle that omits the optional peer). Draws a plain Unicode check so the
137
- * component still communicates "done"; `play()` is a no-op.
138
- */
139
- const StaticCheck = forwardRef<AnimatedCheckRef, StaticCheckProps>(
140
- function StaticCheck({ size, color }, ref) {
141
- useImperativeHandle(ref, () => ({ play: () => undefined }), []);
142
- return (
143
- <Text
144
- accessibilityElementsHidden
145
- style={[styles.staticCheck, { width: size, fontSize: size * 0.8, lineHeight: size, color }]}
146
- >
147
- {'✓'}
148
- </Text>
149
- );
150
- },
151
- );
152
-
153
- /**
154
- * A draw-on checkmark: a ring strokes on, then the check strokes in. Expose a
155
- * ref and call `ref.current?.play()` to (re)play — e.g. on a successful submit.
156
- * Ported from Bluesky's `AnimatedCheck`.
157
- */
158
- export const AnimatedCheck = forwardRef<AnimatedCheckRef, AnimatedCheckProps>(
159
- function AnimatedCheck({ size = 24, color }, ref) {
160
- const theme = useTheme();
161
- const resolvedColor = color ?? theme.colors.success;
162
- const svg = useMemo(() => getSvgModule(), []);
163
-
164
- if (!svg) {
165
- return <StaticCheck ref={ref} size={size} color={resolvedColor} />;
166
- }
167
- return <AnimatedCheckSvg ref={ref} size={size} color={resolvedColor} svg={svg} />;
168
- },
169
- );
170
-
171
111
  AnimatedCheck.displayName = 'AnimatedCheck';
172
-
173
- const styles = StyleSheet.create({
174
- staticCheck: {
175
- textAlign: 'center',
176
- fontWeight: '700',
177
- },
178
- });
@@ -1,6 +1,7 @@
1
1
  import React, { memo, useMemo, useRef, useState } from 'react';
2
2
  import { View, Image, StyleSheet, Text, TouchableOpacity } from 'react-native';
3
3
  import type { TextStyle } from 'react-native';
4
+ import Svg, { ClipPath, Defs, Image as SvgImage, Path } from 'react-native-svg';
4
5
 
5
6
  import { useTheme } from '../theme/use-theme';
6
7
  import { useImageResolver } from '../image-resolver/context';
@@ -8,7 +9,6 @@ import { Z_INDEX } from '../styles/z-index';
8
9
  import { useAvatarPlaceholder } from './placeholder-context';
9
10
  import { LiveBadge } from './LiveBadge';
10
11
  import { AvatarRing, getRingOuterSize } from './AvatarRing';
11
- import { getSvgModule } from './svg-module';
12
12
  import { SQUIRCLE_PATH } from './squircle-path';
13
13
  import type { AvatarProps, AvatarRingConfig } from './types';
14
14
 
@@ -62,16 +62,6 @@ function SquircleImage({
62
62
  onError: () => void;
63
63
  }) {
64
64
  const clipId = useMemo(() => `bloom-sqc${clipIdCounter++}`, []);
65
- const svg = getSvgModule();
66
- if (!svg) {
67
- // Fallback to a circle if react-native-svg is not installed. The ring (if
68
- // any) is drawn separately by AvatarRing, which also degrades to a circle.
69
- return (
70
- <CircleFallback size={size} fallbackColor={fallbackColor} icon={placeholderIcon} name={name} />
71
- );
72
- }
73
-
74
- const { default: Svg, Defs, ClipPath, Path, Image: SvgImage } = svg;
75
65
 
76
66
  const href = uri ? { uri } : fallbackSource;
77
67
  if (!href) {
@@ -1,8 +1,8 @@
1
1
  import React, { memo, useMemo } from 'react';
2
2
  import { View, StyleSheet } from 'react-native';
3
+ import Svg, { Circle, Defs, LinearGradient, Path, Stop } from 'react-native-svg';
3
4
 
4
5
  import { Z_INDEX } from '../styles/z-index';
5
- import { getSvgModule } from './svg-module';
6
6
  import { SQUIRCLE_PATH } from './squircle-path';
7
7
  import type { AvatarRingGradientDirection, AvatarShape } from './types';
8
8
 
@@ -56,10 +56,7 @@ function gradientCoords(direction: AvatarRingGradientDirection) {
56
56
  *
57
57
  * - Solid circle → a plain bordered `<View>` (no `react-native-svg` needed).
58
58
  * - Solid squircle → an SVG `<Path>` stroking the squircle outline.
59
- * - Gradient (either shape) → an SVG `<LinearGradient>` stroke; degrades to a
60
- * solid ring of the first color when `react-native-svg` is unavailable (a
61
- * squircle then degrades to a circle border, matching the avatar's own no-SVG
62
- * circle fallback).
59
+ * - Gradient (either shape) → an SVG `<LinearGradient>` stroke.
63
60
  *
64
61
  * Internal to the avatar family — not exported from the package.
65
62
  */
@@ -79,19 +76,15 @@ const AvatarRingComponent: React.FC<AvatarRingProps> = ({
79
76
 
80
77
  const outer = getRingOuterSize(size, width, gap);
81
78
  // The ring's visible band is always the outermost `width` px inside the outer
82
- // box; when it degrades to a circle border it is a full circle → radius
83
- // `outer / 2`.
79
+ // box; drawn as a plain border it is a full circle → radius `outer / 2`.
84
80
  const ringRadius = outer / 2;
85
81
 
86
- // `react-native-svg` is required for gradients (both shapes) and for the
87
- // squircle outline. A solid circle ring is a plain border and never needs it.
82
+ // SVG is only needed for gradients (both shapes) and for the squircle
83
+ // outline. A solid circle ring is a plain bordered View cheaper, and it
84
+ // keeps the common case off the SVG renderer entirely.
88
85
  const needsSvg = wantsGradient || shape === 'squircle';
89
- const svg = needsSvg ? getSvgModule() : null;
90
86
 
91
- // No SVG available (or a solid circle, which never needs SVG): draw a plain
92
- // bordered View. A gradient degrades to its first color; a squircle degrades
93
- // to a circle border.
94
- if (!svg) {
87
+ if (!needsSvg) {
95
88
  return (
96
89
  <View
97
90
  pointerEvents="none"
@@ -110,7 +103,6 @@ const AvatarRingComponent: React.FC<AvatarRingProps> = ({
110
103
  );
111
104
  }
112
105
 
113
- const { default: Svg, Defs, LinearGradient, Stop, Circle, Path } = svg;
114
106
  const stroke = wantsGradient ? `url(#${gradientId})` : solidColor;
115
107
  const coords = gradientCoords(gradientDirection);
116
108
  const gradientDefs = wantsGradient ? (
@@ -13,8 +13,14 @@ import {
13
13
  // Keyboard handling — only on native platforms. On web, keyboard events are
14
14
  // handled by the browser (and the web build resolves to `index.web.tsx`, which
15
15
  // never imports this module). `react-native-keyboard-controller` is an OPTIONAL
16
- // dependency: it is lazily `require`d below and everything no-ops when it is
17
- // absent (or on web).
16
+ // dependency, loaded below through the shape Metro collects as an optional
17
+ // dependency: a `require()` of a STRING LITERAL as a direct statement of a `try`
18
+ // block — the real module when it is installed, `null` in the dependency map
19
+ // when it is not, so the failure lands in the `catch` and everything no-ops.
20
+ // The specifier used to be bound to a local `const` first, which also worked
21
+ // (Metro evaluates the constant); the literal is simply the form the rule can be
22
+ // checked as. See `connection-status/netinfo.ts` for the full rule, including
23
+ // the specifier shape that does NOT work.
18
24
  const noopKeyboardHandler = (_handlers: Record<string, (e: { height: number }) => void>, _deps: unknown[]) => {};
19
25
  let useKeyboardHandler: (handlers: Record<string, (e: { height: number }) => void>, deps: unknown[]) => void = noopKeyboardHandler;
20
26
 
@@ -34,8 +40,7 @@ let KeyboardProvider: React.ComponentType<{ children: React.ReactNode }> = Passt
34
40
 
35
41
  if (Platform.OS !== 'web' && typeof require !== 'undefined') {
36
42
  try {
37
- const moduleName = 'react-native-keyboard-controller';
38
- const keyboardController = require(moduleName);
43
+ const keyboardController = require('react-native-keyboard-controller');
39
44
  useKeyboardHandler = keyboardController.useKeyboardHandler ?? noopKeyboardHandler;
40
45
  KeyboardProvider = keyboardController.KeyboardProvider ?? PassthroughKeyboardProvider;
41
46
  } catch {
@@ -6,14 +6,16 @@
6
6
  * again in each app, and competes with the app's own chrome. The toast host is
7
7
  * already mounted, already themed, already stacks.
8
8
  *
9
- * NATIVE variant: `@react-native-community/netinfo` (an Expo-managed dependency
10
- * every Oxy app already ships) reports both link state and reachability. The
11
- * web fork uses `navigator.onLine` instead so Vite/SPA consumers need no extra
12
- * dependency at all.
9
+ * NATIVE variant: `@react-native-community/netinfo` reports both link state and
10
+ * reachability. It is an OPTIONAL peer loaded through `./netinfo` see that
11
+ * file for why it is not a static import, and why an app that omits it gets an
12
+ * inert component rather than a broken bundle. The web fork uses
13
+ * `navigator.onLine` instead so Vite/SPA consumers need no extra dependency at
14
+ * all.
13
15
  */
14
16
  import { useEffect, useState } from 'react';
15
- import NetInfo from '@react-native-community/netinfo';
16
17
 
18
+ import { loadNetInfo, warnNetInfoUnavailable } from './netinfo';
17
19
  import { useConnectionStatusToasts, type ConnectionStatusToastsProps } from './shared';
18
20
 
19
21
  export type { ConnectionStatusToastsProps } from './shared';
@@ -23,10 +25,18 @@ export function ConnectionStatusToasts(props: ConnectionStatusToastsProps = {}):
23
25
  const [isOnline, setIsOnline] = useState<boolean | null>(null);
24
26
 
25
27
  useEffect(() => {
28
+ const netInfo = loadNetInfo();
29
+ if (!netInfo) {
30
+ // `isOnline` stays `null`, which `useConnectionStatusToasts` already reads
31
+ // as "not determined yet", so the component is inert instead of broken.
32
+ warnNetInfoUnavailable();
33
+ return undefined;
34
+ }
35
+
26
36
  // `isInternetReachable` is null until the first probe resolves; treat that
27
37
  // as "connected as far as we know" so a captive-portal check never flashes
28
38
  // an outage on launch.
29
- return NetInfo.addEventListener((state) => {
39
+ return netInfo.addEventListener((state) => {
30
40
  setIsOnline(Boolean(state.isConnected && state.isInternetReachable !== false));
31
41
  });
32
42
  }, []);