@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.
- package/lib/commonjs/animated-check/AnimatedCheck.js +22 -93
- package/lib/commonjs/animated-check/AnimatedCheck.js.map +1 -1
- package/lib/commonjs/avatar/Avatar.js +6 -24
- package/lib/commonjs/avatar/Avatar.js.map +1 -1
- package/lib/commonjs/avatar/AvatarRing.js +14 -30
- package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
- package/lib/commonjs/bottom-sheet/index.js +9 -4
- package/lib/commonjs/bottom-sheet/index.js.map +1 -1
- package/lib/commonjs/connection-status/index.js +16 -7
- package/lib/commonjs/connection-status/index.js.map +1 -1
- package/lib/commonjs/connection-status/netinfo.js +119 -0
- package/lib/commonjs/connection-status/netinfo.js.map +1 -0
- package/lib/commonjs/hooks/haptics-module.js +109 -0
- package/lib/commonjs/hooks/haptics-module.js.map +1 -0
- package/lib/commonjs/hooks/useHaptics.js +6 -8
- package/lib/commonjs/hooks/useHaptics.js.map +1 -1
- package/lib/commonjs/loading/SpinnerIcon.js +22 -55
- package/lib/commonjs/loading/SpinnerIcon.js.map +1 -1
- package/lib/commonjs/theme/adaptive-colors.js +20 -4
- package/lib/commonjs/theme/adaptive-colors.js.map +1 -1
- package/lib/commonjs/theme/color-scope/style-builder.js +107 -6
- package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
- package/lib/commonjs/theme/init-css-interop.js +13 -11
- package/lib/commonjs/theme/init-css-interop.js.map +1 -1
- package/lib/module/animated-check/AnimatedCheck.js +22 -91
- package/lib/module/animated-check/AnimatedCheck.js.map +1 -1
- package/lib/module/avatar/Avatar.js +1 -19
- package/lib/module/avatar/Avatar.js.map +1 -1
- package/lib/module/avatar/AvatarRing.js +7 -23
- package/lib/module/avatar/AvatarRing.js.map +1 -1
- package/lib/module/bottom-sheet/index.js +9 -4
- package/lib/module/bottom-sheet/index.js.map +1 -1
- package/lib/module/connection-status/index.js +16 -6
- package/lib/module/connection-status/index.js.map +1 -1
- package/lib/module/connection-status/netinfo.js +114 -0
- package/lib/module/connection-status/netinfo.js.map +1 -0
- package/lib/module/hooks/haptics-module.js +104 -0
- package/lib/module/hooks/haptics-module.js.map +1 -0
- package/lib/module/hooks/useHaptics.js +7 -11
- package/lib/module/hooks/useHaptics.js.map +1 -1
- package/lib/module/loading/SpinnerIcon.js +13 -46
- package/lib/module/loading/SpinnerIcon.js.map +1 -1
- package/lib/module/loading/index.web.js +2 -2
- package/lib/module/theme/adaptive-colors.js +20 -4
- package/lib/module/theme/adaptive-colors.js.map +1 -1
- package/lib/module/theme/color-scope/style-builder.js +107 -6
- package/lib/module/theme/color-scope/style-builder.js.map +1 -1
- package/lib/module/theme/init-css-interop.js +13 -11
- package/lib/module/theme/init-css-interop.js.map +1 -1
- package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/connection-status/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/connection-status/netinfo.d.ts +69 -0
- package/lib/typescript/commonjs/connection-status/netinfo.d.ts.map +1 -0
- package/lib/typescript/commonjs/hooks/haptics-module.d.ts +58 -0
- package/lib/typescript/commonjs/hooks/haptics-module.d.ts.map +1 -0
- package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -1
- package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts +9 -2
- package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/adaptive-colors.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/init-css-interop.d.ts.map +1 -1
- package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -1
- package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/module/connection-status/index.d.ts.map +1 -1
- package/lib/typescript/module/connection-status/netinfo.d.ts +69 -0
- package/lib/typescript/module/connection-status/netinfo.d.ts.map +1 -0
- package/lib/typescript/module/hooks/haptics-module.d.ts +58 -0
- package/lib/typescript/module/hooks/haptics-module.d.ts.map +1 -0
- package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -1
- package/lib/typescript/module/loading/SpinnerIcon.d.ts +9 -2
- package/lib/typescript/module/loading/SpinnerIcon.d.ts.map +1 -1
- package/lib/typescript/module/theme/adaptive-colors.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/module/theme/init-css-interop.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/color-scope-nativewind.test.ts +135 -0
- package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
- package/src/__tests__/optional-peer-imports.test.ts +406 -0
- package/src/__tests__/svg-required-peer.test.tsx +75 -0
- package/src/__tests__/useHaptics.test.tsx +203 -16
- package/src/animated-check/AnimatedCheck.tsx +19 -86
- package/src/avatar/Avatar.tsx +1 -11
- package/src/avatar/AvatarRing.tsx +7 -15
- package/src/bottom-sheet/index.tsx +9 -4
- package/src/connection-status/index.tsx +16 -6
- package/src/connection-status/netinfo.ts +131 -0
- package/src/hooks/haptics-module.ts +118 -0
- package/src/hooks/useHaptics.tsx +6 -9
- package/src/loading/SpinnerIcon.tsx +13 -52
- package/src/loading/index.web.ts +2 -2
- package/src/theme/adaptive-colors.ts +20 -4
- package/src/theme/color-scope/style-builder.ts +118 -6
- package/src/theme/init-css-interop.ts +13 -11
- package/lib/commonjs/avatar/svg-module.js +0 -15
- package/lib/commonjs/avatar/svg-module.js.map +0 -1
- package/lib/commonjs/utils/lazy-require.js +0 -37
- package/lib/commonjs/utils/lazy-require.js.map +0 -1
- package/lib/module/avatar/svg-module.js +0 -11
- package/lib/module/avatar/svg-module.js.map +0 -1
- package/lib/module/utils/lazy-require.js +0 -33
- package/lib/module/utils/lazy-require.js.map +0 -1
- package/lib/typescript/commonjs/avatar/svg-module.d.ts +0 -8
- package/lib/typescript/commonjs/avatar/svg-module.d.ts.map +0 -1
- package/lib/typescript/commonjs/utils/lazy-require.d.ts +0 -15
- package/lib/typescript/commonjs/utils/lazy-require.d.ts.map +0 -1
- package/lib/typescript/module/avatar/svg-module.d.ts +0 -8
- package/lib/typescript/module/avatar/svg-module.d.ts.map +0 -1
- package/lib/typescript/module/utils/lazy-require.d.ts +0 -15
- package/lib/typescript/module/utils/lazy-require.d.ts.map +0 -1
- package/src/avatar/svg-module.ts +0 -11
- package/src/utils/lazy-require.ts +0 -31
|
@@ -1,58 +1,245 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import
|
|
2
|
+
import * as ReactNative from 'react-native';
|
|
3
3
|
import { act, renderHook } from '@testing-library/react-native';
|
|
4
|
-
import
|
|
4
|
+
import type * as ExpoHaptics from 'expo-haptics';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { HapticsLike } from '../hooks/haptics-module';
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
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
|
-
<
|
|
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={
|
|
95
|
+
stroke={resolvedColor}
|
|
114
96
|
strokeWidth={STROKE_WIDTH}
|
|
115
97
|
strokeDasharray={CIRCLE_LENGTH}
|
|
116
98
|
/>
|
|
117
|
-
<
|
|
99
|
+
<AnimatedPath
|
|
118
100
|
animatedProps={checkAnimatedProps}
|
|
119
101
|
d={CHECK_PATH}
|
|
120
|
-
stroke={
|
|
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
|
-
});
|
package/src/avatar/Avatar.tsx
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
-
//
|
|
87
|
-
//
|
|
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
|
-
|
|
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
|
|
17
|
-
//
|
|
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
|
|
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`
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
39
|
+
return netInfo.addEventListener((state) => {
|
|
30
40
|
setIsOnline(Boolean(state.isConnected && state.isInternetReachable !== false));
|
|
31
41
|
});
|
|
32
42
|
}, []);
|