@oxy.so/services 11.17.0 → 12.2.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/assets/assets/one/floral.jpg.d.ts +1 -1
- package/lib/commonjs/ui/client.js +13 -0
- package/lib/commonjs/ui/client.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +1 -6
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +6 -5
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ProfileButton.js +2 -4
- package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
- package/lib/commonjs/ui/components/SafeAreaRoot.js +139 -0
- package/lib/commonjs/ui/components/SafeAreaRoot.js.map +1 -0
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js +17 -14
- package/lib/commonjs/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/commonjs/ui/components/photogrid/JustifiedPhotoGrid.js +10 -17
- package/lib/commonjs/ui/components/photogrid/JustifiedPhotoGrid.js.map +1 -1
- package/lib/commonjs/ui/index.js +0 -37
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/navigation/accountDialogManager.js +5 -4
- package/lib/commonjs/ui/navigation/accountDialogManager.js.map +1 -1
- package/lib/commonjs/ui/navigation/surfaces.js +2 -2
- package/lib/commonjs/ui/one-video.js +3 -2
- package/lib/commonjs/ui/one-video.js.map +1 -1
- package/lib/commonjs/ui/screens/AvatarCropScreen.js +39 -25
- package/lib/commonjs/ui/screens/AvatarCropScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +4 -2
- package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyOneScreen.js +3 -2
- package/lib/commonjs/ui/screens/OxyOneScreen.js.map +1 -1
- package/lib/commonjs/ui/utils/imageSize.js +31 -0
- package/lib/commonjs/ui/utils/imageSize.js.map +1 -0
- package/lib/commonjs/ui/utils/imageSize.native.js +31 -0
- package/lib/commonjs/ui/utils/imageSize.native.js.map +1 -0
- package/lib/module/assets/assets/one/floral.jpg.d.ts +1 -1
- package/lib/module/ui/client.js +3 -0
- package/lib/module/ui/client.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +1 -6
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +6 -5
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/ProfileButton.js +2 -4
- package/lib/module/ui/components/ProfileButton.js.map +1 -1
- package/lib/module/ui/components/SafeAreaRoot.js +135 -0
- package/lib/module/ui/components/SafeAreaRoot.js.map +1 -0
- package/lib/module/ui/components/fileManagement/FileViewer.js +18 -15
- package/lib/module/ui/components/fileManagement/FileViewer.js.map +1 -1
- package/lib/module/ui/components/photogrid/JustifiedPhotoGrid.js +11 -18
- package/lib/module/ui/components/photogrid/JustifiedPhotoGrid.js.map +1 -1
- package/lib/module/ui/index.js +5 -3
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/navigation/accountDialogManager.js +5 -4
- package/lib/module/ui/navigation/accountDialogManager.js.map +1 -1
- package/lib/module/ui/navigation/surfaces.js +2 -2
- package/lib/module/ui/one-video.js +4 -3
- package/lib/module/ui/one-video.js.map +1 -1
- package/lib/module/ui/screens/AvatarCropScreen.js +41 -25
- package/lib/module/ui/screens/AvatarCropScreen.js.map +1 -1
- package/lib/module/ui/screens/EditProfileFieldScreen.js +5 -3
- package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
- package/lib/module/ui/screens/OxyOneScreen.js +4 -3
- package/lib/module/ui/screens/OxyOneScreen.js.map +1 -1
- package/lib/module/ui/utils/imageSize.js +26 -0
- package/lib/module/ui/utils/imageSize.js.map +1 -0
- package/lib/module/ui/utils/imageSize.native.js +26 -0
- package/lib/module/ui/utils/imageSize.native.js.map +1 -0
- package/lib/typescript/assets/one/floral.jpg.d.ts +1 -1
- package/lib/typescript/commonjs/ui/client.d.ts +3 -0
- package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts +3 -2
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts +0 -2
- package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/SafeAreaRoot.d.ts +28 -0
- package/lib/typescript/commonjs/ui/components/SafeAreaRoot.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/photogrid/JustifiedPhotoGrid.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/accountDialogManager.d.ts +5 -5
- package/lib/typescript/commonjs/ui/navigation/accountDialogManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/one-video.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AvatarCropScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/OxyOneScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/imageSize.d.ts +6 -0
- package/lib/typescript/commonjs/ui/utils/imageSize.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/imageSize.native.d.ts +6 -0
- package/lib/typescript/commonjs/ui/utils/imageSize.native.d.ts.map +1 -0
- package/lib/typescript/module/ui/client.d.ts +3 -0
- package/lib/typescript/module/ui/client.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts +3 -2
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ProfileButton.d.ts +0 -2
- package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/SafeAreaRoot.d.ts +28 -0
- package/lib/typescript/module/ui/components/SafeAreaRoot.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/fileManagement/FileViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/photogrid/JustifiedPhotoGrid.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/accountDialogManager.d.ts +5 -5
- package/lib/typescript/module/ui/navigation/accountDialogManager.d.ts.map +1 -1
- package/lib/typescript/module/ui/one-video.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AvatarCropScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/OxyOneScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/imageSize.d.ts +6 -0
- package/lib/typescript/module/ui/utils/imageSize.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/imageSize.native.d.ts +6 -0
- package/lib/typescript/module/ui/utils/imageSize.native.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/assets/one/floral.jpg.d.ts +1 -1
- package/src/ui/client.ts +8 -0
- package/src/ui/components/OxyAuthChooser.tsx +1 -6
- package/src/ui/components/OxyProvider.tsx +7 -6
- package/src/ui/components/ProfileButton.tsx +2 -12
- package/src/ui/components/SafeAreaRoot.tsx +123 -0
- package/src/ui/components/__tests__/SafeAreaRoot.test.tsx +162 -0
- package/src/ui/components/fileManagement/FileViewer.tsx +18 -13
- package/src/ui/components/photogrid/JustifiedPhotoGrid.tsx +13 -24
- package/src/ui/index.ts +5 -4
- package/src/ui/navigation/accountDialogManager.ts +5 -5
- package/src/ui/navigation/surfaces.ts +2 -2
- package/src/ui/one-video.tsx +3 -2
- package/src/ui/screens/AvatarCropScreen.tsx +30 -25
- package/src/ui/screens/EditProfileFieldScreen.tsx +7 -2
- package/src/ui/screens/OxyOneScreen.tsx +4 -3
- package/src/ui/utils/__tests__/imageSize.test.ts +61 -0
- package/src/ui/utils/imageSize.native.ts +26 -0
- package/src/ui/utils/imageSize.ts +26 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxyProvider`'s safe-area root on web reports the WINDOW's metrics and never
|
|
3
|
+
* mounts react-native-safe-area-context's measuring web provider.
|
|
4
|
+
*
|
|
5
|
+
* From 5.9 that provider measures its own element and reports
|
|
6
|
+
* `windowInsets.bottom - (innerHeight - rect.bottom)` unclamped, so a provider
|
|
7
|
+
* around a page that scrolls turns everything below the fold into bottom inset.
|
|
8
|
+
* OxyHQ/website measured it: an 844px viewport over a 2690px page gave the mobile
|
|
9
|
+
* account dialog a 2690px layout and an empty body (844px on 5.8.1). Here every
|
|
10
|
+
* element measures as that page, so any provider that consulted it would fail.
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { act, render } from '@testing-library/react';
|
|
14
|
+
import { Platform } from 'react-native';
|
|
15
|
+
import type { EdgeInsets, Rect } from 'react-native-safe-area-context';
|
|
16
|
+
|
|
17
|
+
const mockMeasuringProvider = jest.fn();
|
|
18
|
+
|
|
19
|
+
// The library's own contexts and hooks, with its measuring provider recorded.
|
|
20
|
+
// (Its real entry loads a codegen native component this jest setup cannot.)
|
|
21
|
+
jest.mock('react-native-safe-area-context', () => {
|
|
22
|
+
const React = jest.requireActual<typeof import('react')>('react');
|
|
23
|
+
const SafeAreaInsetsContext = React.createContext<unknown>(null);
|
|
24
|
+
const SafeAreaFrameContext = React.createContext<unknown>(null);
|
|
25
|
+
return {
|
|
26
|
+
__esModule: true,
|
|
27
|
+
SafeAreaInsetsContext,
|
|
28
|
+
SafeAreaFrameContext,
|
|
29
|
+
SafeAreaProvider: ({ children }: { children?: ReactNode }) => {
|
|
30
|
+
mockMeasuringProvider();
|
|
31
|
+
return children;
|
|
32
|
+
},
|
|
33
|
+
useSafeAreaInsets: () => React.useContext(SafeAreaInsetsContext),
|
|
34
|
+
useSafeAreaFrame: () => React.useContext(SafeAreaFrameContext),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
39
|
+
import { SafeAreaRoot } from '../SafeAreaRoot';
|
|
40
|
+
|
|
41
|
+
const PAGE_HEIGHT = 2690;
|
|
42
|
+
const HOME_INDICATOR = 34;
|
|
43
|
+
|
|
44
|
+
const originalOS = Platform.OS;
|
|
45
|
+
const originalGetComputedStyle = window.getComputedStyle;
|
|
46
|
+
const originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mockMeasuringProvider.mockClear();
|
|
50
|
+
// jsdom has no env(): the fixed probe padded with env(safe-area-inset-*)
|
|
51
|
+
// reads an iPhone's home-indicator inset at the bottom.
|
|
52
|
+
window.getComputedStyle = ((element: Element) => {
|
|
53
|
+
const style = originalGetComputedStyle(element);
|
|
54
|
+
const probe = (element as HTMLElement).style?.position === 'fixed';
|
|
55
|
+
return new Proxy(style, {
|
|
56
|
+
get(target, key) {
|
|
57
|
+
if (key === 'paddingBottom') return probe ? `${HOME_INDICATOR}px` : '0px';
|
|
58
|
+
if (key === 'paddingTop' || key === 'paddingLeft' || key === 'paddingRight') return '0px';
|
|
59
|
+
const value = Reflect.get(target, key);
|
|
60
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}) as typeof window.getComputedStyle;
|
|
64
|
+
HTMLElement.prototype.getBoundingClientRect = () =>
|
|
65
|
+
({
|
|
66
|
+
x: 0,
|
|
67
|
+
y: 0,
|
|
68
|
+
top: 0,
|
|
69
|
+
left: 0,
|
|
70
|
+
width: 375,
|
|
71
|
+
height: PAGE_HEIGHT,
|
|
72
|
+
right: 375,
|
|
73
|
+
bottom: PAGE_HEIGHT,
|
|
74
|
+
toJSON: () => ({}),
|
|
75
|
+
}) as DOMRect;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
Platform.OS = originalOS;
|
|
80
|
+
window.getComputedStyle = originalGetComputedStyle;
|
|
81
|
+
HTMLElement.prototype.getBoundingClientRect = originalRect;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function renderMetrics() {
|
|
85
|
+
const seen: { insets?: EdgeInsets; frame?: Rect } = {};
|
|
86
|
+
function Probe() {
|
|
87
|
+
seen.insets = useSafeAreaInsets();
|
|
88
|
+
seen.frame = useSafeAreaFrame();
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const view = render(
|
|
92
|
+
<SafeAreaRoot>
|
|
93
|
+
<Probe />
|
|
94
|
+
</SafeAreaRoot>,
|
|
95
|
+
);
|
|
96
|
+
return { seen, view };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('SafeAreaRoot', () => {
|
|
100
|
+
it('on web reports env() insets and the viewport frame, not the page', async () => {
|
|
101
|
+
Platform.OS = 'web';
|
|
102
|
+
const { seen, view } = renderMetrics();
|
|
103
|
+
await act(async () => {});
|
|
104
|
+
expect(mockMeasuringProvider).not.toHaveBeenCalled();
|
|
105
|
+
expect(seen.insets).toEqual({ top: 0, right: 0, bottom: HOME_INDICATOR, left: 0 });
|
|
106
|
+
// The react-native mock's window (useWindowDimensions), never PAGE_HEIGHT.
|
|
107
|
+
expect(seen.frame).toEqual({ x: 0, y: 0, width: 375, height: 667 });
|
|
108
|
+
view.unmount();
|
|
109
|
+
expect(document.querySelectorAll('div[aria-hidden="true"]')).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('on web provides zero insets until the browser answers', () => {
|
|
113
|
+
Platform.OS = 'web';
|
|
114
|
+
window.getComputedStyle = originalGetComputedStyle;
|
|
115
|
+
const { seen } = renderMetrics();
|
|
116
|
+
expect(seen.insets).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each(['ios', 'android'] as const)('on %s mounts the native SafeAreaProvider', (os) => {
|
|
120
|
+
Platform.OS = os;
|
|
121
|
+
renderMetrics();
|
|
122
|
+
expect(mockMeasuringProvider).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('the SDK mounts safe-area providers only through SafeAreaRoot', () => {
|
|
127
|
+
const fs = jest.requireActual<typeof import('node:fs')>('node:fs');
|
|
128
|
+
const path = jest.requireActual<typeof import('node:path')>('node:path');
|
|
129
|
+
const srcRoot = path.resolve(__dirname, '../../..');
|
|
130
|
+
|
|
131
|
+
function sourceFiles(dir: string): string[] {
|
|
132
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
133
|
+
const full = path.join(dir, entry.name);
|
|
134
|
+
if (entry.isDirectory()) return entry.name === '__tests__' ? [] : sourceFiles(full);
|
|
135
|
+
return /\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts') ? [full] : [];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('no other source file names SafeAreaProvider', () => {
|
|
140
|
+
const files = sourceFiles(srcRoot);
|
|
141
|
+
expect(files.length).toBeGreaterThan(100);
|
|
142
|
+
const offenders = files
|
|
143
|
+
.filter((file) => /\bSafeAreaProvider\b/.test(fs.readFileSync(file, 'utf8')))
|
|
144
|
+
.map((file) => path.relative(srcRoot, file))
|
|
145
|
+
.filter((rel) => rel !== path.join('ui', 'components', 'SafeAreaRoot.tsx'))
|
|
146
|
+
// OxyProvider's usage doc shows an APP mounting its own provider.
|
|
147
|
+
.filter((rel) => {
|
|
148
|
+
const code = fs
|
|
149
|
+
.readFileSync(path.join(srcRoot, rel), 'utf8')
|
|
150
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
151
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
152
|
+
return /\bSafeAreaProvider\b/.test(code);
|
|
153
|
+
});
|
|
154
|
+
expect(offenders).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('OxyProvider wraps its tree in SafeAreaRoot', () => {
|
|
158
|
+
const provider = fs.readFileSync(path.join(srcRoot, 'ui/components/OxyProvider.tsx'), 'utf8');
|
|
159
|
+
expect(provider).toMatch(/import \{ SafeAreaRoot \} from '\.\/SafeAreaRoot'/);
|
|
160
|
+
expect(provider).toMatch(/<SafeAreaRoot>/);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SpinnerIcon } from '@oxy.so/bloom/loading';
|
|
2
2
|
import type React from 'react';
|
|
3
3
|
import { useMemo, useState, useEffect } from 'react';
|
|
4
|
-
import { View, ScrollView,
|
|
4
|
+
import { View, ScrollView, StyleSheet } from 'react-native';
|
|
5
5
|
import { Button } from '@oxy.so/bloom/button';
|
|
6
6
|
import { Text } from '@oxy.so/bloom/typography';
|
|
7
7
|
import { Image as ExpoImage } from 'expo-image';
|
|
@@ -9,6 +9,7 @@ import MaterialCommunityIcons from '../../icons/MaterialCommunityIcons';
|
|
|
9
9
|
import { useTheme } from '@oxy.so/bloom/theme';
|
|
10
10
|
import type { FileMetadata } from '@oxy.so/core';
|
|
11
11
|
import { formatFileSize } from '../../utils/fileManagement';
|
|
12
|
+
import { getImageSize } from '../../utils/imageSize';
|
|
12
13
|
import { GroupedSection, GroupedSectionItem } from '@oxy.so/bloom/grouped-section';
|
|
13
14
|
|
|
14
15
|
interface FileViewerProps {
|
|
@@ -81,20 +82,24 @@ export const FileViewer: React.FC<FileViewerProps> = ({
|
|
|
81
82
|
|
|
82
83
|
// Load image dimensions when image content is available
|
|
83
84
|
useEffect(() => {
|
|
84
|
-
if (isImage
|
|
85
|
-
Image.getSize(
|
|
86
|
-
fileContent,
|
|
87
|
-
(width, height) => {
|
|
88
|
-
setImageDimensions({ width, height });
|
|
89
|
-
},
|
|
90
|
-
() => {
|
|
91
|
-
// Fallback if dimensions can't be loaded
|
|
92
|
-
setImageDimensions({ width: 400, height: 400 });
|
|
93
|
-
},
|
|
94
|
-
);
|
|
95
|
-
} else {
|
|
85
|
+
if (!isImage || !fileContent) {
|
|
96
86
|
setImageDimensions(null);
|
|
87
|
+
return;
|
|
97
88
|
}
|
|
89
|
+
// A newer file may replace this one before its size arrives.
|
|
90
|
+
let current = true;
|
|
91
|
+
getImageSize(fileContent).then(
|
|
92
|
+
(size) => {
|
|
93
|
+
if (current) setImageDimensions(size);
|
|
94
|
+
},
|
|
95
|
+
() => {
|
|
96
|
+
// Fallback if dimensions can't be loaded
|
|
97
|
+
if (current) setImageDimensions({ width: 400, height: 400 });
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
return () => {
|
|
101
|
+
current = false;
|
|
102
|
+
};
|
|
98
103
|
}, [isImage, fileContent]);
|
|
99
104
|
|
|
100
105
|
// Calculate display size based on natural dimensions and max constraints
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { useMemo, useState, useCallback } from 'react';
|
|
2
|
-
import { View,
|
|
2
|
+
import { View, useWindowDimensions, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
import { Text } from '@oxy.so/bloom/typography';
|
|
4
4
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|
5
5
|
import type { FileMetadata } from '@oxy.so/core';
|
|
6
|
+
import { getImageSize } from '../../utils/imageSize';
|
|
6
7
|
|
|
7
8
|
type PhotoSize = { width: number; height: number };
|
|
8
9
|
|
|
@@ -11,7 +12,7 @@ const EMPTY_DIMENSIONS: Record<string, PhotoSize> = {};
|
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Measure the intrinsic size of every photo whose thumbnail URL has resolved,
|
|
14
|
-
* via `
|
|
15
|
+
* via `getImageSize` (expo-image on a device). Pure/async (no React state) so it can be a React Query
|
|
15
16
|
* `queryFn`. Photos without a resolved URL are skipped and re-measured on the
|
|
16
17
|
* next signature change; a load error falls back to 1×1 (→ the 4/3 default).
|
|
17
18
|
*/
|
|
@@ -21,27 +22,15 @@ async function measurePhotoDimensions(
|
|
|
21
22
|
): Promise<Record<string, PhotoSize>> {
|
|
22
23
|
const measured: Record<string, PhotoSize> = {};
|
|
23
24
|
await Promise.all(
|
|
24
|
-
photos.map(
|
|
25
|
-
(photo)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
url,
|
|
34
|
-
(width, height) => {
|
|
35
|
-
measured[photo.id] = { width, height };
|
|
36
|
-
resolve();
|
|
37
|
-
},
|
|
38
|
-
() => {
|
|
39
|
-
measured[photo.id] = { width: 1, height: 1 };
|
|
40
|
-
resolve();
|
|
41
|
-
},
|
|
42
|
-
);
|
|
43
|
-
}),
|
|
44
|
-
),
|
|
25
|
+
photos.map(async (photo) => {
|
|
26
|
+
const url = getThumbUrl(photo);
|
|
27
|
+
if (!url) return;
|
|
28
|
+
try {
|
|
29
|
+
measured[photo.id] = await getImageSize(url);
|
|
30
|
+
} catch {
|
|
31
|
+
measured[photo.id] = { width: 1, height: 1 };
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
45
34
|
);
|
|
46
35
|
return measured;
|
|
47
36
|
}
|
|
@@ -87,7 +76,7 @@ export interface JustifiedPhotoGridProps {
|
|
|
87
76
|
* Responsive justified photo grid that stretches to the provided containerWidth.
|
|
88
77
|
* Uses flex rows with proportional children widths instead of absolute pixel widths so it always fills.
|
|
89
78
|
*
|
|
90
|
-
* Owns its own intrinsic-dimension measurement: it calls `
|
|
79
|
+
* Owns its own intrinsic-dimension measurement: it calls `getImageSize` on each
|
|
91
80
|
* photo's resolved thumbnail URL and reflows once real aspect ratios are known
|
|
92
81
|
* (falling back to 4/3 until then). Measurement re-runs whenever a photo is added
|
|
93
82
|
* OR its (asynchronously-resolved) private-safe URL first appears.
|
package/src/ui/index.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
/// <reference path="../types/react-native-classname.d.ts" />
|
|
16
16
|
/// <reference path="../types/react-native-web-style.d.ts" />
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// No screen is imported here. `OxyProvider` loads the account screens as their
|
|
19
|
+
// own chunk at startup (`loadScreens`), and a surface opened before that chunk
|
|
20
|
+
// arrives waits for it. A static `screenComponents` import here put all of them
|
|
21
|
+
// in the first-paint bundle of every app that imports anything from `ui`
|
|
22
|
+
// (Allo: +340 KB gzipped).
|
|
22
23
|
|
|
23
24
|
// Components
|
|
24
25
|
export { default as OxyProvider } from './components/OxyProvider';
|
|
@@ -46,11 +46,11 @@ export interface AccountDialogControls {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Optional consumer overrides for account-dialog actions. `ProfileButton` (and
|
|
49
|
-
* similar hosts) register these while mounted so
|
|
50
|
-
*
|
|
49
|
+
* similar hosts) register these while mounted so an app can route "Add account"
|
|
50
|
+
* and "Profile" to app-local navigation, and add its own menu entries. "Manage
|
|
51
|
+
* your Oxy account" is not overridable: it always opens the SDK's ManageAccount.
|
|
51
52
|
*/
|
|
52
53
|
export interface AccountDialogConsumerHooks {
|
|
53
|
-
onNavigateManage?: () => void;
|
|
54
54
|
onAddAccount?: () => void;
|
|
55
55
|
onNavigateProfile?: () => void;
|
|
56
56
|
menuItems?: readonly AccountDialogMenuItem[];
|
|
@@ -77,7 +77,7 @@ export function registerAccountDialogControls(next: AccountDialogControls): () =
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/** Register app-local
|
|
80
|
+
/** Register app-local add/profile handlers and menu entries for the account dialog. */
|
|
81
81
|
export function registerAccountDialogConsumerHooks(
|
|
82
82
|
next: AccountDialogConsumerHooks | null,
|
|
83
83
|
): () => void {
|
|
@@ -95,7 +95,7 @@ export function getAccountDialogConsumerHooks(): AccountDialogConsumerHooks | nu
|
|
|
95
95
|
return consumerHooks;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/** Subscribe to consumer-hook registration changes (menu items,
|
|
98
|
+
/** Subscribe to consumer-hook registration changes (menu items, add/profile overrides). */
|
|
99
99
|
export function subscribeToAccountDialogConsumerHooks(listener: () => void): () => void {
|
|
100
100
|
consumerHookListeners.add(listener);
|
|
101
101
|
return () => {
|
|
@@ -167,8 +167,8 @@ function presentInternal<K extends RouteName>(
|
|
|
167
167
|
bloomOpts,
|
|
168
168
|
);
|
|
169
169
|
|
|
170
|
-
// Never mount a partially-loaded frame.
|
|
171
|
-
//
|
|
170
|
+
// Never mount a partially-loaded frame. The root entry takes the sync path;
|
|
171
|
+
// `ui` and `ui/client` preload at provider startup, normally before the first press.
|
|
172
172
|
const result = screensReady()
|
|
173
173
|
? open()
|
|
174
174
|
: loadScreens().then(() => {
|
package/src/ui/one-video.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Optional video entry: never re-export from services or its UI root barrels. */
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
|
-
import { AppState,
|
|
3
|
+
import { AppState, StyleSheet, View } from 'react-native';
|
|
4
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
4
5
|
import { useVideoPlayer } from 'expo-video';
|
|
5
6
|
import { VideoView } from '@oxy.so/bloom/video-view';
|
|
6
7
|
import { useReducedMotion } from 'react-native-reanimated';
|
|
@@ -29,7 +30,7 @@ export function OxyOneVideo() {
|
|
|
29
30
|
accessibilityElementsHidden
|
|
30
31
|
importantForAccessibility="no-hide-descendants"
|
|
31
32
|
>
|
|
32
|
-
<
|
|
33
|
+
<ExpoImage source={floralPoster} contentFit="cover" style={StyleSheet.absoluteFill} />
|
|
33
34
|
{!reducedMotion && (
|
|
34
35
|
<VideoView
|
|
35
36
|
player={player}
|
|
@@ -33,7 +33,7 @@ import { RiSubtractLine } from '@oxy.so/bloom/icons/RiSubtractLine';
|
|
|
33
33
|
|
|
34
34
|
import type React from 'react';
|
|
35
35
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
36
|
-
import { View, StyleSheet,
|
|
36
|
+
import { View, StyleSheet, Platform, AccessibilityInfo } from 'react-native';
|
|
37
37
|
import { Text } from '@oxy.so/bloom/typography';
|
|
38
38
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
39
39
|
import Animated, {
|
|
@@ -46,6 +46,7 @@ import Animated, {
|
|
|
46
46
|
withTiming,
|
|
47
47
|
} from 'react-native-reanimated';
|
|
48
48
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|
49
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
49
50
|
import { useTheme } from '@oxy.so/bloom/theme';
|
|
50
51
|
import { logger } from '@oxy.so/core';
|
|
51
52
|
import { useOxy } from '../context/oxyRuntimeContext';
|
|
@@ -54,6 +55,7 @@ import { useReduceMotion } from '../hooks/useReduceMotion';
|
|
|
54
55
|
import { useSurfaceHeader, type SurfaceHeaderContent } from '../hooks/useSurfaceHeader';
|
|
55
56
|
import { toast } from '@oxy.so/bloom/toast';
|
|
56
57
|
import type { BaseScreenProps } from '../types/navigation';
|
|
58
|
+
import { getImageSize } from '../utils/imageSize';
|
|
57
59
|
|
|
58
60
|
/** Component name used in `logger` context for filtered diagnostics. */
|
|
59
61
|
const LOG_COMPONENT = 'AvatarCropScreen';
|
|
@@ -138,25 +140,24 @@ interface ImageNaturalSize {
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
/**
|
|
141
|
-
* Measure an image's intrinsic size via `
|
|
142
|
-
* it can be a React Query `queryFn` (no component
|
|
143
|
-
*
|
|
144
|
-
* Mirrors the `measurePhotoDimensions` helper the
|
|
143
|
+
* Measure an image's intrinsic size via `getImageSize` (expo-image on a
|
|
144
|
+
* device), as a Promise so it can be a React Query `queryFn` (no component
|
|
145
|
+
* state / effects). Rejects on a load failure or degenerate dimensions so the
|
|
146
|
+
* query surfaces `isError`. Mirrors the `measurePhotoDimensions` helper the
|
|
147
|
+
* photo grid uses.
|
|
145
148
|
*/
|
|
146
|
-
function measureImageSize(uri: string): Promise<ImageNaturalSize> {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
);
|
|
159
|
-
});
|
|
149
|
+
async function measureImageSize(uri: string): Promise<ImageNaturalSize> {
|
|
150
|
+
let size: ImageNaturalSize;
|
|
151
|
+
try {
|
|
152
|
+
size = await getImageSize(uri);
|
|
153
|
+
} catch {
|
|
154
|
+
throw new Error('Image measurement failed');
|
|
155
|
+
}
|
|
156
|
+
const { width, height } = size;
|
|
157
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
158
|
+
throw new Error('Image reported invalid dimensions');
|
|
159
|
+
}
|
|
160
|
+
return { width, height };
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
/**
|
|
@@ -381,7 +382,7 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
381
382
|
|
|
382
383
|
// Natural size of the source image. Known up front (device/camera sources pass
|
|
383
384
|
// the dimensions) OR measured lazily from `effectiveUri` via a React Query read
|
|
384
|
-
// (no effect) — the same
|
|
385
|
+
// (no effect) — the same getImageSize→useQuery pattern the photo grid uses.
|
|
385
386
|
const hasPassedDimensions = !!(sourceWidth && sourceHeight);
|
|
386
387
|
const measureQuery = useQuery({
|
|
387
388
|
queryKey: ['avatarCropMeasure', effectiveUri],
|
|
@@ -1047,11 +1048,15 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
1047
1048
|
<GestureDetector gesture={composedGesture}>
|
|
1048
1049
|
<View style={styles.viewport}>
|
|
1049
1050
|
{baseFit && effectiveUri ? (
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1051
|
+
// Reanimated moves the box; expo-image draws the pixels, so the
|
|
1052
|
+
// crop stage never needs react-native's GIF/WebP decoders.
|
|
1053
|
+
<Animated.View style={[styles.image, imageAnimatedStyle]}>
|
|
1054
|
+
<ExpoImage
|
|
1055
|
+
source={{ uri: effectiveUri }}
|
|
1056
|
+
style={StyleSheet.absoluteFill}
|
|
1057
|
+
contentFit="cover"
|
|
1058
|
+
/>
|
|
1059
|
+
</Animated.View>
|
|
1055
1060
|
) : (
|
|
1056
1061
|
<SpinnerIcon color="#ffffff" />
|
|
1057
1062
|
)}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useCallback, useMemo } from 'react';
|
|
2
|
-
import { View,
|
|
2
|
+
import { View, StyleSheet, type TextInputProps } from 'react-native';
|
|
3
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
3
4
|
import Ionicons from '../icons/Ionicons';
|
|
4
5
|
import type { BaseScreenProps } from '../types/navigation';
|
|
5
6
|
import { useTheme } from '@oxy.so/bloom/theme';
|
|
@@ -657,7 +658,11 @@ const EditProfileFieldScreen: React.FC<EditProfileFieldScreenProps> = ({
|
|
|
657
658
|
description={fieldType === 'links' ? String(item.url) : undefined}
|
|
658
659
|
icon={
|
|
659
660
|
fieldType === 'links' && item.image ? (
|
|
660
|
-
<
|
|
661
|
+
<ExpoImage
|
|
662
|
+
source={{ uri: item.image }}
|
|
663
|
+
style={styles.linkImage}
|
|
664
|
+
contentFit="cover"
|
|
665
|
+
/>
|
|
661
666
|
) : undefined
|
|
662
667
|
}
|
|
663
668
|
rightElement={
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useContext, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Linking, View } from 'react-native';
|
|
3
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
3
4
|
import { SubscriptionPlans } from '@oxy.so/bloom/subscription-plans';
|
|
4
5
|
import {
|
|
5
6
|
SegmentedControl,
|
|
@@ -174,9 +175,9 @@ export default function OxyOneScreen() {
|
|
|
174
175
|
productName="Oxy One"
|
|
175
176
|
heroBackground={
|
|
176
177
|
oxyOneArtwork ?? (
|
|
177
|
-
<
|
|
178
|
+
<ExpoImage
|
|
178
179
|
source={floralPoster}
|
|
179
|
-
|
|
180
|
+
contentFit="cover"
|
|
180
181
|
style={{ width: '100%', height: '100%' }}
|
|
181
182
|
/>
|
|
182
183
|
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both forks of `getImageSize` keep react-native's `Image.getSize` contract —
|
|
3
|
+
* the PIXEL size on success, a rejection when the image cannot load — so the
|
|
4
|
+
* grid, the viewer and the crop stage size their boxes the same on every
|
|
5
|
+
* platform.
|
|
6
|
+
*/
|
|
7
|
+
const loadAsync = jest.fn();
|
|
8
|
+
jest.mock('expo-image', () => ({ Image: { loadAsync } }), { virtual: true });
|
|
9
|
+
|
|
10
|
+
const getSize = jest.fn();
|
|
11
|
+
jest.mock('react-native', () => ({ Image: { getSize } }));
|
|
12
|
+
|
|
13
|
+
import { getImageSize as getNativeImageSize } from '../imageSize.native';
|
|
14
|
+
import { getImageSize as getWebImageSize } from '../imageSize';
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
loadAsync.mockReset();
|
|
18
|
+
getSize.mockReset();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('getImageSize (native: expo-image)', () => {
|
|
22
|
+
it('reports pixels and releases the decoded image', async () => {
|
|
23
|
+
const release = jest.fn();
|
|
24
|
+
loadAsync.mockResolvedValue({ width: 200, height: 150, scale: 2, release });
|
|
25
|
+
|
|
26
|
+
await expect(getNativeImageSize('https://cdn.example/a.gif')).resolves.toEqual({
|
|
27
|
+
width: 400,
|
|
28
|
+
height: 300,
|
|
29
|
+
});
|
|
30
|
+
expect(loadAsync).toHaveBeenCalledWith('https://cdn.example/a.gif');
|
|
31
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('treats a missing scale as 1', async () => {
|
|
35
|
+
loadAsync.mockResolvedValue({ width: 64, height: 32, scale: 0, release: jest.fn() });
|
|
36
|
+
await expect(getNativeImageSize('file:///a.webp')).resolves.toEqual({ width: 64, height: 32 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects when the image cannot load', async () => {
|
|
40
|
+
loadAsync.mockRejectedValue(new Error('decode failed'));
|
|
41
|
+
await expect(getNativeImageSize('https://cdn.example/broken')).rejects.toThrow('decode failed');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('getImageSize (web: react-native-web getSize)', () => {
|
|
46
|
+
it('resolves what getSize reports', async () => {
|
|
47
|
+
getSize.mockImplementation((_uri: string, ok: (w: number, h: number) => void) => ok(640, 480));
|
|
48
|
+
await expect(getWebImageSize('https://cdn.example/a.png')).resolves.toEqual({
|
|
49
|
+
width: 640,
|
|
50
|
+
height: 480,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects through the failure callback', async () => {
|
|
55
|
+
const failure = new Error('404');
|
|
56
|
+
getSize.mockImplementation((_uri: string, _ok: unknown, fail: (error: unknown) => void) =>
|
|
57
|
+
fail(failure),
|
|
58
|
+
);
|
|
59
|
+
await expect(getWebImageSize('https://cdn.example/missing')).rejects.toBe(failure);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native fork of `imageSize.ts`: measures through expo-image (Glide on
|
|
3
|
+
* Android, SDWebImage on iOS), the same pipeline and cache `ExpoImage` draws
|
|
4
|
+
* from. react-native's `Image.getSize` decodes through Fresco, whose GIF and
|
|
5
|
+
* animated-WebP support an Android app ships only while `expo-build-properties`
|
|
6
|
+
* keeps `gifEnabled`/`webpEnabled` on — so this file must never name
|
|
7
|
+
* react-native's `Image` (`__tests__/packaging/rnImageBoundary.test.ts`).
|
|
8
|
+
*/
|
|
9
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
10
|
+
|
|
11
|
+
export interface ImageSize {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getImageSize(uri: string): Promise<ImageSize> {
|
|
17
|
+
const ref = await ExpoImage.loadAsync(uri);
|
|
18
|
+
try {
|
|
19
|
+
// `width`/`height` are logical; times `scale` is the pixel size
|
|
20
|
+
// `Image.getSize` reports, which the callers' layouts were written for.
|
|
21
|
+
const scale = ref.scale > 0 ? ref.scale : 1;
|
|
22
|
+
return { width: ref.width * scale, height: ref.height * scale };
|
|
23
|
+
} finally {
|
|
24
|
+
ref.release();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intrinsic pixel size of an image the SDK is about to draw, for the layouts
|
|
3
|
+
* that size a box from it (the justified photo grid, the file viewer, the
|
|
4
|
+
* avatar crop stage). Resolves `{ width, height }`; rejects when the image
|
|
5
|
+
* cannot be loaded, exactly as react-native's `Image.getSize` reports through
|
|
6
|
+
* its failure callback.
|
|
7
|
+
*
|
|
8
|
+
* This is the web (and `tsc`/Vite) fork: react-native-web's `getSize` is an
|
|
9
|
+
* `<img>` probe, which needs no CORS headers — expo-image's web `loadAsync`
|
|
10
|
+
* uses `fetch`, which does. The native fork (`imageSize.native.ts`) measures
|
|
11
|
+
* through expo-image, so no SDK image depends on react-native's GIF/WebP
|
|
12
|
+
* decoders on Android and an app may turn them off
|
|
13
|
+
* (`expo-build-properties` `android.gifEnabled`/`webpEnabled: false`).
|
|
14
|
+
*/
|
|
15
|
+
import { Image } from 'react-native';
|
|
16
|
+
|
|
17
|
+
export interface ImageSize {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getImageSize(uri: string): Promise<ImageSize> {
|
|
23
|
+
return new Promise<ImageSize>((resolve, reject) => {
|
|
24
|
+
Image.getSize(uri, (width, height) => resolve({ width, height }), reject);
|
|
25
|
+
});
|
|
26
|
+
}
|