@oxyhq/services 22.6.4 → 22.7.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/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +109 -14
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +66 -1
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/fileManagement/AnimatedButton.js +7 -1
- package/lib/commonjs/ui/components/fileManagement/AnimatedButton.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +43 -0
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/screens/FileManagementScreen.js +6 -51
- package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +130 -57
- package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +64 -0
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/fileManagement/AnimatedButton.js +7 -1
- package/lib/module/ui/components/fileManagement/AnimatedButton.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +44 -1
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/screens/FileManagementScreen.js +1 -46
- package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +130 -57
- package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +12 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/fileManagement/AnimatedButton.d.ts +1 -0
- package/lib/typescript/commonjs/ui/components/fileManagement/AnimatedButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +12 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/fileManagement/AnimatedButton.d.ts +1 -0
- package/lib/typescript/module/ui/components/fileManagement/AnimatedButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ui/boot/runProviderColdBoot.ts +67 -0
- package/src/ui/components/fileManagement/AnimatedButton.tsx +9 -1
- package/src/ui/context/OxyContext.tsx +43 -1
- package/src/ui/screens/FileManagementScreen.tsx +1 -54
- package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +131 -54
|
@@ -18,6 +18,59 @@ import type { CommitInput } from '../context/oxyContextTypes';
|
|
|
18
18
|
/** How long the cold boot waits for the post-boot SessionClient handoff (ms). */
|
|
19
19
|
export const SESSION_HANDOFF_DEADLINE_MS = 6000;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* HARD overall deadline (ms) for the whole `runSessionColdBoot` step chain.
|
|
23
|
+
*
|
|
24
|
+
* Bounds time-to-route: routing gates on `isAuthResolved`, which resolves when
|
|
25
|
+
* the cold boot finishes, so a network step that never settles (a black-hole
|
|
26
|
+
* network that neither connects nor rejects) would otherwise hang routing
|
|
27
|
+
* indefinitely. 12s comfortably exceeds the healthy worst case of the
|
|
28
|
+
* sequential, single-attempt (`retry:false`), 5s-capped network steps, so it is
|
|
29
|
+
* INERT on healthy loads and only trips on a pathological network. Offline
|
|
30
|
+
* devices short-circuit far sooner via the connectivity hint below.
|
|
31
|
+
*/
|
|
32
|
+
export const COLD_BOOT_OVERALL_DEADLINE_MS = 12_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Timeout (ms) for the best-effort native connectivity probe. Kept tight so the
|
|
36
|
+
* probe never itself adds meaningful latency to a healthy boot — an unknown
|
|
37
|
+
* result within this window is treated as "online".
|
|
38
|
+
*/
|
|
39
|
+
const OFFLINE_PROBE_TIMEOUT_MS = 500;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort, FAST connectivity probe run once before the cold boot.
|
|
43
|
+
*
|
|
44
|
+
* Returns `true` ONLY on an EXPLICIT disconnected verdict; every ambiguous
|
|
45
|
+
* outcome (probe timeout, unknown/`null` state, NetInfo unavailable, a thrown
|
|
46
|
+
* error) resolves to `false` (assume online) so a flaky probe can never falsely
|
|
47
|
+
* skip a real sign-in. Never rejects. On web it reads `navigator.onLine`; on
|
|
48
|
+
* native it races `NetInfo.fetch()` against {@link OFFLINE_PROBE_TIMEOUT_MS},
|
|
49
|
+
* mirroring the existing NetInfo dynamic-import pattern in `OxyProvider`.
|
|
50
|
+
*/
|
|
51
|
+
async function detectOfflineHint(): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
if (isWebBrowser()) {
|
|
54
|
+
const online = (globalThis as { navigator?: { onLine?: boolean } }).navigator?.onLine;
|
|
55
|
+
// Only an explicit `false` is an offline verdict; `undefined` ⇒ assume online.
|
|
56
|
+
return online === false;
|
|
57
|
+
}
|
|
58
|
+
const NetInfo = await import('@react-native-community/netinfo');
|
|
59
|
+
const state = await Promise.race([
|
|
60
|
+
NetInfo.default.fetch(),
|
|
61
|
+
new Promise<null>((resolve) => {
|
|
62
|
+
setTimeout(() => resolve(null), OFFLINE_PROBE_TIMEOUT_MS);
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
// `null` ⇒ the probe timed out (unknown → assume online). Only an explicit
|
|
66
|
+
// `isConnected === false` disables the network steps.
|
|
67
|
+
return state?.isConnected === false;
|
|
68
|
+
} catch {
|
|
69
|
+
// NetInfo missing / probe threw — never block sign-in on a probe failure.
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
export interface RunProviderColdBootOptions {
|
|
22
75
|
oxyServices: OxyServices;
|
|
23
76
|
authStore: AuthStateStore;
|
|
@@ -79,10 +132,24 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
|
|
|
79
132
|
return;
|
|
80
133
|
}
|
|
81
134
|
|
|
135
|
+
// Best-effort connectivity probe up front: an EXPLICIT offline verdict skips
|
|
136
|
+
// the two doomed network steps so routing settles immediately instead of
|
|
137
|
+
// burning the overall deadline on a mint that cannot succeed. Any ambiguity
|
|
138
|
+
// resolves to "online" — the network steps still run.
|
|
139
|
+
const offline = await detectOfflineHint();
|
|
140
|
+
|
|
82
141
|
const outcome = await runSessionColdBoot({
|
|
83
142
|
oxy: oxyServices,
|
|
84
143
|
store: authStore,
|
|
85
144
|
platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
|
|
145
|
+
overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE_MS,
|
|
146
|
+
isOffline: () => offline,
|
|
147
|
+
onStepDeadline: (stepId) => {
|
|
148
|
+
loggerUtil.warn(
|
|
149
|
+
`Cold-boot step "${stepId}" exceeded the ${COLD_BOOT_OVERALL_DEADLINE_MS}ms overall deadline — abandoned; routing proceeds signed-out`,
|
|
150
|
+
{ component: 'runProviderColdBoot', method: 'onStepDeadline' },
|
|
151
|
+
);
|
|
152
|
+
},
|
|
86
153
|
onSession: async (session) => {
|
|
87
154
|
// Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
|
|
88
155
|
// in-memory SessionClient host so sockets + tab-focus re-mint can use it.
|
|
@@ -10,6 +10,7 @@ interface AnimatedButtonProps {
|
|
|
10
10
|
primaryColor: string;
|
|
11
11
|
textColor: string;
|
|
12
12
|
style: Record<string, unknown>;
|
|
13
|
+
accessibilityLabel: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -23,6 +24,7 @@ export const AnimatedButton: React.FC<AnimatedButtonProps> = ({
|
|
|
23
24
|
primaryColor,
|
|
24
25
|
textColor,
|
|
25
26
|
style,
|
|
27
|
+
accessibilityLabel,
|
|
26
28
|
}) => {
|
|
27
29
|
const animatedValue = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
28
30
|
|
|
@@ -41,7 +43,13 @@ export const AnimatedButton: React.FC<AnimatedButtonProps> = ({
|
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
return (
|
|
44
|
-
<TouchableOpacity
|
|
46
|
+
<TouchableOpacity
|
|
47
|
+
onPress={onPress}
|
|
48
|
+
activeOpacity={0.7}
|
|
49
|
+
accessibilityRole="button"
|
|
50
|
+
accessibilityLabel={accessibilityLabel}
|
|
51
|
+
accessibilityState={{ selected: isSelected }}
|
|
52
|
+
>
|
|
45
53
|
<Animated.View style={[style, { backgroundColor }]}>
|
|
46
54
|
<Animated.View>
|
|
47
55
|
<MaterialCommunityIcons
|
|
@@ -45,7 +45,7 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
|
|
|
45
45
|
import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
|
|
46
46
|
import type { RouteName } from '../navigation/routes';
|
|
47
47
|
import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
|
|
48
|
-
import { useQueryClient } from '@tanstack/react-query';
|
|
48
|
+
import { useQueryClient, onlineManager } from '@tanstack/react-query';
|
|
49
49
|
import { clearQueryCache } from '../hooks/queryClient';
|
|
50
50
|
import { useAvatarPicker } from '../hooks/useAvatarPicker';
|
|
51
51
|
import { useAccountStore } from '../stores/accountStore';
|
|
@@ -848,6 +848,41 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
848
848
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
|
849
849
|
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
|
|
850
850
|
|
|
851
|
+
// Reconnect heal: when connectivity transitions offline→online while there is
|
|
852
|
+
// no live access token but a persisted device credential exists, re-mint ONCE
|
|
853
|
+
// through the SAME shared single-flight lane the scheduler / tab-focus
|
|
854
|
+
// reconcile / 401 path use — never a private mint lane, so this can't
|
|
855
|
+
// double-rotate the device secret against them. A device that cold-booted
|
|
856
|
+
// OFFLINE skipped the network mint step, so its session is otherwise only
|
|
857
|
+
// recovered by the next scheduled re-mint; healing on the reconnect edge makes
|
|
858
|
+
// recovery immediate. `onlineManager` is fed by OxyProvider's existing NetInfo
|
|
859
|
+
// (native) / `navigator.onLine` (web) listener, so this reuses the one
|
|
860
|
+
// connectivity signal instead of opening a second NetInfo subscription. Works
|
|
861
|
+
// on native and web alike.
|
|
862
|
+
useEffect(() => {
|
|
863
|
+
// Seed from the current verdict so the first callback heals only on a
|
|
864
|
+
// genuine offline→online edge, never an initial subscribe fan-out.
|
|
865
|
+
let previousOnline = onlineManager.isOnline();
|
|
866
|
+
return onlineManager.subscribe((online: boolean) => {
|
|
867
|
+
const wasOffline = previousOnline === false;
|
|
868
|
+
previousOnline = online;
|
|
869
|
+
if (!online || !wasOffline) {
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
void (async () => {
|
|
873
|
+
if (oxyServices.getAccessToken() || !sessionClientHost.getDeviceCredential()) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
await oxyServices.httpService.refreshAccessToken('preflight');
|
|
877
|
+
if (!oxyServices.getAccessToken()) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
await sessionClient.bootstrap();
|
|
881
|
+
await syncFromClient();
|
|
882
|
+
})().catch(() => undefined);
|
|
883
|
+
});
|
|
884
|
+
}, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
|
|
885
|
+
|
|
851
886
|
// Exposed `refreshSessions`: re-bootstrap the server-authoritative device
|
|
852
887
|
// state and reproject — the manual counterpart to the realtime socket.
|
|
853
888
|
const refreshSessionsForContext = useCallback(async (): Promise<void> => {
|
|
@@ -877,6 +912,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
877
912
|
[sessionClient, syncFromClient],
|
|
878
913
|
);
|
|
879
914
|
|
|
915
|
+
// Thin passthroughs to the platform-agnostic KeyManager. NOTE: both now THROW
|
|
916
|
+
// `IdentityUnavailableError` (from `@oxyhq/core`) when identity storage is
|
|
917
|
+
// locked/unreadable, instead of flattening that into `false`/`null`. We keep
|
|
918
|
+
// the signatures and deliberately let the typed error PROPAGATE to the caller
|
|
919
|
+
// — a locked keychain must never be misreported as "no identity". `hasIdentity`
|
|
920
|
+
// still resolves `false` and `getPublicKey` still resolves `null` for a genuine
|
|
921
|
+
// absence.
|
|
880
922
|
const hasIdentity = useCallback(async (): Promise<boolean> => KeyManager.hasIdentity(), []);
|
|
881
923
|
const getPublicKey = useCallback(async (): Promise<string | null> => KeyManager.getPublicKey(), []);
|
|
882
924
|
|
|
@@ -40,6 +40,7 @@ import { useFileUploadState } from './fileManagement/hooks/useFileUploadState';
|
|
|
40
40
|
import PhotoPickerView from './fileManagement/PhotoPickerSection';
|
|
41
41
|
import FileListSection, { type FileListItem } from './fileManagement/FileListSection';
|
|
42
42
|
import UploadBar from './fileManagement/UploadBar';
|
|
43
|
+
import { AnimatedButton } from '../components/fileManagement/AnimatedButton';
|
|
43
44
|
|
|
44
45
|
// Genuinely-inline-only styles: `viewModeButton` is spread into an Animated.View
|
|
45
46
|
// style array (interpolated backgroundColor), and the photo tiles are
|
|
@@ -58,60 +59,6 @@ const screenStyles = StyleSheet.create({
|
|
|
58
59
|
justifiedPhotoImage: { width: '100%', height: '100%', borderRadius: 6 },
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
// Animated button component for smooth transitions
|
|
62
|
-
const AnimatedButton: React.FC<{
|
|
63
|
-
isSelected: boolean;
|
|
64
|
-
onPress: () => void;
|
|
65
|
-
icon: string;
|
|
66
|
-
primaryColor: string;
|
|
67
|
-
textColor: string;
|
|
68
|
-
style: Record<string, unknown>;
|
|
69
|
-
accessibilityLabel: string;
|
|
70
|
-
}> = ({ isSelected, onPress, icon, primaryColor, textColor, style, accessibilityLabel }) => {
|
|
71
|
-
const animatedValue = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
Animated.timing(animatedValue, {
|
|
75
|
-
toValue: isSelected ? 1 : 0,
|
|
76
|
-
duration: 200,
|
|
77
|
-
easing: Easing.out(Easing.ease),
|
|
78
|
-
useNativeDriver: false,
|
|
79
|
-
}).start();
|
|
80
|
-
}, [isSelected, animatedValue]);
|
|
81
|
-
|
|
82
|
-
const backgroundColor = animatedValue.interpolate({
|
|
83
|
-
inputRange: [0, 1],
|
|
84
|
-
outputRange: ['transparent', primaryColor],
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
return (
|
|
88
|
-
<TouchableOpacity
|
|
89
|
-
onPress={onPress}
|
|
90
|
-
activeOpacity={0.7}
|
|
91
|
-
accessibilityRole="button"
|
|
92
|
-
accessibilityLabel={accessibilityLabel}
|
|
93
|
-
accessibilityState={{ selected: isSelected }}
|
|
94
|
-
>
|
|
95
|
-
<Animated.View
|
|
96
|
-
style={[
|
|
97
|
-
style,
|
|
98
|
-
{
|
|
99
|
-
backgroundColor,
|
|
100
|
-
},
|
|
101
|
-
]}
|
|
102
|
-
>
|
|
103
|
-
<Animated.View>
|
|
104
|
-
<MaterialCommunityIcons
|
|
105
|
-
name={icon as React.ComponentProps<typeof MaterialCommunityIcons>['name']}
|
|
106
|
-
size={16}
|
|
107
|
-
color={isSelected ? '#FFFFFF' : textColor}
|
|
108
|
-
/>
|
|
109
|
-
</Animated.View>
|
|
110
|
-
</Animated.View>
|
|
111
|
-
</TouchableOpacity>
|
|
112
|
-
);
|
|
113
|
-
};
|
|
114
|
-
|
|
115
62
|
const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
|
|
116
63
|
onClose,
|
|
117
64
|
theme,
|
|
@@ -151,14 +151,61 @@ export interface PhotoPickerViewProps {
|
|
|
151
151
|
* Apple Photos pattern: when any cell is selected, *non-selected* siblings
|
|
152
152
|
* fade to 0.6 opacity to focus attention on the active selection.
|
|
153
153
|
*/
|
|
154
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Shared cell chrome (image, dim, badge). Ring border is injected by the
|
|
156
|
+
* static vs animated wrappers so web never touches Reanimated worklets.
|
|
157
|
+
*/
|
|
158
|
+
function PhotoPickerCellContent(props: {
|
|
159
|
+
photo: FileMetadata;
|
|
160
|
+
dim: boolean;
|
|
161
|
+
isSelected: boolean;
|
|
162
|
+
selectionIndex: number;
|
|
163
|
+
primaryColor: string;
|
|
164
|
+
thumbUrl: string | undefined;
|
|
165
|
+
ring: React.ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
const {
|
|
168
|
+
photo, dim, isSelected, selectionIndex, primaryColor, thumbUrl, ring,
|
|
169
|
+
} = props;
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<>
|
|
173
|
+
<View
|
|
174
|
+
className={`flex-1 rounded-radius-8 overflow-hidden bg-[#111111]${dim ? ' opacity-60' : ''}`}
|
|
175
|
+
>
|
|
176
|
+
<ExpoImage
|
|
177
|
+
source={{ uri: thumbUrl }}
|
|
178
|
+
style={{ width: '100%', height: '100%' }}
|
|
179
|
+
contentFit="cover"
|
|
180
|
+
transition={120}
|
|
181
|
+
cachePolicy="memory-disk"
|
|
182
|
+
accessibilityLabel={photo.filename}
|
|
183
|
+
/>
|
|
184
|
+
</View>
|
|
185
|
+
{ring}
|
|
186
|
+
{isSelected && (
|
|
187
|
+
<View
|
|
188
|
+
pointerEvents="none"
|
|
189
|
+
className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
|
|
190
|
+
style={{ backgroundColor: primaryColor }}
|
|
191
|
+
>
|
|
192
|
+
<Text className="text-white text-[12px] font-bold leading-[14px]">
|
|
193
|
+
{selectionIndex > 0 ? String(selectionIndex) : ''}
|
|
194
|
+
</Text>
|
|
195
|
+
</View>
|
|
196
|
+
)}
|
|
197
|
+
</>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type PhotoPickerCellProps = {
|
|
155
202
|
photo: FileMetadata;
|
|
156
203
|
size: number;
|
|
157
204
|
marginRight: number;
|
|
158
205
|
marginBottom: number;
|
|
159
206
|
isSelected: boolean;
|
|
160
|
-
selectionIndex: number;
|
|
161
|
-
dim: boolean;
|
|
207
|
+
selectionIndex: number;
|
|
208
|
+
dim: boolean;
|
|
162
209
|
primaryColor: string;
|
|
163
210
|
thumbUrl: string | undefined;
|
|
164
211
|
enterIndex: number;
|
|
@@ -166,21 +213,64 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
|
|
|
166
213
|
onPress: () => void;
|
|
167
214
|
onLongPress: () => void;
|
|
168
215
|
a11yLabel: string;
|
|
169
|
-
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const PhotoPickerCellStatic = React.memo(function PhotoPickerCellStatic(props: PhotoPickerCellProps) {
|
|
219
|
+
const {
|
|
220
|
+
photo, size, marginRight, marginBottom, isSelected, selectionIndex,
|
|
221
|
+
dim, primaryColor, thumbUrl, onPress, onLongPress, a11yLabel,
|
|
222
|
+
} = props;
|
|
223
|
+
|
|
224
|
+
const cellWrapperStyle = {
|
|
225
|
+
width: size,
|
|
226
|
+
height: size,
|
|
227
|
+
marginRight,
|
|
228
|
+
marginBottom,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const ring = isSelected ? (
|
|
232
|
+
<View
|
|
233
|
+
pointerEvents="none"
|
|
234
|
+
className="absolute inset-0 rounded-radius-8 border-[3px]"
|
|
235
|
+
style={{ borderColor: primaryColor }}
|
|
236
|
+
/>
|
|
237
|
+
) : null;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<TouchableOpacity
|
|
241
|
+
activeOpacity={0.85}
|
|
242
|
+
onPress={onPress}
|
|
243
|
+
onLongPress={onLongPress}
|
|
244
|
+
className="relative"
|
|
245
|
+
style={cellWrapperStyle}
|
|
246
|
+
accessibilityRole="button"
|
|
247
|
+
accessibilityLabel={a11yLabel}
|
|
248
|
+
accessibilityState={{ selected: isSelected }}
|
|
249
|
+
>
|
|
250
|
+
<PhotoPickerCellContent
|
|
251
|
+
photo={photo}
|
|
252
|
+
dim={dim}
|
|
253
|
+
isSelected={isSelected}
|
|
254
|
+
selectionIndex={selectionIndex}
|
|
255
|
+
primaryColor={primaryColor}
|
|
256
|
+
thumbUrl={thumbUrl}
|
|
257
|
+
ring={ring}
|
|
258
|
+
/>
|
|
259
|
+
</TouchableOpacity>
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const PhotoPickerCellAnimated = React.memo(function PhotoPickerCellAnimated(props: PhotoPickerCellProps) {
|
|
170
264
|
const {
|
|
171
265
|
photo, size, marginRight, marginBottom, isSelected, selectionIndex,
|
|
172
266
|
dim, primaryColor, thumbUrl, enterIndex, reduceMotion, onPress,
|
|
173
267
|
onLongPress, a11yLabel,
|
|
174
268
|
} = props;
|
|
175
269
|
|
|
176
|
-
// Cap the cumulative stagger at ~800ms total so the very long grid does
|
|
177
|
-
// not keep fading in late tiles. Beyond ~53 tiles the delay maxes out.
|
|
178
270
|
const STAGGER_PER_CELL_MS = 15;
|
|
179
271
|
const MAX_TOTAL_STAGGER_MS = 800;
|
|
180
272
|
const delay = Math.min(enterIndex * STAGGER_PER_CELL_MS, MAX_TOTAL_STAGGER_MS);
|
|
181
273
|
|
|
182
|
-
// Selection ring pulse animation: 1.0 → 1.05 → 1.0 on transition to
|
|
183
|
-
// selected. Plays at most once per selection change; reduce-motion skips.
|
|
184
274
|
const ringScale = useSharedValue(1);
|
|
185
275
|
const prevSelected = useRef(isSelected);
|
|
186
276
|
useEffect(() => {
|
|
@@ -204,8 +294,6 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
|
|
|
204
294
|
transform: [{ scale: ringScale.value }],
|
|
205
295
|
}));
|
|
206
296
|
|
|
207
|
-
// Per-tile pixel geometry is derived from the measured grid width, so it
|
|
208
|
-
// stays an inline `style` — NativeWind cannot express a runtime pixel size.
|
|
209
297
|
const cellWrapperStyle = {
|
|
210
298
|
width: size,
|
|
211
299
|
height: size,
|
|
@@ -213,49 +301,15 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
|
|
|
213
301
|
marginBottom,
|
|
214
302
|
};
|
|
215
303
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// expo-image is a third-party component (no NativeWind
|
|
224
|
-
// className remap), so fill via inline style.
|
|
225
|
-
style={{ width: '100%', height: '100%' }}
|
|
226
|
-
contentFit="cover"
|
|
227
|
-
transition={120}
|
|
228
|
-
cachePolicy="memory-disk"
|
|
229
|
-
accessibilityLabel={photo.filename}
|
|
230
|
-
/>
|
|
231
|
-
</View>
|
|
232
|
-
{isSelected && (
|
|
233
|
-
<Reanimated.View
|
|
234
|
-
pointerEvents="none"
|
|
235
|
-
className="absolute inset-0 rounded-radius-8 border-[3px]"
|
|
236
|
-
// borderColor is the theme primary (dynamic); the animated
|
|
237
|
-
// scale transform must ride on `style` too.
|
|
238
|
-
style={[{ borderColor: primaryColor }, ringAnimatedStyle]}
|
|
239
|
-
/>
|
|
240
|
-
)}
|
|
241
|
-
{isSelected && (
|
|
242
|
-
<View
|
|
243
|
-
pointerEvents="none"
|
|
244
|
-
className="absolute top-1.5 right-1.5 min-w-[22px] h-[22px] px-1.5 rounded-full items-center justify-center"
|
|
245
|
-
style={{ backgroundColor: primaryColor }}
|
|
246
|
-
>
|
|
247
|
-
<Text className="text-white text-[12px] font-bold leading-[14px]">
|
|
248
|
-
{selectionIndex > 0 ? String(selectionIndex) : ''}
|
|
249
|
-
</Text>
|
|
250
|
-
</View>
|
|
251
|
-
)}
|
|
252
|
-
</>
|
|
253
|
-
);
|
|
304
|
+
const ring = isSelected ? (
|
|
305
|
+
<Reanimated.View
|
|
306
|
+
pointerEvents="none"
|
|
307
|
+
className="absolute inset-0 rounded-radius-8 border-[3px]"
|
|
308
|
+
style={[{ borderColor: primaryColor }, ringAnimatedStyle]}
|
|
309
|
+
/>
|
|
310
|
+
) : null;
|
|
254
311
|
|
|
255
|
-
|
|
256
|
-
// babel plugin in the RN-Web build) — they warn and no-op. So on web (and
|
|
257
|
-
// when reduce-motion is set) render the plain cell with no entering stagger.
|
|
258
|
-
if (reduceMotion || Platform.OS === 'web') {
|
|
312
|
+
if (reduceMotion) {
|
|
259
313
|
return (
|
|
260
314
|
<TouchableOpacity
|
|
261
315
|
activeOpacity={0.85}
|
|
@@ -267,7 +321,15 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
|
|
|
267
321
|
accessibilityLabel={a11yLabel}
|
|
268
322
|
accessibilityState={{ selected: isSelected }}
|
|
269
323
|
>
|
|
270
|
-
|
|
324
|
+
<PhotoPickerCellContent
|
|
325
|
+
photo={photo}
|
|
326
|
+
dim={dim}
|
|
327
|
+
isSelected={isSelected}
|
|
328
|
+
selectionIndex={selectionIndex}
|
|
329
|
+
primaryColor={primaryColor}
|
|
330
|
+
thumbUrl={thumbUrl}
|
|
331
|
+
ring={ring}
|
|
332
|
+
/>
|
|
271
333
|
</TouchableOpacity>
|
|
272
334
|
);
|
|
273
335
|
}
|
|
@@ -287,12 +349,27 @@ const PhotoPickerCell = React.memo(function PhotoPickerCell(props: {
|
|
|
287
349
|
accessibilityLabel={a11yLabel}
|
|
288
350
|
accessibilityState={{ selected: isSelected }}
|
|
289
351
|
>
|
|
290
|
-
|
|
352
|
+
<PhotoPickerCellContent
|
|
353
|
+
photo={photo}
|
|
354
|
+
dim={dim}
|
|
355
|
+
isSelected={isSelected}
|
|
356
|
+
selectionIndex={selectionIndex}
|
|
357
|
+
primaryColor={primaryColor}
|
|
358
|
+
thumbUrl={thumbUrl}
|
|
359
|
+
ring={ring}
|
|
360
|
+
/>
|
|
291
361
|
</TouchableOpacity>
|
|
292
362
|
</Reanimated.View>
|
|
293
363
|
);
|
|
294
364
|
});
|
|
295
365
|
|
|
366
|
+
const PhotoPickerCell = React.memo(function PhotoPickerCell(props: PhotoPickerCellProps) {
|
|
367
|
+
if (Platform.OS === 'web') {
|
|
368
|
+
return <PhotoPickerCellStatic {...props} />;
|
|
369
|
+
}
|
|
370
|
+
return <PhotoPickerCellAnimated {...props} />;
|
|
371
|
+
});
|
|
372
|
+
|
|
296
373
|
const PhotoPickerView: React.FC<PhotoPickerViewProps> = ({
|
|
297
374
|
photos,
|
|
298
375
|
selectedIds,
|