@entropy-softworks/ui 2026.8.41 → 2026.9.1
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 +116 -106
- package/lib/components/item-icon.d.ts +16 -0
- package/lib/components/item-icon.d.ts.map +1 -1
- package/lib/components/item-icon.jsx +2 -2
- package/lib/components/item-icon.jsx.map +1 -1
- package/lib/components/pin-pad.d.ts +22 -1
- package/lib/components/pin-pad.d.ts.map +1 -1
- package/lib/components/pin-pad.jsx +73 -20
- package/lib/components/pin-pad.jsx.map +1 -1
- package/lib/hooks/index.d.ts +1 -1
- package/lib/hooks/index.d.ts.map +1 -1
- package/lib/hooks/index.js +1 -1
- package/lib/hooks/index.js.map +1 -1
- package/lib/hooks/useUploadBlobUrl.d.ts +49 -5
- package/lib/hooks/useUploadBlobUrl.d.ts.map +1 -1
- package/lib/hooks/useUploadBlobUrl.js +92 -13
- package/lib/hooks/useUploadBlobUrl.js.map +1 -1
- package/package.json +1 -1
- package/src/components/item-icon.tsx +18 -2
- package/src/components/pin-pad.tsx +129 -25
- package/src/hooks/__tests__/useUploadBlobUrl.test.tsx +128 -16
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useUploadBlobUrl.ts +140 -16
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Behavioural tests for `useUploadBlobUrl`.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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("
|
|
45
|
-
|
|
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).
|
|
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("
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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("
|
|
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).
|
|
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
|
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -16,7 +16,12 @@ export { useDebouncedValue } from "./useDebouncedValue";
|
|
|
16
16
|
|
|
17
17
|
export { useAutoLock, type UseAutoLockOptions } from "./useAutoLock";
|
|
18
18
|
|
|
19
|
-
export {
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|