@entropy-softworks/ui 2026.8.41 → 2026.8.42

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/CHANGELOG.md CHANGED
@@ -7,151 +7,161 @@ are CalVer — `YYYY.M.D` of the publish date, with leading zeros dropped (npm's
7
7
  them), matching the `entropy-auth` crate. The `0.1.0` entry below predates the switch and was never
8
8
  published to npm.
9
9
 
10
+ ## 2026.8.42
11
+
12
+ ### Fixed
13
+
14
+ - Custom uploaded icons never rendered on a phone. `useUploadBlobUrl` built its URI with
15
+ `URL.createObjectURL`, which is a browser API — React Native's `URL` does not implement it in any
16
+ way that can be relied on, so the call either threw or produced something no image loader would
17
+ open, and every upload fell through to the fallback glyph on every native consumer. Web still gets
18
+ a blob URL (no copy, and revocable); native now reads the bytes and builds
19
+ `data:<type>;base64,...`, which every RN image loader accepts. Base64 inflates by a third and the
20
+ string lives as long as the row is mounted, which is affordable for icons — vault caps an upload
21
+ at 256KB — and is why the two platforms differ rather than both taking the data URI.
22
+ - The base64 encoder is hand-rolled rather than `btoa`. React Native ships no `btoa`, so whether one
23
+ exists depends on which polyfill the consuming app installed, which is not something a library can
24
+ assume.
25
+
26
+ ### Added
27
+
28
+ - `useUploadBlobUrl` takes an options object — `{ uploadsPath, resolve }` — in place of the bare
29
+ path, which still works. `resolve` produces the URI for an upload id without going through
30
+ `apiFetch` at all, for a consumer whose uploads are not behind a server: vault's device-only
31
+ vaults keep theirs in on-device SQLite. Returning `null` reports `errored`, so an id that does not
32
+ resolve draws the same fallback a failed fetch does. The resolver is held in a ref and only
33
+ `uploadId` / `uploadsPath` re-run the effect, so an arrow defined inline at the call site cannot
34
+ turn every render into a refetch.
35
+ - `ItemIcon` takes `resolveUpload`, passed straight through. Without it such a consumer had to
36
+ pre-resolve the reference into a `data:` URI and hand it over as `iconUrl` — which works only
37
+ because anything that is not `custom:` is treated as a plain URL, and hides the intent completely.
38
+
10
39
  ## 2026.8.41
11
40
 
12
41
  ### Fixed
13
42
 
14
- - `AuthScreen`'s form-height lock skipped its measuring pass for any consumer
15
- that resolves `signUpEnabled` asynchronously. `probed` was initialised from
16
- `signUpEnabled`, which is right when the consumer knows its registration
17
- policy at mount but one that has to ASK for it (Access's portal fetches the
18
- tenant's policy and starts at the conservative "closed") mounted with sign-up
19
- off, skipped the pre-measure, and turned sign-up on a moment later with
20
- nothing measured. The first real toggle was then the one that grew the card:
21
- it resized once, then held still forever, which is exactly the symptom the
22
- lock exists to remove. The probe now re-arms on the false -> true edge.
43
+ - `AuthScreen`'s form-height lock skipped its measuring pass for any consumer that resolves
44
+ `signUpEnabled` asynchronously. `probed` was initialised from `signUpEnabled`, which is right when
45
+ the consumer knows its registration policy at mount but one that has to ASK for it (Access's
46
+ portal fetches the tenant's policy and starts at the conservative "closed") mounted with sign-up
47
+ off, skipped the pre-measure, and turned sign-up on a moment later with nothing measured. The
48
+ first real toggle was then the one that grew the card: it resized once, then held still forever,
49
+ which is exactly the symptom the lock exists to remove. The probe now re-arms on the false -> true
50
+ edge.
23
51
 
24
52
  ## 2026.8.40
25
53
 
26
54
  ### Added
27
55
 
28
- - `AuthScreen` takes `lockFormHeight` (default `true`). The card reserves the
29
- height of the taller of Sign In / Sign Up so the two toggle without the
30
- layout moving; the cost is slack, left as empty space above the shorter
31
- mode's submit button. Where Sign Up carries several more fields that gap is
32
- the more visible flaw of the two most people only ever sign in, and they
33
- all see it — so a consumer can now let the card resize instead. The body is
34
- already wrapped in a layout animation, so the change eases rather than jumps.
56
+ - `AuthScreen` takes `lockFormHeight` (default `true`). The card reserves the height of the taller
57
+ of Sign In / Sign Up so the two toggle without the layout moving; the cost is slack, left as empty
58
+ space above the shorter mode's submit button. Where Sign Up carries several more fields that gap
59
+ is the more visible flaw of the two most people only ever sign in, and they all see it — so a
60
+ consumer can now let the card resize instead. The body is already wrapped in a layout animation,
61
+ so the change eases rather than jumps.
35
62
 
36
63
  ## 2026.8.39
37
64
 
38
65
  ### Added
39
66
 
40
- - `SocialConnections` takes `showEntropy` (default `true`), which drops the
41
- first-party "Continue with Entropy" button from the row. Unlike the other
42
- three, that one is not a staged-rollout placeholder waiting for its
43
- connector: on Entropy Access's own sign-in surfaces it offers to sign in to
44
- the identity provider with the identity provider, which is a button that can
45
- never mean anything. A flag rather than inferring intent from a missing
46
- `onEntropy`, because rendering an unhandled provider is deliberate here
47
- simple-finance renders the whole row with no handlers at all, and inference
48
- would silently take that button away from it.
67
+ - `SocialConnections` takes `showEntropy` (default `true`), which drops the first-party "Continue
68
+ with Entropy" button from the row. Unlike the other three, that one is not a staged-rollout
69
+ placeholder waiting for its connector: on Entropy Access's own sign-in surfaces it offers to sign
70
+ in to the identity provider with the identity provider, which is a button that can never mean
71
+ anything. A flag rather than inferring intent from a missing `onEntropy`, because rendering an
72
+ unhandled provider is deliberate here simple-finance renders the whole row with no handlers at
73
+ all, and inference would silently take that button away from it.
49
74
 
50
75
  ## 2026.8.35
51
76
 
52
77
  ### Fixed
53
78
 
54
- - `AuthService.getCurrentUser()` destroyed the session it was asked about. It
55
- returned `loadPersistedUser()` and nothing else, and on web that reads a
56
- module-level variable which is null in every freshly loaded document — so it
57
- answered "nobody" on each page load and refresh while the session cookie was
58
- perfectly valid. `useAuth.checkAuth` treats that as a session it cannot
59
- resolve and calls `logout()`, which POSTs `/auth/logout` and ends the session
60
- server-side: the user was signed out by the act of loading the page.
61
-
62
- Observed on Access, whose portal and admin console are separate SPAs on one
63
- origin so every navigation between them is a fresh document and therefore a
64
- guaranteed logout, presenting as an infinite sign-in loop. Its audit log
65
- recorded the logouts nobody had asked for.
66
-
67
- Native was affected too, if less visibly: `isAuthenticated()` there is
68
- satisfied by a token in SecureStore, so a token that outlived its cached user
69
- record produced the same spurious logout.
70
-
71
- `getCurrentUser()` now falls back to `GET /auth/me` when nothing is persisted.
72
- It deliberately does not route through `isAuthenticated()`, whose web branch
73
- falls back to calling `getCurrentUser()` going the other way would recurse
74
- until the stack gave out, on exactly the signed-out path this makes cheap.
75
- The request is shared with `refreshUserData()` through a private `fetchMe`,
76
- parameterised on whether a 401 should log out: a refresh of a session
77
- believed live that 401s means the session died, while a lookup that 401s is
78
- just an anonymous visitor and must not tear anything down.
79
+ - `AuthService.getCurrentUser()` destroyed the session it was asked about. It returned
80
+ `loadPersistedUser()` and nothing else, and on web that reads a module-level variable which is
81
+ null in every freshly loaded document — so it answered "nobody" on each page load and refresh
82
+ while the session cookie was perfectly valid. `useAuth.checkAuth` treats that as a session it
83
+ cannot resolve and calls `logout()`, which POSTs `/auth/logout` and ends the session server-side:
84
+ the user was signed out by the act of loading the page.
85
+
86
+ Observed on Access, whose portal and admin console are separate SPAs on one origin — so every
87
+ navigation between them is a fresh document and therefore a guaranteed logout, presenting as an
88
+ infinite sign-in loop. Its audit log recorded the logouts nobody had asked for.
89
+
90
+ Native was affected too, if less visibly: `isAuthenticated()` there is satisfied by a token in
91
+ SecureStore, so a token that outlived its cached user record produced the same spurious logout.
92
+
93
+ `getCurrentUser()` now falls back to `GET /auth/me` when nothing is persisted. It deliberately
94
+ does not route through `isAuthenticated()`, whose web branch falls back to calling
95
+ `getCurrentUser()` — going the other way would recurse until the stack gave out, on exactly the
96
+ signed-out path this makes cheap. The request is shared with `refreshUserData()` through a private
97
+ `fetchMe`, parameterised on whether a 401 should log out: a refresh of a session believed live
98
+ that 401s means the session died, while a lookup that 401s is just an anonymous visitor and must
99
+ not tear anything down.
79
100
 
80
101
  ## 2026.8.17
81
102
 
82
103
  ### Fixed
83
104
 
84
- - The systemic bare-CSS-var-no-`dark:`-fallback pattern flagged as a known
85
- follow-up in 2026.8.15 — completed across the rest of the package: `Card`,
86
- `ContextMenu`, `DataTable`, `DropdownMenu`, `Popover`, `SettingsRow`,
87
- `PinPad`, `PasswordGenerator`, `EntropyByline`, `ErrorBoundary` (severe:
88
- its retry button's label was invisible white-on-white in dark mode),
89
- `Pagination`, `LockOverlay`, `ProtectedRoute`, and every screen under
90
- `src/screens/auth/*` (root backgrounds, plus scattered destructive-text and
91
- `ServerConfigScreen`'s from-scratch raw-`Text` markup).
92
- - `CardFooter`'s base `items-center` was never overridden by the `flex-col`
93
- className every consuming screen passes it (`AuthScreen`, `LoginScreen`,
94
- `RegisterScreen`, and ~10 others) `flex-row`→`flex-col` and `border-t`→
95
- `border-t-0` correctly override via tailwind-merge's same-property
96
- matching, but `items-center` has no `items-*` counterpart in that
97
- className to conflict with, so it silently survived. Left the submit
98
- button's wrapping view with ambiguous (content-fit) width instead of
99
- stretching to match its own `w-full` Pressable child — surfaced as the
100
- "Sign In" button rendering with a runaway width specifically once its
101
- content swapped from label text to the loading spinner (the one thing
102
- that actually changes Yoga's layout resolution for that ambiguous case).
103
- Added `items-stretch` alongside `flex-col` at every call site.
105
+ - The systemic bare-CSS-var-no-`dark:`-fallback pattern flagged as a known follow-up in 2026.8.15 —
106
+ completed across the rest of the package: `Card`, `ContextMenu`, `DataTable`, `DropdownMenu`,
107
+ `Popover`, `SettingsRow`, `PinPad`, `PasswordGenerator`, `EntropyByline`, `ErrorBoundary` (severe:
108
+ its retry button's label was invisible white-on-white in dark mode), `Pagination`, `LockOverlay`,
109
+ `ProtectedRoute`, and every screen under `src/screens/auth/*` (root backgrounds, plus scattered
110
+ destructive-text and `ServerConfigScreen`'s from-scratch raw-`Text` markup).
111
+ - `CardFooter`'s base `items-center` was never overridden by the `flex-col` className every
112
+ consuming screen passes it (`AuthScreen`, `LoginScreen`, `RegisterScreen`, and ~10 others)
113
+ `flex-row`→`flex-col` and `border-t`→ `border-t-0` correctly override via tailwind-merge's
114
+ same-property matching, but `items-center` has no `items-*` counterpart in that className to
115
+ conflict with, so it silently survived. Left the submit button's wrapping view with ambiguous
116
+ (content-fit) width instead of stretching to match its own `w-full` Pressable child surfaced as
117
+ the "Sign In" button rendering with a runaway width specifically once its content swapped from
118
+ label text to the loading spinner (the one thing that actually changes Yoga's layout resolution
119
+ for that ambiguous case). Added `items-stretch` alongside `flex-col` at every call site.
104
120
 
105
121
  ## 2026.8.15
106
122
 
107
123
  ### Fixed
108
124
 
109
- - Several components colored themselves with a bare CSS-variable utility
110
- (`bg-primary`, `bg-background`, `border-border`, `bg-destructive`, …) and no
111
- `dark:` fallback on native Android that value stays stuck on its light
112
- setting regardless of actual system theme; only components carrying an
113
- explicit `dark:`-prefixed class (the existing pattern in `Heading`/`Text`/
114
- `MutedText`) reacted correctly. Added matching `dark:` variants one per
115
- CSS variable's own `.dark` value in `styles/globals.css` to `Button`,
116
- `Input`, `Dialog`'s destructive variant, `legal-dialog`'s body text, and
117
- `AuthScreen`'s root background. Surfaced as vault's "Get Started" screen
118
- going render-broken in dark mode: invisible button, invisible heading/legal
119
- text, and a background that stayed white.
120
- - `AuthScreen`'s tagline and "← Back" link used the plain `Text` component
121
- with a `text-muted-foreground` override that fought `Text`'s own base
122
- `dark:text-white` the override lost to the base class's `dark:` variant,
123
- so both rendered bright white instead of muted in dark mode. Switched both
124
- to `MutedText`, which carries the correct `dark:text-neutral-400`.
125
- - Known follow-up, not fixed here: the same bare-CSS-var-no-dark:-fallback
126
- pattern is present in roughly 15 more files in this package
127
- (`password-generator.tsx`, `pin-pad.tsx`, `tag-chip.tsx`, several other
128
- still-active auth screens, …) — scoped out of this release to unblock the
129
- reported bug; needs its own pass.
125
+ - Several components colored themselves with a bare CSS-variable utility (`bg-primary`,
126
+ `bg-background`, `border-border`, `bg-destructive`, …) and no `dark:` fallback — on native Android
127
+ that value stays stuck on its light setting regardless of actual system theme; only components
128
+ carrying an explicit `dark:`-prefixed class (the existing pattern in `Heading`/`Text`/
129
+ `MutedText`) reacted correctly. Added matching `dark:` variants one per CSS variable's own
130
+ `.dark` value in `styles/globals.css` to `Button`, `Input`, `Dialog`'s destructive variant,
131
+ `legal-dialog`'s body text, and `AuthScreen`'s root background. Surfaced as vault's "Get Started"
132
+ screen going render-broken in dark mode: invisible button, invisible heading/legal text, and a
133
+ background that stayed white.
134
+ - `AuthScreen`'s tagline and "← Back" link used the plain `Text` component with a
135
+ `text-muted-foreground` override that fought `Text`'s own base `dark:text-white` — the override
136
+ lost to the base class's `dark:` variant, so both rendered bright white instead of muted in dark
137
+ mode. Switched both to `MutedText`, which carries the correct `dark:text-neutral-400`.
138
+ - Known follow-up, not fixed here: the same bare-CSS-var-no-dark:-fallback pattern is present in
139
+ roughly 15 more files in this package (`password-generator.tsx`, `pin-pad.tsx`, `tag-chip.tsx`,
140
+ several other still-active auth screens, …) — scoped out of this release to unblock the reported
141
+ bug; needs its own pass.
130
142
 
131
143
  ## 2026.8.2
132
144
 
133
145
  ### Fixed
134
146
 
135
- - `registerTelemetry` and the rest of the telemetry registry were exported from
136
- the root barrel twice — once through `export * from "./services"` and once by
137
- name. Metro's CommonJS interop resolved the duplicate ambiguously and
138
- consumers got `undefined` at runtime despite the types checking. The root
139
- barrel now re-exports them only through `./services`.
147
+ - `registerTelemetry` and the rest of the telemetry registry were exported from the root barrel
148
+ twice — once through `export * from "./services"` and once by name. Metro's CommonJS interop
149
+ resolved the duplicate ambiguously and consumers got `undefined` at runtime despite the types
150
+ checking. The root barrel now re-exports them only through `./services`.
140
151
 
141
152
  ## 2026.8.1
142
153
 
143
154
  ### Added
144
155
 
145
- - Telemetry registry (`registerTelemetry`, `trackClick`, `withClickTracking`). The
146
- package reports interactions to a sink the host app supplies; it knows nothing
147
- about batching, authentication or transport. No sink registered means every
148
- entry point is a no-op, so consumers that do not opt in are unaffected.
149
- - `telemetryId` prop on `Button` and `IconButton`. Reports a stable,
150
- author-assigned id when the control is activated. Wired through `onPress`, so
151
- a disabled or loading button records nothing.
152
- - All 69 interactive controls across the 18 auth screens carry a `telemetryId`,
153
- making sign-in, sign-up, MFA and consent measurable by consuming apps without
154
- each of them re-tagging the packaged screens.
156
+ - Telemetry registry (`registerTelemetry`, `trackClick`, `withClickTracking`). The package reports
157
+ interactions to a sink the host app supplies; it knows nothing about batching, authentication or
158
+ transport. No sink registered means every entry point is a no-op, so consumers that do not opt in
159
+ are unaffected.
160
+ - `telemetryId` prop on `Button` and `IconButton`. Reports a stable, author-assigned id when the
161
+ control is activated. Wired through `onPress`, so a disabled or loading button records nothing.
162
+ - All 69 interactive controls across the 18 auth screens carry a `telemetryId`, making sign-in,
163
+ sign-up, MFA and consent measurable by consuming apps without each of them re-tagging the packaged
164
+ screens.
155
165
 
156
166
  ## [Unreleased]
157
167
 
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { type UploadResolver } from "../hooks/useUploadBlobUrl";
2
3
  export interface ItemIconProps {
3
4
  /**
4
5
  * Stored icon reference. Either a literal URL, a `custom:<uploadId>`
@@ -22,6 +23,21 @@ export interface ItemIconProps {
22
23
  faviconPath?: string;
23
24
  /** Upload-fetch path prefix. Defaults to `/uploads`. */
24
25
  uploadsPath?: string;
26
+ /**
27
+ * Resolve a `custom:<uploadId>` reference yourself instead of letting this
28
+ * component fetch it.
29
+ *
30
+ * For a consumer whose uploads are not behind `apiFetch` at all — vault's
31
+ * device-only vaults keep them in on-device SQLite, with no server to ask.
32
+ * Without this, such a consumer has to either fork the component or
33
+ * pre-resolve the reference into a `data:` URI and pass it as `iconUrl`,
34
+ * which works by accident (anything that is not `custom:` is treated as a
35
+ * plain URL) and hides the intent.
36
+ *
37
+ * Return `null` for an id that does not resolve; the fallback glyph is drawn,
38
+ * exactly as for a fetch that fails.
39
+ */
40
+ resolveUpload?: UploadResolver;
25
41
  }
26
42
  /**
27
43
  * Per-item icon: shows the custom upload, falls back to the favicon
@@ -1 +1 @@
1
- {"version":3,"file":"item-icon.d.ts","sourceRoot":"","sources":["../../src/components/item-icon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAOnD,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAQD;;;;GAIG;AACH,eAAO,MAAM,QAAQ,2CAmEnB,CAAC"}
1
+ {"version":3,"file":"item-icon.d.ts","sourceRoot":"","sources":["../../src/components/item-icon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAKnD,OAAO,EAAE,KAAK,cAAc,EAAoB,MAAM,2BAA2B,CAAC;AAElF,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;OAaG;IACH,aAAa,CAAC,EAAE,cAAc,CAAC;CAChC;AAQD;;;;GAIG;AACH,eAAO,MAAM,QAAQ,2CAoEnB,CAAC"}
@@ -13,12 +13,12 @@ const WEB_LAZY_PROPS = Platform.OS === "web" ? { loading: "lazy", decoding: "asy
13
13
  * proxy, then to a generic globe glyph on a tile-toned square. Shared
14
14
  * between RN clients (mobile) and RN-Web (extension popup).
15
15
  */
16
- export const ItemIcon = React.memo(function ItemIcon({ iconUrl, siteUrl, size = 40, imageSize, borderRadius, background, fallbackColor, faviconPath = "/favicon", uploadsPath = "/uploads", }) {
16
+ export const ItemIcon = React.memo(function ItemIcon({ iconUrl, siteUrl, size = 40, imageSize, borderRadius, background, fallbackColor, faviconPath = "/favicon", uploadsPath = "/uploads", resolveUpload, }) {
17
17
  const { colorScheme } = useColorScheme();
18
18
  const isDark = colorScheme === "dark";
19
19
  const resolved = resolveIcon({ iconUrl, siteUrl, faviconPath });
20
20
  const uploadId = resolved.kind === "upload" ? resolved.uploadId : null;
21
- const upload = useUploadBlobUrl(uploadId, uploadsPath);
21
+ const upload = useUploadBlobUrl(uploadId, { uploadsPath, resolve: resolveUpload });
22
22
  const directUri = resolved.kind === "url" ? resolved.uri : null;
23
23
  const [urlErrored, setUrlErrored] = useState(false);
24
24
  useEffect(() => {
@@ -1 +1 @@
1
- {"version":3,"file":"item-icon.jsx","sourceRoot":"","sources":["../../src/components/item-icon.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"item-icon.jsx","sourceRoot":"","sources":["../../src/components/item-icon.tsx"],"names":[],"mappings":"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"}
@@ -2,6 +2,6 @@ export { AuthProvider, useAuth, useRole, useRequireAuth, useSession, type AuthPr
2
2
  export { useShakeAnimation } from "./useShakeAnimation";
3
3
  export { useDebouncedValue } from "./useDebouncedValue";
4
4
  export { useAutoLock, type UseAutoLockOptions } from "./useAutoLock";
5
- export { useUploadBlobUrl, type UploadBlobUrlResult } from "./useUploadBlobUrl";
5
+ export { type UploadBlobUrlOptions, type UploadBlobUrlResult, type UploadResolver, useUploadBlobUrl, } from "./useUploadBlobUrl";
6
6
  export { usePasswordGenerator, getPasswordStrength, type UsePasswordGeneratorResult, type PasswordStrength, type BreachState, } from "./usePasswordGenerator";
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,OAAO,EACP,OAAO,EACP,cAAc,EACd,UAAU,EACV,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,uBAAuB,GAC7B,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAErE,OAAO,EAAE,gBAAgB,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAEhF,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,gBAAgB,EACrB,KAAK,WAAW,GACjB,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,OAAO,EACP,OAAO,EACP,cAAc,EACd,UAAU,EACV,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,uBAAuB,GAC7B,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAErE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,0BAA0B,EAC/B,KAAK,gBAAgB,EACrB,KAAK,WAAW,GACjB,MAAM,wBAAwB,CAAC"}
@@ -2,6 +2,6 @@ export { AuthProvider, useAuth, useRole, useRequireAuth, useSession, } from "./u
2
2
  export { useShakeAnimation } from "./useShakeAnimation";
3
3
  export { useDebouncedValue } from "./useDebouncedValue";
4
4
  export { useAutoLock } from "./useAutoLock";
5
- export { useUploadBlobUrl } from "./useUploadBlobUrl";
5
+ export { useUploadBlobUrl, } from "./useUploadBlobUrl";
6
6
  export { usePasswordGenerator, getPasswordStrength, } from "./usePasswordGenerator";
7
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,OAAO,EACP,OAAO,EACP,cAAc,EACd,UAAU,GAKX,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,WAAW,EAA2B,MAAM,eAAe,CAAC;AAErE,OAAO,EAAE,gBAAgB,EAA4B,MAAM,oBAAoB,CAAC;AAEhF,OAAO,EACL,oBAAoB,EACpB,mBAAmB,GAIpB,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,OAAO,EACP,OAAO,EACP,cAAc,EACd,UAAU,GAKX,MAAM,WAAW,CAAC;AAEnB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,OAAO,EAAE,WAAW,EAA2B,MAAM,eAAe,CAAC;AAErE,OAAO,EAIL,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,oBAAoB,EACpB,mBAAmB,GAIpB,MAAM,wBAAwB,CAAC"}
@@ -4,10 +4,54 @@ export interface UploadBlobUrlResult {
4
4
  setErrored: (v: boolean) => void;
5
5
  }
6
6
  /**
7
- * Fetch an authenticated upload via `apiFetch`, expose a blob URL the
8
- * caller can hand to an `<Image>` / `<img>`. Cancels in-flight on input
9
- * change and revokes the URL on unmount so list scrolling doesn't leak
10
- * memory.
7
+ * Produce a renderable URI for an upload id without going through
8
+ * `apiFetch`.
9
+ *
10
+ * For a consumer that keeps uploads somewhere this package cannot reach — an
11
+ * on-device database, a cache it populated itself — the resolver is the whole
12
+ * seam. Return `null` for an id that does not resolve; the hook reports that
13
+ * as `errored`, which is what a renderer draws its fallback on.
14
+ *
15
+ * The `signal` aborts with the effect, so a resolver doing real work can stop
16
+ * when the row scrolls away.
11
17
  */
12
- export declare function useUploadBlobUrl(uploadId: string | null | undefined, uploadsPath?: string): UploadBlobUrlResult;
18
+ export type UploadResolver = (uploadId: string, signal: AbortSignal) => Promise<string | null>;
19
+ export interface UploadBlobUrlOptions {
20
+ /** Upload-fetch path prefix. Defaults to `/uploads`. */
21
+ uploadsPath?: string;
22
+ /**
23
+ * Resolve the URI yourself instead of fetching it.
24
+ *
25
+ * Identity does not have to be stable: the resolver is held in a ref and
26
+ * only `uploadId` / `uploadsPath` re-run the effect. An inline arrow would
27
+ * otherwise refetch on every render, and what identifies the resource is the
28
+ * id, not the function that happens to fetch it.
29
+ */
30
+ resolve?: UploadResolver;
31
+ }
32
+ /**
33
+ * A URI for an upload, fetched through `apiFetch` unless the consumer resolves
34
+ * it itself.
35
+ *
36
+ * # Web gets a blob URL; native gets a data URI
37
+ *
38
+ * `URL.createObjectURL` is a browser API. React Native's `URL` does not
39
+ * implement it in any way that can be relied on across both platforms — so on
40
+ * native this hook used to hand `<Image>` a URI built by a function that
41
+ * either threw or produced something the image loader could not open, and the
42
+ * fetch fell into the `catch` below. The visible symptom was that custom
43
+ * uploaded icons never appeared on a phone: they resolved to the fallback
44
+ * glyph every time, on every consumer, and looked like a missing-image problem
45
+ * rather than a URL one.
46
+ *
47
+ * Native therefore reads the bytes and builds `data:<type>;base64,…`, which
48
+ * every RN image loader accepts. The cost is that base64 inflates by a third
49
+ * and the string sits in the JS heap for as long as the row is mounted, which
50
+ * is affordable for the icons this exists to draw (the vault caps an upload at
51
+ * 256KB) and is why web keeps the blob URL — no copy, and revocable.
52
+ *
53
+ * The second argument accepts a bare path for backwards compatibility with
54
+ * `useUploadBlobUrl(id, "/uploads")`; new call sites should pass options.
55
+ */
56
+ export declare function useUploadBlobUrl(uploadId: string | null | undefined, options?: string | UploadBlobUrlOptions): UploadBlobUrlResult;
13
57
  //# sourceMappingURL=useUploadBlobUrl.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useUploadBlobUrl.d.ts","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,WAAW,SAAa,GACvB,mBAAmB,CAsDrB"}
1
+ {"version":3,"file":"useUploadBlobUrl.d.ts","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAE/F,MAAM,WAAW,oBAAoB;IACnC,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,GAAE,MAAM,GAAG,oBAAyB,GAC1C,mBAAmB,CAgGrB"}
@@ -1,15 +1,39 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Platform } from "react-native";
2
3
  import { apiFetch } from "../utils/api-fetch";
3
4
  const FETCH_TIMEOUT_MS = 15_000;
4
5
  /**
5
- * Fetch an authenticated upload via `apiFetch`, expose a blob URL the
6
- * caller can hand to an `<Image>` / `<img>`. Cancels in-flight on input
7
- * change and revokes the URL on unmount so list scrolling doesn't leak
8
- * memory.
6
+ * A URI for an upload, fetched through `apiFetch` unless the consumer resolves
7
+ * it itself.
8
+ *
9
+ * # Web gets a blob URL; native gets a data URI
10
+ *
11
+ * `URL.createObjectURL` is a browser API. React Native's `URL` does not
12
+ * implement it in any way that can be relied on across both platforms — so on
13
+ * native this hook used to hand `<Image>` a URI built by a function that
14
+ * either threw or produced something the image loader could not open, and the
15
+ * fetch fell into the `catch` below. The visible symptom was that custom
16
+ * uploaded icons never appeared on a phone: they resolved to the fallback
17
+ * glyph every time, on every consumer, and looked like a missing-image problem
18
+ * rather than a URL one.
19
+ *
20
+ * Native therefore reads the bytes and builds `data:<type>;base64,…`, which
21
+ * every RN image loader accepts. The cost is that base64 inflates by a third
22
+ * and the string sits in the JS heap for as long as the row is mounted, which
23
+ * is affordable for the icons this exists to draw (the vault caps an upload at
24
+ * 256KB) and is why web keeps the blob URL — no copy, and revocable.
25
+ *
26
+ * The second argument accepts a bare path for backwards compatibility with
27
+ * `useUploadBlobUrl(id, "/uploads")`; new call sites should pass options.
9
28
  */
10
- export function useUploadBlobUrl(uploadId, uploadsPath = "/uploads") {
29
+ export function useUploadBlobUrl(uploadId, options = {}) {
30
+ const { uploadsPath = "/uploads", resolve } = typeof options === "string" ? { uploadsPath: options, resolve: undefined } : options;
11
31
  const [uri, setUri] = useState(null);
12
32
  const [errored, setErrored] = useState(false);
33
+ // See `UploadBlobUrlOptions.resolve`: kept in a ref so a resolver defined
34
+ // inline at the call site cannot turn every render into a refetch.
35
+ const resolveRef = useRef(resolve);
36
+ resolveRef.current = resolve;
13
37
  useEffect(() => {
14
38
  setErrored(false);
15
39
  if (!uploadId) {
@@ -19,21 +43,51 @@ export function useUploadBlobUrl(uploadId, uploadsPath = "/uploads") {
19
43
  const controller = new AbortController();
20
44
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
21
45
  let createdUrl = null;
22
- apiFetch(`${uploadsPath}/${encodeURIComponent(uploadId)}`, { signal: controller.signal })
23
- .then(async (response) => {
46
+ const load = async () => {
47
+ const resolver = resolveRef.current;
48
+ if (resolver) {
49
+ return resolver(uploadId, controller.signal);
50
+ }
51
+ const response = await apiFetch(`${uploadsPath}/${encodeURIComponent(uploadId)}`, {
52
+ signal: controller.signal,
53
+ });
24
54
  if (controller.signal.aborted) {
25
- return;
55
+ return null;
26
56
  }
27
57
  if (!response.ok) {
28
58
  throw new Error(`Failed to load upload (${response.status})`);
29
59
  }
30
- const blob = await response.blob();
60
+ if (Platform.OS === "web") {
61
+ const blob = await response.blob();
62
+ if (controller.signal.aborted) {
63
+ return null;
64
+ }
65
+ createdUrl = URL.createObjectURL(blob);
66
+ return createdUrl;
67
+ }
68
+ const bytes = new Uint8Array(await response.arrayBuffer());
69
+ if (controller.signal.aborted) {
70
+ return null;
71
+ }
72
+ // The server sends the stored content type; anything else is a server
73
+ // that has lost track of what it is holding, and `<Image>` will refuse
74
+ // the URI rather than guess — which surfaces as the fallback glyph, the
75
+ // same outcome a failed fetch gives.
76
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
77
+ return `data:${contentType};base64,${toBase64(bytes)}`;
78
+ };
79
+ load()
80
+ .then((next) => {
31
81
  if (controller.signal.aborted) {
32
82
  return;
33
83
  }
34
- const url = URL.createObjectURL(blob);
35
- createdUrl = url;
36
- setUri(url);
84
+ if (next === null) {
85
+ // A resolver that has nothing for this id, or an aborted read. Either
86
+ // way there is no image, which is what `errored` means to a renderer.
87
+ setErrored(true);
88
+ return;
89
+ }
90
+ setUri(next);
37
91
  })
38
92
  .catch((e) => {
39
93
  if (controller.signal.aborted) {
@@ -57,4 +111,29 @@ export function useUploadBlobUrl(uploadId, uploadsPath = "/uploads") {
57
111
  }, [uploadId, uploadsPath]);
58
112
  return { uri, errored, setErrored };
59
113
  }
114
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
115
+ /**
116
+ * Base64, without depending on the host having `btoa`.
117
+ *
118
+ * React Native ships no `btoa`, and whether one exists depends entirely on
119
+ * which polyfill the consuming app happened to install — a library cannot
120
+ * assume it. Encoding by hand is a dozen lines and removes the question.
121
+ *
122
+ * The `?? 0` on the tail bytes is the padding rule rather than a guard: base64
123
+ * completes a short final group with zero bits and then marks the missing
124
+ * bytes with `=`. Reading past the end is therefore correct here, and doing it
125
+ * this way keeps the loop free of the non-null assertions
126
+ * `noUncheckedIndexedAccess` would otherwise demand at every lookup.
127
+ */
128
+ function toBase64(bytes) {
129
+ const chars = [];
130
+ for (let index = 0; index < bytes.length; index += 3) {
131
+ const b0 = bytes[index] ?? 0;
132
+ const b1 = bytes[index + 1] ?? 0;
133
+ const b2 = bytes[index + 2] ?? 0;
134
+ const remaining = bytes.length - index;
135
+ chars.push(BASE64_ALPHABET.charAt(b0 >> 2), BASE64_ALPHABET.charAt(((b0 & 3) << 4) | (b1 >> 4)), remaining > 1 ? BASE64_ALPHABET.charAt(((b1 & 15) << 2) | (b2 >> 6)) : "=", remaining > 2 ? BASE64_ALPHABET.charAt(b2 & 63) : "=");
136
+ }
137
+ return chars.join("");
138
+ }
60
139
  //# sourceMappingURL=useUploadBlobUrl.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useUploadBlobUrl.js","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAE9C,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAQhC;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAmC,EACnC,WAAW,GAAG,UAAU;IAExB,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IACpD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAE9C,SAAS,CAAC,GAAG,EAAE;QACb,UAAU,CAAC,KAAK,CAAC,CAAC;QAClB,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,CAAC,IAAI,CAAC,CAAC;YACb,OAAO;QACT,CAAC;QAED,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,gBAAgB,CAAC,CAAC;QACvE,IAAI,UAAU,GAAkB,IAAI,CAAC;QAErC,QAAQ,CAAC,GAAG,WAAW,IAAI,kBAAkB,CAAC,QAAQ,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC;aACtF,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE;YACvB,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBAC9B,OAAO;YACT,CAAC;YACD,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC;YAChE,CAAC;YACD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;YACnC,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBAC9B,OAAO;YACT,CAAC;YACD,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;YACtC,UAAU,GAAG,GAAG,CAAC;YACjB,MAAM,CAAC,GAAG,CAAC,CAAC;QACd,CAAC,CAAC;aACD,KAAK,CAAC,CAAC,CAAU,EAAE,EAAE;YACpB,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;gBAC9B,OAAO;YACT,CAAC;YACD,IAAI,CAAC,YAAY,YAAY,IAAI,CAAC,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;gBACzD,OAAO;YACT,CAAC;YACD,UAAU,CAAC,IAAI,CAAC,CAAC;QACnB,CAAC,CAAC;aACD,OAAO,CAAC,GAAG,EAAE;YACZ,YAAY,CAAC,OAAO,CAAC,CAAC;QACxB,CAAC,CAAC,CAAC;QAEL,OAAO,GAAG,EAAE;YACV,UAAU,CAAC,KAAK,EAAE,CAAC;YACnB,YAAY,CAAC,OAAO,CAAC,CAAC;YACtB,IAAI,UAAU,EAAE,CAAC;gBACf,GAAG,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC;YAClC,CAAC;QACH,CAAC,CAAC;IACJ,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC;IAE5B,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC;AACtC,CAAC"}
1
+ {"version":3,"file":"useUploadBlobUrl.js","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entropy-softworks/ui",
3
- "version": "2026.8.41",
3
+ "version": "2026.8.42",
4
4
  "private": false,
5
5
  "description": "Shared React Native UI primitives, auth screens, hooks, and services for Entropy Softworks Expo apps",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@ import { Image, Platform, View } from "react-native";
3
3
  import { Ionicons } from "@expo/vector-icons";
4
4
  import { useColorScheme } from "nativewind";
5
5
  import { resolveIcon } from "../services/favicon";
6
- import { useUploadBlobUrl } from "../hooks/useUploadBlobUrl";
6
+ import { type UploadResolver, useUploadBlobUrl } from "../hooks/useUploadBlobUrl";
7
7
 
8
8
  export interface ItemIconProps {
9
9
  /**
@@ -28,6 +28,21 @@ export interface ItemIconProps {
28
28
  faviconPath?: string;
29
29
  /** Upload-fetch path prefix. Defaults to `/uploads`. */
30
30
  uploadsPath?: string;
31
+ /**
32
+ * Resolve a `custom:<uploadId>` reference yourself instead of letting this
33
+ * component fetch it.
34
+ *
35
+ * For a consumer whose uploads are not behind `apiFetch` at all — vault's
36
+ * device-only vaults keep them in on-device SQLite, with no server to ask.
37
+ * Without this, such a consumer has to either fork the component or
38
+ * pre-resolve the reference into a `data:` URI and pass it as `iconUrl`,
39
+ * which works by accident (anything that is not `custom:` is treated as a
40
+ * plain URL) and hides the intent.
41
+ *
42
+ * Return `null` for an id that does not resolve; the fallback glyph is drawn,
43
+ * exactly as for a fetch that fails.
44
+ */
45
+ resolveUpload?: UploadResolver;
31
46
  }
32
47
 
33
48
  // react-native-web forwards `loading` and `decoding` to the underlying
@@ -51,13 +66,14 @@ export const ItemIcon = React.memo(function ItemIcon({
51
66
  fallbackColor,
52
67
  faviconPath = "/favicon",
53
68
  uploadsPath = "/uploads",
69
+ resolveUpload,
54
70
  }: ItemIconProps) {
55
71
  const { colorScheme } = useColorScheme();
56
72
  const isDark = colorScheme === "dark";
57
73
 
58
74
  const resolved = resolveIcon({ iconUrl, siteUrl, faviconPath });
59
75
  const uploadId = resolved.kind === "upload" ? resolved.uploadId : null;
60
- const upload = useUploadBlobUrl(uploadId, uploadsPath);
76
+ const upload = useUploadBlobUrl(uploadId, { uploadsPath, resolve: resolveUpload });
61
77
 
62
78
  const directUri = resolved.kind === "url" ? resolved.uri : null;
63
79
  const [urlErrored, setUrlErrored] = useState(false);
@@ -1,27 +1,44 @@
1
1
  /**
2
- * Behavioural tests for `useUploadBlobUrl`. The hook is exercised
3
- * through `renderHook` from `@testing-library/react-native`, with the
4
- * package's `apiFetch` mocked so we can drive successful and failing
5
- * upload fetches deterministically.
2
+ * Behavioural tests for `useUploadBlobUrl`.
3
+ *
4
+ * The platform branch is the point of most of these: `URL.createObjectURL` is
5
+ * a browser API that React Native does not implement usably, so web gets a
6
+ * blob URL and native gets a data URI. `react-native` is mocked down to a
7
+ * mutable `Platform` so one file can drive both.
6
8
  */
7
9
  import { act, renderHook, waitFor } from "@testing-library/react-native";
10
+ import { Platform } from "react-native";
8
11
 
9
12
  import { apiFetch } from "../../utils/api-fetch";
10
13
  import { useUploadBlobUrl } from "../useUploadBlobUrl";
11
14
 
15
+ // Only `Platform` is reached from the hook. A full react-native mock would be
16
+ // a much bigger surface to keep true than this needs.
17
+ jest.mock("react-native", () => ({ Platform: { OS: "ios" } }));
18
+
12
19
  jest.mock("../../utils/api-fetch", () => ({
13
20
  apiFetch: jest.fn(),
14
21
  }));
15
22
 
16
23
  const apiFetchMock = apiFetch as jest.MockedFunction<typeof apiFetch>;
24
+ const mutablePlatform = Platform as { OS: string };
17
25
 
18
26
  const originalCreateObjectURL = globalThis.URL.createObjectURL;
19
27
  const originalRevokeObjectURL = globalThis.URL.revokeObjectURL;
20
28
  let urlCounter = 0;
21
29
  const revokedUrls: string[] = [];
22
30
 
31
+ /** A PNG-flavoured response, headers included — the data URI carries its type. */
32
+ function imageResponse(bytes: number[], contentType = "image/png"): Response {
33
+ return new Response(Uint8Array.from(bytes), {
34
+ status: 200,
35
+ headers: { "Content-Type": contentType },
36
+ });
37
+ }
38
+
23
39
  beforeEach(() => {
24
40
  apiFetchMock.mockReset();
41
+ mutablePlatform.OS = "ios";
25
42
  urlCounter = 0;
26
43
  revokedUrls.length = 0;
27
44
  globalThis.URL.createObjectURL = jest.fn(() => `blob:test/${++urlCounter}`);
@@ -41,29 +58,46 @@ describe("useUploadBlobUrl", () => {
41
58
  expect(result.current.errored).toBe(false);
42
59
  });
43
60
 
44
- it("resolves a blob URL on a successful fetch", async () => {
45
- apiFetchMock.mockResolvedValueOnce(new Response(new Blob(["ok"]), { status: 200 }));
61
+ it("builds a data URI on native, carrying the response's content type", async () => {
62
+ // "ok" short enough to check the base64 by eye.
63
+ apiFetchMock.mockResolvedValueOnce(imageResponse([0x6f, 0x6b], "image/webp"));
46
64
  const { result } = renderHook(() => useUploadBlobUrl("abc"));
47
65
  await waitFor(() => {
48
- expect(result.current.uri).toMatch(/^blob:test\//);
66
+ expect(result.current.uri).toBe("data:image/webp;base64,b2s=");
49
67
  });
50
68
  expect(result.current.errored).toBe(false);
69
+ // Never `createObjectURL` on native: that is the whole bug this replaced.
70
+ expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled();
51
71
  });
52
72
 
53
- it("marks errored when the fetch fails", async () => {
54
- apiFetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
55
- const { result } = renderHook(() => useUploadBlobUrl("abc"));
56
- await waitFor(() => {
57
- expect(result.current.errored).toBe(true);
58
- });
59
- expect(result.current.uri).toBeNull();
73
+ it("encodes every remainder length correctly", async () => {
74
+ // Base64 pads by input length mod 3, and the two padded cases take
75
+ // different branches. Byte values chosen so a bit-shift slip shows up as a
76
+ // wrong character rather than a coincidentally-right one.
77
+ const cases: [number[], string][] = [
78
+ [[0xff], "/w=="],
79
+ [[0xff, 0xee], "/+4="],
80
+ [[0xff, 0xee, 0xdd], "/+7d"],
81
+ [[0x00, 0x00, 0x00, 0x01], "AAAAAQ=="],
82
+ ];
83
+ for (const [bytes, expected] of cases) {
84
+ apiFetchMock.mockResolvedValueOnce(imageResponse(bytes));
85
+ const { result, unmount } = renderHook(() => useUploadBlobUrl(`id-${expected}`));
86
+ await waitFor(() => {
87
+ expect(result.current.uri).toBe(`data:image/png;base64,${expected}`);
88
+ });
89
+ act(() => {
90
+ unmount();
91
+ });
92
+ }
60
93
  });
61
94
 
62
- it("revokes the blob URL on unmount", async () => {
95
+ it("keeps the blob URL on web, and revokes it on unmount", async () => {
96
+ mutablePlatform.OS = "web";
63
97
  apiFetchMock.mockResolvedValueOnce(new Response(new Blob(["ok"]), { status: 200 }));
64
98
  const { result, unmount } = renderHook(() => useUploadBlobUrl("abc"));
65
99
  await waitFor(() => {
66
- expect(result.current.uri).not.toBeNull();
100
+ expect(result.current.uri).toMatch(/^blob:test\//);
67
101
  });
68
102
  const uri = result.current.uri;
69
103
  if (uri === null) {
@@ -72,6 +106,84 @@ describe("useUploadBlobUrl", () => {
72
106
  act(() => {
73
107
  unmount();
74
108
  });
109
+ // The revoke matters on web and only on web — a data URI is a string with
110
+ // nothing to release.
75
111
  expect(revokedUrls).toContain(uri);
76
112
  });
113
+
114
+ it("marks errored when the fetch fails", async () => {
115
+ apiFetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 }));
116
+ const { result } = renderHook(() => useUploadBlobUrl("abc"));
117
+ await waitFor(() => {
118
+ expect(result.current.errored).toBe(true);
119
+ });
120
+ expect(result.current.uri).toBeNull();
121
+ });
122
+
123
+ describe("with a consumer-supplied resolver", () => {
124
+ it("uses it instead of the transport", async () => {
125
+ const resolve = jest.fn().mockResolvedValue("data:image/png;base64,AAAA");
126
+ const { result } = renderHook(() => useUploadBlobUrl("abc", { resolve }));
127
+
128
+ await waitFor(() => {
129
+ expect(result.current.uri).toBe("data:image/png;base64,AAAA");
130
+ });
131
+ expect(resolve).toHaveBeenCalledWith("abc", expect.any(AbortSignal));
132
+ // The reason the seam exists: a consumer whose uploads are not behind a
133
+ // server must not have one asked.
134
+ expect(apiFetchMock).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it("reports an unresolvable id as errored so the fallback is drawn", async () => {
138
+ const resolve = jest.fn().mockResolvedValue(null);
139
+ const { result } = renderHook(() => useUploadBlobUrl("gone", { resolve }));
140
+ await waitFor(() => {
141
+ expect(result.current.errored).toBe(true);
142
+ });
143
+ expect(result.current.uri).toBeNull();
144
+ });
145
+
146
+ it("reports a throwing resolver as errored rather than crashing the tree", async () => {
147
+ const resolve = jest.fn().mockRejectedValue(new Error("database is closed"));
148
+ const { result } = renderHook(() => useUploadBlobUrl("abc", { resolve }));
149
+ await waitFor(() => {
150
+ expect(result.current.errored).toBe(true);
151
+ });
152
+ });
153
+
154
+ it("does not re-resolve when only the resolver's identity changes", async () => {
155
+ // The hazard this guards: an inline arrow at the call site is a new
156
+ // function every render, and a resolver in the effect's deps would turn
157
+ // that into an unbounded refetch loop. What identifies the resource is
158
+ // the id.
159
+ const first = jest.fn().mockResolvedValue("data:image/png;base64,AAAA");
160
+ const { result, rerender } = renderHook(
161
+ ({ resolve }: { resolve: jest.Mock }) => useUploadBlobUrl("abc", { resolve }),
162
+ { initialProps: { resolve: first } }
163
+ );
164
+ await waitFor(() => {
165
+ expect(result.current.uri).not.toBeNull();
166
+ });
167
+
168
+ const second = jest.fn().mockResolvedValue("data:image/png;base64,BBBB");
169
+ act(() => {
170
+ rerender({ resolve: second });
171
+ rerender({ resolve: jest.fn() });
172
+ });
173
+
174
+ expect(first).toHaveBeenCalledTimes(1);
175
+ expect(second).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("still accepts a bare path as the second argument", async () => {
179
+ // The published signature was `(uploadId, uploadsPath?)`; other
180
+ // consumers are on it.
181
+ apiFetchMock.mockResolvedValueOnce(imageResponse([0x6f, 0x6b]));
182
+ const { result } = renderHook(() => useUploadBlobUrl("abc", "/attachments"));
183
+ await waitFor(() => {
184
+ expect(result.current.uri).not.toBeNull();
185
+ });
186
+ expect(apiFetchMock).toHaveBeenCalledWith("/attachments/abc", expect.anything());
187
+ });
188
+ });
77
189
  });
@@ -16,7 +16,12 @@ export { useDebouncedValue } from "./useDebouncedValue";
16
16
 
17
17
  export { useAutoLock, type UseAutoLockOptions } from "./useAutoLock";
18
18
 
19
- export { useUploadBlobUrl, type UploadBlobUrlResult } from "./useUploadBlobUrl";
19
+ export {
20
+ type UploadBlobUrlOptions,
21
+ type UploadBlobUrlResult,
22
+ type UploadResolver,
23
+ useUploadBlobUrl,
24
+ } from "./useUploadBlobUrl";
20
25
 
21
26
  export {
22
27
  usePasswordGenerator,
@@ -1,4 +1,5 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Platform } from "react-native";
2
3
  import { apiFetch } from "../utils/api-fetch";
3
4
 
4
5
  const FETCH_TIMEOUT_MS = 15_000;
@@ -10,18 +11,72 @@ export interface UploadBlobUrlResult {
10
11
  }
11
12
 
12
13
  /**
13
- * Fetch an authenticated upload via `apiFetch`, expose a blob URL the
14
- * caller can hand to an `<Image>` / `<img>`. Cancels in-flight on input
15
- * change and revokes the URL on unmount so list scrolling doesn't leak
16
- * memory.
14
+ * Produce a renderable URI for an upload id without going through
15
+ * `apiFetch`.
16
+ *
17
+ * For a consumer that keeps uploads somewhere this package cannot reach — an
18
+ * on-device database, a cache it populated itself — the resolver is the whole
19
+ * seam. Return `null` for an id that does not resolve; the hook reports that
20
+ * as `errored`, which is what a renderer draws its fallback on.
21
+ *
22
+ * The `signal` aborts with the effect, so a resolver doing real work can stop
23
+ * when the row scrolls away.
24
+ */
25
+ export type UploadResolver = (uploadId: string, signal: AbortSignal) => Promise<string | null>;
26
+
27
+ export interface UploadBlobUrlOptions {
28
+ /** Upload-fetch path prefix. Defaults to `/uploads`. */
29
+ uploadsPath?: string;
30
+ /**
31
+ * Resolve the URI yourself instead of fetching it.
32
+ *
33
+ * Identity does not have to be stable: the resolver is held in a ref and
34
+ * only `uploadId` / `uploadsPath` re-run the effect. An inline arrow would
35
+ * otherwise refetch on every render, and what identifies the resource is the
36
+ * id, not the function that happens to fetch it.
37
+ */
38
+ resolve?: UploadResolver;
39
+ }
40
+
41
+ /**
42
+ * A URI for an upload, fetched through `apiFetch` unless the consumer resolves
43
+ * it itself.
44
+ *
45
+ * # Web gets a blob URL; native gets a data URI
46
+ *
47
+ * `URL.createObjectURL` is a browser API. React Native's `URL` does not
48
+ * implement it in any way that can be relied on across both platforms — so on
49
+ * native this hook used to hand `<Image>` a URI built by a function that
50
+ * either threw or produced something the image loader could not open, and the
51
+ * fetch fell into the `catch` below. The visible symptom was that custom
52
+ * uploaded icons never appeared on a phone: they resolved to the fallback
53
+ * glyph every time, on every consumer, and looked like a missing-image problem
54
+ * rather than a URL one.
55
+ *
56
+ * Native therefore reads the bytes and builds `data:<type>;base64,…`, which
57
+ * every RN image loader accepts. The cost is that base64 inflates by a third
58
+ * and the string sits in the JS heap for as long as the row is mounted, which
59
+ * is affordable for the icons this exists to draw (the vault caps an upload at
60
+ * 256KB) and is why web keeps the blob URL — no copy, and revocable.
61
+ *
62
+ * The second argument accepts a bare path for backwards compatibility with
63
+ * `useUploadBlobUrl(id, "/uploads")`; new call sites should pass options.
17
64
  */
18
65
  export function useUploadBlobUrl(
19
66
  uploadId: string | null | undefined,
20
- uploadsPath = "/uploads"
67
+ options: string | UploadBlobUrlOptions = {}
21
68
  ): UploadBlobUrlResult {
69
+ const { uploadsPath = "/uploads", resolve } =
70
+ typeof options === "string" ? { uploadsPath: options, resolve: undefined } : options;
71
+
22
72
  const [uri, setUri] = useState<string | null>(null);
23
73
  const [errored, setErrored] = useState(false);
24
74
 
75
+ // See `UploadBlobUrlOptions.resolve`: kept in a ref so a resolver defined
76
+ // inline at the call site cannot turn every render into a refetch.
77
+ const resolveRef = useRef<UploadResolver | undefined>(resolve);
78
+ resolveRef.current = resolve;
79
+
25
80
  useEffect(() => {
26
81
  setErrored(false);
27
82
  if (!uploadId) {
@@ -33,21 +88,55 @@ export function useUploadBlobUrl(
33
88
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
34
89
  let createdUrl: string | null = null;
35
90
 
36
- apiFetch(`${uploadsPath}/${encodeURIComponent(uploadId)}`, { signal: controller.signal })
37
- .then(async (response) => {
91
+ const load = async (): Promise<string | null> => {
92
+ const resolver = resolveRef.current;
93
+ if (resolver) {
94
+ return resolver(uploadId, controller.signal);
95
+ }
96
+
97
+ const response = await apiFetch(`${uploadsPath}/${encodeURIComponent(uploadId)}`, {
98
+ signal: controller.signal,
99
+ });
100
+ if (controller.signal.aborted) {
101
+ return null;
102
+ }
103
+ if (!response.ok) {
104
+ throw new Error(`Failed to load upload (${response.status})`);
105
+ }
106
+
107
+ if (Platform.OS === "web") {
108
+ const blob = await response.blob();
38
109
  if (controller.signal.aborted) {
39
- return;
40
- }
41
- if (!response.ok) {
42
- throw new Error(`Failed to load upload (${response.status})`);
110
+ return null;
43
111
  }
44
- const blob = await response.blob();
112
+ createdUrl = URL.createObjectURL(blob);
113
+ return createdUrl;
114
+ }
115
+
116
+ const bytes = new Uint8Array(await response.arrayBuffer());
117
+ if (controller.signal.aborted) {
118
+ return null;
119
+ }
120
+ // The server sends the stored content type; anything else is a server
121
+ // that has lost track of what it is holding, and `<Image>` will refuse
122
+ // the URI rather than guess — which surfaces as the fallback glyph, the
123
+ // same outcome a failed fetch gives.
124
+ const contentType = response.headers.get("content-type") ?? "application/octet-stream";
125
+ return `data:${contentType};base64,${toBase64(bytes)}`;
126
+ };
127
+
128
+ load()
129
+ .then((next) => {
45
130
  if (controller.signal.aborted) {
46
131
  return;
47
132
  }
48
- const url = URL.createObjectURL(blob);
49
- createdUrl = url;
50
- setUri(url);
133
+ if (next === null) {
134
+ // A resolver that has nothing for this id, or an aborted read. Either
135
+ // way there is no image, which is what `errored` means to a renderer.
136
+ setErrored(true);
137
+ return;
138
+ }
139
+ setUri(next);
51
140
  })
52
141
  .catch((e: unknown) => {
53
142
  if (controller.signal.aborted) {
@@ -73,3 +162,38 @@ export function useUploadBlobUrl(
73
162
 
74
163
  return { uri, errored, setErrored };
75
164
  }
165
+
166
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
167
+
168
+ /**
169
+ * Base64, without depending on the host having `btoa`.
170
+ *
171
+ * React Native ships no `btoa`, and whether one exists depends entirely on
172
+ * which polyfill the consuming app happened to install — a library cannot
173
+ * assume it. Encoding by hand is a dozen lines and removes the question.
174
+ *
175
+ * The `?? 0` on the tail bytes is the padding rule rather than a guard: base64
176
+ * completes a short final group with zero bits and then marks the missing
177
+ * bytes with `=`. Reading past the end is therefore correct here, and doing it
178
+ * this way keeps the loop free of the non-null assertions
179
+ * `noUncheckedIndexedAccess` would otherwise demand at every lookup.
180
+ */
181
+ function toBase64(bytes: Uint8Array): string {
182
+ const chars: string[] = [];
183
+
184
+ for (let index = 0; index < bytes.length; index += 3) {
185
+ const b0 = bytes[index] ?? 0;
186
+ const b1 = bytes[index + 1] ?? 0;
187
+ const b2 = bytes[index + 2] ?? 0;
188
+ const remaining = bytes.length - index;
189
+
190
+ chars.push(
191
+ BASE64_ALPHABET.charAt(b0 >> 2),
192
+ BASE64_ALPHABET.charAt(((b0 & 3) << 4) | (b1 >> 4)),
193
+ remaining > 1 ? BASE64_ALPHABET.charAt(((b1 & 15) << 2) | (b2 >> 6)) : "=",
194
+ remaining > 2 ? BASE64_ALPHABET.charAt(b2 & 63) : "="
195
+ );
196
+ }
197
+
198
+ return chars.join("");
199
+ }