@oxy.so/services 13.2.0 → 13.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/lib/commonjs/ui/client.js +13 -0
  2. package/lib/commonjs/ui/client.js.map +1 -1
  3. package/lib/commonjs/ui/hooks/useAccountTheme.js +182 -0
  4. package/lib/commonjs/ui/hooks/useAccountTheme.js.map +1 -0
  5. package/lib/commonjs/ui/hooks/useFileDownloadUrl.js +64 -44
  6. package/lib/commonjs/ui/hooks/useFileDownloadUrl.js.map +1 -1
  7. package/lib/module/ui/client.js +1 -0
  8. package/lib/module/ui/client.js.map +1 -1
  9. package/lib/module/ui/hooks/useAccountTheme.js +178 -0
  10. package/lib/module/ui/hooks/useAccountTheme.js.map +1 -0
  11. package/lib/module/ui/hooks/useFileDownloadUrl.js +64 -44
  12. package/lib/module/ui/hooks/useFileDownloadUrl.js.map +1 -1
  13. package/lib/typescript/commonjs/ui/client.d.ts +2 -0
  14. package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/ui/hooks/useAccountTheme.d.ts +47 -0
  16. package/lib/typescript/commonjs/ui/hooks/useAccountTheme.d.ts.map +1 -0
  17. package/lib/typescript/commonjs/ui/hooks/useFileDownloadUrl.d.ts +10 -4
  18. package/lib/typescript/commonjs/ui/hooks/useFileDownloadUrl.d.ts.map +1 -1
  19. package/lib/typescript/module/ui/client.d.ts +2 -0
  20. package/lib/typescript/module/ui/client.d.ts.map +1 -1
  21. package/lib/typescript/module/ui/hooks/useAccountTheme.d.ts +47 -0
  22. package/lib/typescript/module/ui/hooks/useAccountTheme.d.ts.map +1 -0
  23. package/lib/typescript/module/ui/hooks/useFileDownloadUrl.d.ts +10 -4
  24. package/lib/typescript/module/ui/hooks/useFileDownloadUrl.d.ts.map +1 -1
  25. package/package.json +4 -4
  26. package/plugins/withOxyReleaseSigning.js +277 -0
  27. package/plugins/withOxySharedPermissions.js +8 -1
  28. package/src/ui/client.ts +2 -0
  29. package/src/ui/hooks/useAccountTheme.ts +177 -0
  30. package/src/ui/hooks/useFileDownloadUrl.ts +59 -49
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Config plugin: withOxyReleaseSigning (EVERY Oxy Android app, through
3
+ * `withOxySharedPermissions`; listed on its own only by an app that declares no
4
+ * Oxy permission).
5
+ *
6
+ * An app that declares the Oxy signature permissions (`so.oxy.permission.IDENTITY`,
7
+ * `DEVICE_SESSION`) is only safe to install when it carries the ONE Oxy ecosystem
8
+ * certificate. Signed with anything else it takes ownership of both permissions,
9
+ * after which Android refuses to install every correctly signed Oxy app next to it
10
+ * (`INSTALL_FAILED_DUPLICATE_PERMISSION`), and Commons refuses it as a caller
11
+ * anyway. That is why the plugin that declares the permissions applies this one:
12
+ * an app cannot get the permissions without the signing rule.
13
+ *
14
+ * It edits `android/app/build.gradle` in three places:
15
+ *
16
+ * 1. Before `android {`: resolve the keystore from Gradle properties (set in
17
+ * `~/.gradle/gradle.properties`, with `-P`, or as `ORG_GRADLE_PROJECT_<name>`
18
+ * environment variables; never in the repo):
19
+ * - `OXY_UPLOAD_STORE_FILE`: the keystore (`~/` is expanded).
20
+ * - `OXY_UPLOAD_STORE_PASSWORD`, or `OXY_UPLOAD_STORE_PASSWORD_FILE`: a file
21
+ * holding it (trimmed).
22
+ * - `OXY_UPLOAD_KEY_ALIAS`: `oxy` for the ecosystem key.
23
+ * - `OXY_UPLOAD_KEY_PASSWORD` / `OXY_UPLOAD_KEY_PASSWORD_FILE`: optional,
24
+ * defaults to the store password (a PKCS12 keystore has one password).
25
+ * 2. `signingConfigs.release` from those, and the `release` buildType signed by
26
+ * it ALWAYS (the `debug` buildType only with `OXY_UPLOAD_SIGN_DEBUG`, below).
27
+ * Expo's template signs release with the debug key; nothing here falls back
28
+ * to it.
29
+ * 3. `oxyVerifyReleaseSigning`, a task every release variant's `pre…ReleaseBuild`
30
+ * and `validateSigning…Release` depend on. It opens the release buildType's
31
+ * EFFECTIVE signing config and fails the build unless the key's certificate
32
+ * SHA-256 is the ecosystem one, so it holds however the keystore arrived
33
+ * (these properties, EAS's injected `credentials.json`, anything else) and it
34
+ * refuses a debug key, a per-app key and a wrong alias alike. Debug builds
35
+ * never run it.
36
+ *
37
+ * Opt-in for debug: `OXY_UPLOAD_SIGN_DEBUG=true` signs the debug buildType with
38
+ * the same key and runs the same guard before every debug variant, so a dev
39
+ * client can sit next to real Oxy apps and be granted the permissions (Willo's
40
+ * `withOxyDebugSigning` did this with `OXY_DEBUG_*`). Without it debug keeps
41
+ * Expo's debug key and is never checked.
42
+ *
43
+ * There is deliberately no opt-out for release: no CI job builds an Android
44
+ * release without the key, and a release signed with anything else must not
45
+ * exist. An app that declares no Oxy permission (Authenticator) lists this
46
+ * plugin directly to get the same release signing.
47
+ *
48
+ * `android/` is generated by `expo prebuild`, so these edits live here or they
49
+ * are lost on the next prebuild.
50
+ */
51
+ // The app's own Expo SDK copy, never one this package ships (Expo's guidance for library plugins).
52
+ const { withAppBuildGradle } = require('expo/config-plugins');
53
+
54
+ /** SHA-256 of the Oxy ecosystem release certificate (alias `oxy`), lowercase hex. */
55
+ const ECOSYSTEM_CERT_SHA256 = 'b8ab3746466ce4569d7a6a0ffa1eed91bc375174d503613987ca09cb33d6a58d';
56
+
57
+ const MARKER = '@generated oxy-release-signing';
58
+ const PLUGIN = '[@oxy.so/services withOxyReleaseSigning]';
59
+
60
+ /** Gradle property names holding the release signing material. */
61
+ const PROPERTIES = {
62
+ storeFile: 'OXY_UPLOAD_STORE_FILE',
63
+ storePassword: 'OXY_UPLOAD_STORE_PASSWORD',
64
+ keyAlias: 'OXY_UPLOAD_KEY_ALIAS',
65
+ keyPassword: 'OXY_UPLOAD_KEY_PASSWORD',
66
+ signDebug: 'OXY_UPLOAD_SIGN_DEBUG',
67
+ };
68
+
69
+ /** Resolves the OXY_UPLOAD_* properties; inserted before `android {`. */
70
+ const RESOLVE_PROPERTIES = `// ${MARKER}: the Oxy ecosystem release key (@oxy.so/services
71
+ // withOxyReleaseSigning). Edit the plugin, never this file.
72
+ def oxyHomePath = { String path -> path.startsWith('~/') ? System.getProperty('user.home') + path.substring(1) : path }
73
+ def oxySigningProblems = []
74
+ def oxySigningSecret = { String name ->
75
+ def value = findProperty(name)
76
+ if (value != null) return value.toString()
77
+ def path = findProperty(name + '_FILE')
78
+ if (path == null) return null
79
+ def secretFile = file(oxyHomePath(path.toString()))
80
+ if (!secretFile.isFile()) {
81
+ oxySigningProblems << (name + '_FILE names ' + secretFile + ', which is not a file')
82
+ return null
83
+ }
84
+ return secretFile.getText('UTF-8').trim()
85
+ }
86
+ def oxyStoreFilePath = findProperty('${PROPERTIES.storeFile}')
87
+ def oxyStoreFile = oxyStoreFilePath == null ? null : file(oxyHomePath(oxyStoreFilePath.toString()))
88
+ def oxyStorePassword = oxySigningSecret('${PROPERTIES.storePassword}')
89
+ def oxyKeyAlias = findProperty('${PROPERTIES.keyAlias}')?.toString()
90
+ def oxyKeyPassword = oxySigningSecret('${PROPERTIES.keyPassword}') ?: oxyStorePassword
91
+ if (oxyStoreFile == null) oxySigningProblems << '${PROPERTIES.storeFile} is not set'
92
+ if (oxyStorePassword == null && findProperty('${PROPERTIES.storePassword}_FILE') == null) oxySigningProblems << '${PROPERTIES.storePassword} (or ${PROPERTIES.storePassword}_FILE) is not set'
93
+ if (oxyKeyAlias == null) oxySigningProblems << '${PROPERTIES.keyAlias} is not set'
94
+ def oxyUploadSigning = oxyStoreFile != null && oxyStorePassword != null && oxyKeyAlias != null
95
+ // Opt-in: sign debug with the ecosystem key too (a dev client next to real Oxy apps),
96
+ // and then refuse a debug build without it exactly like a release.
97
+ def oxySignDebug = (findProperty('${PROPERTIES.signDebug}') ?: 'false').toString().toBoolean()
98
+ def oxySignedBuildTypes = oxySignDebug ? ['release', 'debug'] : ['release']
99
+
100
+ `;
101
+
102
+ const RELEASE_SIGNING_CONFIG = ` release {
103
+ // ${MARKER}: the Oxy ecosystem key from the OXY_UPLOAD_* properties.
104
+ // Without them it stays empty and oxyVerifyReleaseSigning fails the build.
105
+ if (oxyUploadSigning) {
106
+ storeFile oxyStoreFile
107
+ storePassword oxyStorePassword
108
+ keyAlias oxyKeyAlias
109
+ keyPassword oxyKeyPassword
110
+ }
111
+ }
112
+ `;
113
+
114
+ const RELEASE_SIGNING_LINE = 'signingConfig signingConfigs.release';
115
+ const DEBUG_SIGNING_LINE = 'signingConfig signingConfigs.debug';
116
+ const OPT_IN_DEBUG_SIGNING_LINE =
117
+ 'signingConfig oxySignDebug ? signingConfigs.release : signingConfigs.debug';
118
+
119
+ /** The fail-closed guard; appended to the file. */
120
+ const VERIFY_TASK = String.raw`
121
+ // ${MARKER}: every release build must carry the Oxy ecosystem certificate.
122
+ // Signed with anything else, the app declares so.oxy.permission.IDENTITY and
123
+ // DEVICE_SESSION under the wrong certificate: Android then refuses to install the
124
+ // correctly signed Oxy apps next to it (INSTALL_FAILED_DUPLICATE_PERMISSION), and
125
+ // Commons refuses it. This checks the release buildType's EFFECTIVE signing config.
126
+ def oxyVerifyReleaseSigning = tasks.register('oxyVerifyReleaseSigning') {
127
+ group = 'verification'
128
+ description = 'Fails unless release (and, with OXY_UPLOAD_SIGN_DEBUG, debug) is signed with the Oxy ecosystem certificate.'
129
+ notCompatibleWithConfigurationCache('reads the android extension when it runs')
130
+ doLast {
131
+ def expected = '${ECOSYSTEM_CERT_SHA256}'
132
+ def fail = { String reason ->
133
+ throw new GradleException([
134
+ 'Oxy release signing: ' + reason,
135
+ '',
136
+ 'Every Oxy Android release is signed with the ONE ecosystem key (certificate SHA-256 ' + expected + ').',
137
+ 'Provide it as Gradle properties, in ~/.gradle/gradle.properties, as -P<name>=<value>,',
138
+ 'or as ORG_GRADLE_PROJECT_<name> environment variables:',
139
+ ' ${PROPERTIES.storeFile}=~/.config/oxy/oxy-ecosystem-release.keystore',
140
+ ' ${PROPERTIES.storePassword}_FILE=~/.config/oxy/oxy-ecosystem-release-keystore.pass (or ${PROPERTIES.storePassword})',
141
+ ' ${PROPERTIES.keyAlias}=oxy',
142
+ ' ${PROPERTIES.keyPassword}[_FILE] only when the key password differs from the store password',
143
+ 'Debug builds need none of this unless ${PROPERTIES.signDebug}=true.',
144
+ ].join('\n'))
145
+ }
146
+ oxySignedBuildTypes.each { buildType ->
147
+ def config = android.buildTypes.getByName(buildType).signingConfig
148
+ if (config == null || config.storeFile == null) {
149
+ fail('the ' + buildType + ' build has no keystore' + (oxySigningProblems ? ': ' + oxySigningProblems.join('; ') : '') + '.')
150
+ }
151
+ def keyStore = null
152
+ try {
153
+ keyStore = java.security.KeyStore.getInstance(config.storeFile, config.storePassword?.toCharArray())
154
+ } catch (Exception e) {
155
+ fail('could not open ' + config.storeFile + ' (' + e.getClass().getSimpleName() + ': ' + e.getMessage() + ').')
156
+ }
157
+ def certificate = keyStore.getCertificate(config.keyAlias)
158
+ if (certificate == null) {
159
+ fail(config.storeFile.toString() + ' has no certificate under the alias "' + config.keyAlias + '".')
160
+ }
161
+ def actual = java.security.MessageDigest.getInstance('SHA-256').digest(certificate.getEncoded()).encodeHex().toString()
162
+ if (actual != expected) {
163
+ fail('the ' + buildType + ' build is signed by "' + config.name + '" (' + config.storeFile + ', alias ' + config.keyAlias + '), certificate SHA-256 ' + actual + ', not the Oxy ecosystem one.')
164
+ }
165
+ }
166
+ }
167
+ }
168
+ tasks.configureEach { task ->
169
+ if (task.name ==~ (oxySignDebug ? /pre\w*(Release|Debug)Build|validateSigning\w*(Release|Debug)/ : /pre\w*ReleaseBuild|validateSigning\w*Release/)) {
170
+ task.dependsOn(oxyVerifyReleaseSigning)
171
+ }
172
+ }
173
+ `;
174
+
175
+ function fail(message) {
176
+ throw new Error(`${PLUGIN} ${message}`);
177
+ }
178
+
179
+ /**
180
+ * Apply the edits to the text of a Groovy `android/app/build.gradle`.
181
+ * Idempotent; refuses a file another plugin already gave a release signing config.
182
+ *
183
+ * @param {string} contents
184
+ * @returns {string}
185
+ */
186
+ function applyReleaseSigning(contents) {
187
+ if (contents.includes(MARKER)) return contents;
188
+
189
+ if (contents.includes('OXY_UPLOAD_')) {
190
+ fail(
191
+ 'app/build.gradle already has an OXY_UPLOAD_* signing config written by another plugin: ' +
192
+ "`@oxy.so/app-preset/plugin/withOxyAndroidRelease` (app-preset 4 or earlier) or an app's " +
193
+ "own copy (such as Mention's plugins/withAndroidReleaseBuild.js). Remove it from the app " +
194
+ 'config: withOxySharedPermissions signs every Oxy release.',
195
+ );
196
+ }
197
+
198
+ if (contents.includes('OXY_DEBUG_')) {
199
+ fail(
200
+ "app/build.gradle already has an OXY_DEBUG_* debug signing config (such as Willo's " +
201
+ 'plugins/withOxyDebugSigning.js). Remove that plugin and set the Gradle property ' +
202
+ `${PROPERTIES.signDebug}=true to sign debug builds with the ecosystem key.`,
203
+ );
204
+ }
205
+
206
+ const androidBlock = /^android \{$/m;
207
+ if (!androidBlock.test(contents)) {
208
+ fail(
209
+ 'Could not find the top-level `android {` block in app/build.gradle. The Expo template changed: update this plugin.',
210
+ );
211
+ }
212
+ let next = contents.replace(androidBlock, `${RESOLVE_PROPERTIES}android {`);
213
+
214
+ // Declare signingConfigs.release immediately after the generated debug entry.
215
+ const debugSigningBlock =
216
+ /(signingConfigs\s*\{\s*\n\s*debug\s*\{[\s\S]*?\n\s{8}\}\n)(\s*release\s*\{)?/;
217
+ const debugMatch = next.match(debugSigningBlock);
218
+ if (!debugMatch) {
219
+ fail(
220
+ 'Could not find the generated signingConfigs.debug block in app/build.gradle. The Expo template changed: update this plugin.',
221
+ );
222
+ }
223
+ if (debugMatch[2]) {
224
+ fail(
225
+ 'app/build.gradle already declares signingConfigs.release. Remove the plugin that writes it: withOxySharedPermissions signs every Oxy release.',
226
+ );
227
+ }
228
+ next = next.replace(debugSigningBlock, `$1${RELEASE_SIGNING_CONFIG}`);
229
+
230
+ // Point the release buildType at it. The debug buildType has its own
231
+ // `signingConfig signingConfigs.debug`, so only the line inside `release {` is replaced.
232
+ const buildTypes = next.indexOf('buildTypes {');
233
+ const releaseBuildType = buildTypes === -1 ? -1 : next.indexOf('release {', buildTypes);
234
+ if (releaseBuildType === -1) {
235
+ fail('Could not find the release buildType in app/build.gradle.');
236
+ }
237
+ const releaseBody = next.slice(releaseBuildType);
238
+ const signingLine = releaseBody.match(/signingConfig [^\n]+/);
239
+ const closing = releaseBody.indexOf('\n }');
240
+ if (
241
+ !signingLine ||
242
+ signingLine.index === undefined ||
243
+ (closing !== -1 && signingLine.index > closing)
244
+ ) {
245
+ fail('Could not find the signingConfig line of the release buildType in app/build.gradle.');
246
+ }
247
+ const at = releaseBuildType + signingLine.index;
248
+ next = next.slice(0, at) + RELEASE_SIGNING_LINE + next.slice(at + signingLine[0].length);
249
+
250
+ // The debug buildType keeps Expo's debug key unless OXY_UPLOAD_SIGN_DEBUG asks.
251
+ const debugBuildType = next.indexOf('debug {', next.indexOf('buildTypes {'));
252
+ const debugLine = debugBuildType === -1 ? -1 : next.indexOf(DEBUG_SIGNING_LINE, debugBuildType);
253
+ if (debugLine === -1 || debugLine > next.indexOf('release {', next.indexOf('buildTypes {'))) {
254
+ fail('Could not find the signingConfig line of the debug buildType in app/build.gradle.');
255
+ }
256
+ next =
257
+ next.slice(0, debugLine) +
258
+ OPT_IN_DEBUG_SIGNING_LINE +
259
+ next.slice(debugLine + DEBUG_SIGNING_LINE.length);
260
+
261
+ return `${next.replace(/\n*$/, '\n')}${VERIFY_TASK}`;
262
+ }
263
+
264
+ function withOxyReleaseSigning(config) {
265
+ return withAppBuildGradle(config, (modConfig) => {
266
+ if (modConfig.modResults.language !== 'groovy') {
267
+ fail(`Expected a Groovy app/build.gradle, got ${modConfig.modResults.language}.`);
268
+ }
269
+ modConfig.modResults.contents = applyReleaseSigning(modConfig.modResults.contents);
270
+ return modConfig;
271
+ });
272
+ }
273
+
274
+ module.exports = withOxyReleaseSigning;
275
+ module.exports.applyReleaseSigning = applyReleaseSigning;
276
+ module.exports.ECOSYSTEM_CERT_SHA256 = ECOSYSTEM_CERT_SHA256;
277
+ module.exports.PROPERTIES = PROPERTIES;
@@ -29,9 +29,16 @@
29
29
  *
30
30
  * `signature` is the whole trust boundary at the manifest level; each provider
31
31
  * also checks the calling package and its certificate itself.
32
+ *
33
+ * Declaring these permissions is only safe under the ONE Oxy ecosystem
34
+ * certificate, so this plugin also applies `withOxyReleaseSigning`: every
35
+ * release build is signed with the ecosystem key from the `OXY_UPLOAD_*` Gradle
36
+ * properties and FAILS unless its certificate is the ecosystem one. Apps list
37
+ * neither that plugin nor any signing plugin of their own.
32
38
  */
33
39
  // The app's own Expo SDK copy, never one this package ships (Expo's guidance for library plugins).
34
40
  const { withAndroidManifest } = require('expo/config-plugins');
41
+ const withOxyReleaseSigning = require('./withOxyReleaseSigning');
35
42
 
36
43
  const IDENTITY_PERMISSION = 'so.oxy.permission.IDENTITY';
37
44
  const DEVICE_SESSION_PERMISSION = 'so.oxy.permission.DEVICE_SESSION';
@@ -52,7 +59,7 @@ const HOST_AUTHORITIES = [
52
59
  ];
53
60
 
54
61
  function withOxySharedPermissions(config) {
55
- return withAndroidManifest(config, (modConfig) => {
62
+ return withAndroidManifest(withOxyReleaseSigning(config), (modConfig) => {
56
63
  const manifest = modConfig.modResults.manifest;
57
64
 
58
65
  manifest.permission = manifest.permission ?? [];
package/src/ui/client.ts CHANGED
@@ -53,6 +53,8 @@ export {
53
53
  resolveAccountColor,
54
54
  entitledAccountColors,
55
55
  } from './hooks/useAccountColor';
56
+ export { useAccountTheme, useAccountThemeControls } from './hooks/useAccountTheme';
57
+ export type { AccountThemeControls, ThemeSource } from './hooks/useAccountTheme';
56
58
  // This app's Oxy-signed assertion of which app it is (ADR 0045), for an SDK
57
59
  // that attaches it to its own requests without pulling in the account screens.
58
60
  export {
@@ -0,0 +1,177 @@
1
+ /**
2
+ * useAccountTheme / useAccountThemeControls
3
+ *
4
+ * The Oxy account carries a portable theme (`user.themePreference`: a mode and
5
+ * a colour preset) that every Oxy app can wear. The COLOUR is the account
6
+ * colour and always applies (`useAccountColor`). Whether an app also follows
7
+ * the account's MODE is the viewer's choice per device: the theme SOURCE.
8
+ *
9
+ * - `account` (the default): the account's mode is applied to Bloom, and a mode
10
+ * chosen here is written back to the account for the other Oxy apps.
11
+ * - `app`: the mode stays on this device; nothing is written to the account.
12
+ *
13
+ * The source is a local preference, never sent anywhere. On web it is read
14
+ * synchronously, so the first paint already knows it; on native it resolves
15
+ * from AsyncStorage right after mount. Bloom's own persistence still paints the
16
+ * last theme instantly on cold boot, so the account theme lands without a flash
17
+ * once the session resolves.
18
+ */
19
+
20
+ import { useCallback, useEffect } from 'react';
21
+ import { create } from 'zustand';
22
+ import { logger, type User } from '@oxy.so/core';
23
+ import { useBloomTheme, type AppColorName, type ThemeMode } from '@oxy.so/bloom/theme';
24
+ import { createPlatformStorage, isReactNative } from '../utils/storageHelpers';
25
+ import { resolveAccountColor, useAccountColor } from './useAccountColor';
26
+ import { useAuth } from './useAuth';
27
+ import { useUpdateProfile } from './mutations/useAccountMutations';
28
+
29
+ /** Where the app's theme MODE comes from on this device. */
30
+ export type ThemeSource = 'app' | 'account';
31
+
32
+ type PortableThemeMode = NonNullable<User['themePreference']>['mode'];
33
+
34
+ const THEME_SOURCE_KEY = 'oxy.theme.source';
35
+
36
+ function normalizeSource(stored: string | null | undefined): ThemeSource {
37
+ return stored === 'app' ? 'app' : 'account';
38
+ }
39
+
40
+ /**
41
+ * Bloom's richer mode set onto the portable subset the account stores
42
+ * (`adaptive` has no cross-app meaning, so it is `system`).
43
+ */
44
+ function toPortableMode(mode: ThemeMode): PortableThemeMode {
45
+ return mode === 'light' || mode === 'dark' ? mode : 'system';
46
+ }
47
+
48
+ /** Web reads `localStorage` synchronously; native resolves in `hydrate`. */
49
+ function readInitialSource(): { source: ThemeSource; hydrated: boolean } {
50
+ if (isReactNative() || typeof window === 'undefined') {
51
+ return { source: 'account', hydrated: false };
52
+ }
53
+ try {
54
+ return {
55
+ source: normalizeSource(window.localStorage?.getItem(THEME_SOURCE_KEY)),
56
+ hydrated: true,
57
+ };
58
+ } catch {
59
+ // A sandboxed document's storage getter throws: no stored choice.
60
+ return { source: 'account', hydrated: true };
61
+ }
62
+ }
63
+
64
+ interface ThemeSourceState {
65
+ source: ThemeSource;
66
+ /** True once the stored source is known (at once on web). */
67
+ hydrated: boolean;
68
+ setSource: (source: ThemeSource) => void;
69
+ hydrate: () => Promise<void>;
70
+ }
71
+
72
+ const useThemeSourceStore = create<ThemeSourceState>((set, get) => ({
73
+ ...readInitialSource(),
74
+ setSource(source) {
75
+ set({ source, hydrated: true });
76
+ void createPlatformStorage()
77
+ .then((storage) => storage.setItem(THEME_SOURCE_KEY, source))
78
+ .catch(() => undefined);
79
+ },
80
+ async hydrate() {
81
+ if (get().hydrated) return;
82
+ const stored = await createPlatformStorage()
83
+ .then((storage) => storage.getItem(THEME_SOURCE_KEY))
84
+ .catch(() => null);
85
+ // A choice made while storage was being read wins over the stored one.
86
+ if (!get().hydrated) set({ source: normalizeSource(stored), hydrated: true });
87
+ },
88
+ }));
89
+
90
+ /**
91
+ * Apply the account theme to Bloom: the colour always (through
92
+ * `useAccountColor`, which `canUse` can narrow), the mode while this device's
93
+ * source is `account`. Mount ONCE, inside `OxyProvider` and Bloom's theme
94
+ * provider.
95
+ */
96
+ export function useAccountTheme({
97
+ canUse,
98
+ }: {
99
+ canUse?: (name: AppColorName) => boolean;
100
+ } = {}): void {
101
+ const { isAuthenticated, user } = useAuth();
102
+ const source = useThemeSourceStore((state) => state.source);
103
+ const hydrated = useThemeSourceStore((state) => state.hydrated);
104
+ const hydrate = useThemeSourceStore((state) => state.hydrate);
105
+ const { setMode } = useBloomTheme();
106
+ useAccountColor({ canUse });
107
+
108
+ useEffect(() => {
109
+ void hydrate();
110
+ }, [hydrate]);
111
+
112
+ const accountMode = user?.themePreference?.mode;
113
+ useEffect(() => {
114
+ if (!hydrated || !isAuthenticated || source !== 'account' || !accountMode) return;
115
+ setMode(accountMode);
116
+ }, [hydrated, isAuthenticated, source, accountMode, setMode]);
117
+ }
118
+
119
+ export interface AccountThemeControls {
120
+ /** The effective source: `app` whenever no session can reach the account. */
121
+ source: ThemeSource;
122
+ /** Switch this device's source; enabling `account` seeds an account with no theme yet. */
123
+ changeThemeSource: (source: ThemeSource) => void;
124
+ /** Change the mode now; on the `account` source, also write it to the account. */
125
+ changeThemeMode: (mode: ThemeMode) => Promise<void>;
126
+ }
127
+
128
+ /**
129
+ * Source-aware theme mode changes for a settings screen. Bloom changes at once;
130
+ * on the `account` source the account's portable theme follows. Without a
131
+ * usable session the effective source is `app`, so changes stay local, and the
132
+ * stored choice applies again once signed in. The colour is chosen with
133
+ * `useAccountColorSave`.
134
+ */
135
+ export function useAccountThemeControls(): AccountThemeControls {
136
+ const { user, canUsePrivateApi } = useAuth();
137
+ const { mode, colorPreset, setMode } = useBloomTheme();
138
+ const { mutateAsync } = useUpdateProfile();
139
+ const storedSource = useThemeSourceStore((state) => state.source);
140
+ const setSource = useThemeSourceStore((state) => state.setSource);
141
+ const source: ThemeSource = canUsePrivateApi ? storedSource : 'app';
142
+
143
+ const persist = useCallback(
144
+ async (next: { mode: ThemeMode; colorPreset: AppColorName }) => {
145
+ await mutateAsync({
146
+ themePreference: { mode: toPortableMode(next.mode), colorPreset: next.colorPreset },
147
+ });
148
+ },
149
+ [mutateAsync],
150
+ );
151
+
152
+ const changeThemeMode = useCallback(
153
+ async (nextMode: ThemeMode) => {
154
+ setMode(nextMode);
155
+ if (source === 'account') await persist({ mode: nextMode, colorPreset });
156
+ },
157
+ [setMode, source, persist, colorPreset],
158
+ );
159
+
160
+ const accountTheme = user?.themePreference;
161
+ const hasAccountTheme =
162
+ !!accountTheme && resolveAccountColor({ themePreference: accountTheme }) !== null;
163
+ const changeThemeSource = useCallback(
164
+ (nextSource: ThemeSource) => {
165
+ setSource(nextSource);
166
+ // An existing account theme is applied by `useAccountTheme`; only an
167
+ // account without one is seeded, from what the viewer sees now.
168
+ if (nextSource !== 'account' || !canUsePrivateApi || hasAccountTheme) return;
169
+ persist({ mode, colorPreset }).catch((error: unknown) => {
170
+ logger.error('Failed to seed the account theme', error);
171
+ });
172
+ },
173
+ [setSource, canUsePrivateApi, hasAccountTheme, persist, mode, colorPreset],
174
+ );
175
+
176
+ return { source, changeThemeSource, changeThemeMode };
177
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import type { OxyServices } from '@oxy.so/core';
3
+ import { useOptionalOxy } from '../context/oxyRuntimeContext';
3
4
 
4
5
  export interface UseFileDownloadUrlOptions {
5
6
  variant?: string;
@@ -12,72 +13,81 @@ export interface UseFileDownloadUrlResult {
12
13
  error: Error | null;
13
14
  }
14
15
 
16
+ interface Settled {
17
+ request: string;
18
+ url: string | null;
19
+ error: Error | null;
20
+ }
21
+
22
+ function isAbsoluteUrl(ref: string): boolean {
23
+ return ref.startsWith('http://') || ref.startsWith('https://');
24
+ }
25
+
15
26
  /**
16
- * Hook to resolve a file's download URL asynchronously.
27
+ * Resolve a file's download URL for the current caller.
17
28
  *
18
- * Uses `getFileDownloadUrlAsync`, which returns a scoped media-token stream URL
19
- * for private assets. There is no sync CDN fallback — the synchronous
20
- * `getFileDownloadUrl` is public-CDN-only and 404s for private uploads.
29
+ * A URL `assets.url` already resolved is answered in the FIRST render from
30
+ * core's identity-scoped cache (`assets.cachedUrl`): no request, no loading
31
+ * frame. Only a miss resolves asynchronously, through `assets.url`, which
32
+ * returns a scoped media-token stream URL for a private asset. There is no
33
+ * public-CDN fallback: `assets.publicUrl` 404s for private uploads. A
34
+ * reference that is already an absolute `http(s)` URL is returned unchanged.
35
+ *
36
+ * For a synchronous, render-only answer (an image resolver, a list row) use
37
+ * `oxyServices.assets.renderUrl`, which reads the same cache.
21
38
  */
22
39
  export const useFileDownloadUrl = (
23
40
  oxyServices: OxyServices | null | undefined,
24
41
  fileId: string | null | undefined,
25
42
  options?: UseFileDownloadUrlOptions,
26
43
  ): UseFileDownloadUrlResult => {
27
- const [url, setUrl] = useState<string | null>(null);
28
- const [loading, setLoading] = useState(false);
29
- const [error, setError] = useState<Error | null>(null);
30
-
31
44
  const variant = options?.variant;
32
45
  const expiresIn = options?.expiresIn;
46
+ // A resolved URL can carry the viewer's scoped media token, so the viewer is
47
+ // part of what was asked: an account switch never shows the previous one's.
48
+ const viewerId = useOptionalOxy()?.user?.id ?? '';
49
+ const request = fileId ? `${viewerId}|${fileId}|${variant ?? ''}|${expiresIn ?? ''}` : null;
50
+ const immediate = !fileId
51
+ ? null
52
+ : isAbsoluteUrl(fileId)
53
+ ? fileId
54
+ : (oxyServices?.assets.cachedUrl(fileId, variant, expiresIn) ?? null);
55
+ const needsResolve = !!fileId && !!oxyServices && immediate === null;
33
56
 
34
- useEffect(() => {
35
- if (!fileId) {
36
- setUrl(null);
37
- setLoading(false);
38
- setError(null);
39
- return;
40
- }
41
-
42
- if (!oxyServices) {
43
- setUrl(null);
44
- setLoading(false);
45
- setError(new Error('OxyServices instance not configured for useFileDownloadUrl'));
46
- return;
47
- }
57
+ const [settled, setSettled] = useState<Settled | null>(null);
48
58
 
59
+ useEffect(() => {
60
+ if (!needsResolve || !fileId || !oxyServices || !request) return;
49
61
  let cancelled = false;
50
- const instance = oxyServices;
51
- const targetFileId = fileId;
52
-
53
- const load = async () => {
54
- setLoading(true);
55
- setError(null);
56
-
57
- try {
58
- const resolvedUrl = await instance.assets.url(targetFileId, variant, expiresIn);
59
-
62
+ oxyServices.assets.url(fileId, variant, expiresIn).then(
63
+ (url) => {
64
+ if (!cancelled) setSettled({ request, url: url || null, error: null });
65
+ },
66
+ (err: unknown) => {
60
67
  if (!cancelled) {
61
- setUrl(resolvedUrl || null);
68
+ setSettled({
69
+ request,
70
+ url: null,
71
+ error: err instanceof Error ? err : new Error(String(err)),
72
+ });
62
73
  }
63
- } catch (err: unknown) {
64
- if (!cancelled) {
65
- setUrl(null);
66
- setError(err instanceof Error ? err : new Error(String(err)));
67
- }
68
- } finally {
69
- if (!cancelled) {
70
- setLoading(false);
71
- }
72
- }
73
- };
74
-
75
- load();
76
-
74
+ },
75
+ );
77
76
  return () => {
78
77
  cancelled = true;
79
78
  };
80
- }, [fileId, oxyServices, variant, expiresIn]);
79
+ }, [needsResolve, fileId, oxyServices, variant, expiresIn, request]);
81
80
 
82
- return { url, loading, error };
81
+ if (!fileId) return { url: null, loading: false, error: null };
82
+ if (immediate !== null) return { url: immediate, loading: false, error: null };
83
+ if (!oxyServices) {
84
+ return {
85
+ url: null,
86
+ loading: false,
87
+ error: new Error('OxyServices instance not configured for useFileDownloadUrl'),
88
+ };
89
+ }
90
+ // A result settled for another file, variant or expiry is never shown.
91
+ if (settled?.request !== request) return { url: null, loading: true, error: null };
92
+ return { url: settled.url, loading: false, error: settled.error };
83
93
  };