jazz-tools 0.18.29 → 0.18.30

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 (107) hide show
  1. package/.svelte-kit/__package__/media/image.svelte +7 -4
  2. package/.svelte-kit/__package__/media/image.svelte.d.ts.map +1 -1
  3. package/.svelte-kit/__package__/media/image.types.d.ts +1 -0
  4. package/.svelte-kit/__package__/media/image.types.d.ts.map +1 -1
  5. package/.svelte-kit/__package__/tests/media/image.svelte.test.js +63 -0
  6. package/.turbo/turbo-build.log +62 -62
  7. package/CHANGELOG.md +17 -0
  8. package/dist/better-auth/auth/client.d.ts +1 -1
  9. package/dist/better-auth/auth/server.d.ts +1 -1
  10. package/dist/better-auth/auth/server.d.ts.map +1 -1
  11. package/dist/better-auth/auth/server.js.map +1 -1
  12. package/dist/better-auth/database-adapter/index.d.ts +3 -3
  13. package/dist/better-auth/database-adapter/index.d.ts.map +1 -1
  14. package/dist/better-auth/database-adapter/index.js +6 -2
  15. package/dist/better-auth/database-adapter/index.js.map +1 -1
  16. package/dist/better-auth/database-adapter/utils.d.ts.map +1 -1
  17. package/dist/browser/index.d.ts +2 -1
  18. package/dist/browser/index.d.ts.map +1 -1
  19. package/dist/browser/index.js.map +1 -1
  20. package/dist/{chunk-F55R554M.js → chunk-6BIYT3KH.js} +51 -30
  21. package/dist/chunk-6BIYT3KH.js.map +1 -0
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/inspector/{custom-element-35MDW4SW.js → custom-element-RQTLPAPJ.js} +584 -298
  25. package/dist/inspector/custom-element-RQTLPAPJ.js.map +1 -0
  26. package/dist/inspector/index.js +570 -338
  27. package/dist/inspector/index.js.map +1 -1
  28. package/dist/inspector/register-custom-element.js +1 -1
  29. package/dist/inspector/ui/index.d.ts +6 -0
  30. package/dist/inspector/ui/index.d.ts.map +1 -0
  31. package/dist/inspector/viewer/group-view.d.ts +3 -2
  32. package/dist/inspector/viewer/group-view.d.ts.map +1 -1
  33. package/dist/inspector/viewer/page.d.ts.map +1 -1
  34. package/dist/react/index.js +2 -2
  35. package/dist/react/index.js.map +1 -1
  36. package/dist/react/media/image.d.ts +8 -0
  37. package/dist/react/media/image.d.ts.map +1 -1
  38. package/dist/react-native-core/index.js +3 -3
  39. package/dist/react-native-core/index.js.map +1 -1
  40. package/dist/react-native-core/media/image.d.ts +15 -0
  41. package/dist/react-native-core/media/image.d.ts.map +1 -1
  42. package/dist/svelte/media/image.svelte +7 -4
  43. package/dist/svelte/media/image.svelte.d.ts.map +1 -1
  44. package/dist/svelte/media/image.types.d.ts +1 -0
  45. package/dist/svelte/media/image.types.d.ts.map +1 -1
  46. package/dist/svelte/tests/media/image.svelte.test.js +63 -0
  47. package/dist/testing.js +8 -1
  48. package/dist/testing.js.map +1 -1
  49. package/dist/tools/coValues/account.d.ts +1 -0
  50. package/dist/tools/coValues/account.d.ts.map +1 -1
  51. package/dist/tools/coValues/group.d.ts +3 -3
  52. package/dist/tools/coValues/group.d.ts.map +1 -1
  53. package/dist/tools/implementation/invites.d.ts +2 -2
  54. package/dist/tools/implementation/invites.d.ts.map +1 -1
  55. package/dist/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.d.ts +1 -1
  56. package/dist/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.d.ts.map +1 -1
  57. package/dist/tools/implementation/zodSchema/schemaTypes/AccountSchema.d.ts.map +1 -1
  58. package/dist/tools/implementation/zodSchema/typeConverters/TypeOfZodSchema.d.ts +3 -1
  59. package/dist/tools/implementation/zodSchema/typeConverters/TypeOfZodSchema.d.ts.map +1 -1
  60. package/dist/tools/implementation/zodSchema/zodCo.d.ts.map +1 -1
  61. package/dist/tools/implementation/zodSchema/zodReExport.d.ts +1 -1
  62. package/dist/tools/implementation/zodSchema/zodReExport.d.ts.map +1 -1
  63. package/dist/tools/subscribe/CoValueCoreSubscription.d.ts.map +1 -1
  64. package/dist/tools/subscribe/SubscriptionScope.d.ts +0 -2
  65. package/dist/tools/subscribe/SubscriptionScope.d.ts.map +1 -1
  66. package/dist/tools/testing.d.ts +1 -0
  67. package/dist/tools/testing.d.ts.map +1 -1
  68. package/dist/tools/tests/CoValueCoreSubscription.test.d.ts.map +1 -0
  69. package/package.json +4 -4
  70. package/src/better-auth/auth/server.ts +7 -2
  71. package/src/better-auth/auth/tests/server.test.ts +39 -17
  72. package/src/better-auth/database-adapter/index.ts +8 -5
  73. package/src/better-auth/database-adapter/utils.ts +4 -0
  74. package/src/browser/index.ts +2 -1
  75. package/src/inspector/ui/index.ts +5 -0
  76. package/src/inspector/viewer/group-view.tsx +304 -20
  77. package/src/inspector/viewer/new-app.tsx +4 -4
  78. package/src/inspector/viewer/page.tsx +16 -2
  79. package/src/react/media/image.tsx +11 -2
  80. package/src/react/tests/media/image.test.tsx +94 -0
  81. package/src/react-native-core/media/image.tsx +11 -3
  82. package/src/svelte/media/image.svelte +7 -4
  83. package/src/svelte/media/image.types.ts +1 -0
  84. package/src/svelte/tests/media/image.svelte.test.ts +85 -0
  85. package/src/tools/coValues/account.ts +30 -5
  86. package/src/tools/coValues/group.ts +13 -12
  87. package/src/tools/coValues/inbox.ts +5 -5
  88. package/src/tools/implementation/invites.ts +3 -8
  89. package/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts +5 -1
  90. package/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +2 -0
  91. package/src/tools/implementation/zodSchema/typeConverters/TypeOfZodSchema.ts +63 -50
  92. package/src/tools/implementation/zodSchema/zodReExport.ts +2 -2
  93. package/src/tools/subscribe/CoValueCoreSubscription.ts +17 -0
  94. package/src/tools/subscribe/SubscriptionScope.ts +1 -27
  95. package/src/tools/testing.ts +7 -0
  96. package/src/tools/{subscribe → tests}/CoValueCoreSubscription.test.ts +233 -3
  97. package/src/tools/tests/coFeed.branch.test.ts +14 -5
  98. package/src/tools/tests/coMap.test.ts +139 -42
  99. package/src/tools/tests/coOptional.test.ts +9 -1
  100. package/src/tools/tests/groupsAndAccounts.test.ts +156 -1
  101. package/src/tools/tests/load.test.ts +198 -1
  102. package/src/tools/tests/zod.test-d.ts +0 -2
  103. package/src/tools/tests/zod.test.ts +43 -40
  104. package/dist/chunk-F55R554M.js.map +0 -1
  105. package/dist/inspector/custom-element-35MDW4SW.js.map +0 -1
  106. package/dist/tools/subscribe/CoValueCoreSubscription.test.d.ts.map +0 -1
  107. /package/dist/tools/{subscribe → tests}/CoValueCoreSubscription.test.d.ts +0 -0
@@ -1,5 +1,13 @@
1
- import { JsonObject, LocalNode, RawAccount } from "cojson";
1
+ import {
2
+ Everyone,
3
+ JsonObject,
4
+ LocalNode,
5
+ RawAccount,
6
+ RawCoValue,
7
+ RawGroup,
8
+ } from "cojson";
2
9
  import { CoID } from "cojson";
10
+ import { useState } from "react";
3
11
  import {
4
12
  Table,
5
13
  TableBody,
@@ -8,60 +16,336 @@ import {
8
16
  TableHeader,
9
17
  TableRow,
10
18
  } from "../ui/table.js";
11
- import { Text } from "../ui/text.js";
12
19
  import { AccountOrGroupText } from "./account-or-group-text.js";
13
20
  import { RawDataCard } from "./raw-data-card.js";
14
21
  import { PageInfo, isCoId } from "./types.js";
22
+ import { Button, Icon, Modal, Input, Select } from "../ui/index.js";
23
+
24
+ function partitionMembers(data: Record<string, string>) {
25
+ const everyone = Object.entries(data)
26
+ .filter(([key]) => key === "everyone")
27
+ .map(([key, value]) => ({
28
+ id: key as CoID<RawCoValue>,
29
+ role: value as string,
30
+ }));
31
+
32
+ const members = Object.entries(data)
33
+ .filter(([key]) => isCoId(key))
34
+ .map(([key, value]) => ({
35
+ id: key as CoID<RawCoValue>,
36
+ role: value,
37
+ }));
38
+
39
+ const parentGroups = Object.entries(data)
40
+ .filter(([key]) => key.startsWith("parent_co_"))
41
+ .map(([key, value]) => ({
42
+ id: key.slice(7) as CoID<RawCoValue>,
43
+ role: value,
44
+ }));
45
+
46
+ const childGroups = Object.entries(data)
47
+ .filter(
48
+ ([key, value]) => key.startsWith("child_co_") && value !== "revoked",
49
+ )
50
+ .map(([key, value]) => ({
51
+ id: key.slice(6) as CoID<RawCoValue>,
52
+ role: value,
53
+ }));
54
+
55
+ return { everyone, members, parentGroups, childGroups };
56
+ }
15
57
 
16
58
  export function GroupView({
59
+ coValue,
17
60
  data,
18
61
  onNavigate,
19
62
  node,
20
63
  }: {
64
+ coValue: RawCoValue;
21
65
  data: JsonObject;
22
66
  onNavigate: (pages: PageInfo[]) => void;
23
67
  node: LocalNode;
24
68
  }) {
69
+ const [addMemberType, setAddMemberType] = useState<
70
+ null | "account" | "group"
71
+ >(null);
72
+
73
+ const { everyone, members, parentGroups, childGroups } = partitionMembers(
74
+ data as Record<string, string>,
75
+ );
76
+
77
+ const onRemoveMember = async (id: CoID<RawCoValue>) => {
78
+ if (confirm("Are you sure you want to remove this member?") === false) {
79
+ return;
80
+ }
81
+ try {
82
+ const group = await node.load(coValue.id);
83
+ if (group === "unavailable") {
84
+ throw new Error("Group not found");
85
+ }
86
+ const rawGroup = group as RawGroup;
87
+ rawGroup.removeMember(id as any);
88
+ } catch (error) {
89
+ console.error(error);
90
+ throw error;
91
+ }
92
+ };
93
+
94
+ const onRemoveGroup = async (id: CoID<RawCoValue>) => {
95
+ if (confirm("Are you sure you want to remove this group?") === false) {
96
+ return;
97
+ }
98
+ try {
99
+ const group = await node.load(coValue.id);
100
+ if (group === "unavailable") {
101
+ throw new Error("Group not found");
102
+ }
103
+ const rawGroup = group as RawGroup;
104
+ const targetGroup = await node.load(id);
105
+ if (targetGroup === "unavailable") {
106
+ throw new Error("Group not found");
107
+ }
108
+ const rawTargetGroup = targetGroup as RawGroup;
109
+ rawGroup.revokeExtend(rawTargetGroup);
110
+ } catch (error) {
111
+ console.error(error);
112
+ throw error;
113
+ }
114
+ };
115
+
116
+ const handleAddMemberSubmit = async (
117
+ event: React.FormEvent<HTMLFormElement>,
118
+ ) => {
119
+ event.preventDefault();
120
+ const form = event.currentTarget;
121
+
122
+ const memberId = (form.elements.namedItem("memberId") as HTMLInputElement)
123
+ ?.value;
124
+ const role = (form.elements.namedItem("role") as HTMLSelectElement)?.value;
125
+
126
+ try {
127
+ const group = await node.load(coValue.id);
128
+ if (group === "unavailable") {
129
+ throw new Error("Group not found");
130
+ }
131
+
132
+ const rawGroup = group as RawGroup;
133
+
134
+ // Adding an account
135
+ if (addMemberType === "account") {
136
+ let rawAccount: RawAccount | Everyone = "everyone";
137
+
138
+ if (memberId !== "everyone") {
139
+ const account = await node.load(memberId as CoID<RawCoValue>);
140
+ if (account === "unavailable") {
141
+ throw new Error("Account not found");
142
+ }
143
+ rawAccount = account as RawAccount;
144
+ }
145
+
146
+ rawGroup.addMember(rawAccount, role as "reader" | "writer" | "admin");
147
+ }
148
+ // Adding a group
149
+ else if (addMemberType === "group") {
150
+ const targetGroup = await node.load(memberId as CoID<RawCoValue>);
151
+ if (targetGroup === "unavailable") {
152
+ throw new Error("Group not found");
153
+ }
154
+
155
+ const rawTargetGroup = targetGroup as RawGroup;
156
+ rawGroup.extend(
157
+ rawTargetGroup,
158
+ role as "reader" | "writer" | "admin" | "inherit",
159
+ );
160
+ }
161
+
162
+ setAddMemberType(null);
163
+ } catch (error: any) {
164
+ console.error(error);
165
+ alert(`Failed to add ${addMemberType}: ${error.message}`);
166
+ }
167
+ };
168
+
25
169
  return (
26
170
  <>
27
- <Text strong>Members</Text>
28
-
29
171
  <Table>
30
172
  <TableHead>
31
173
  <TableRow>
32
- <TableHeader>Account</TableHeader>
174
+ <TableHeader>Member</TableHeader>
33
175
  <TableHeader>Permission</TableHeader>
176
+ <TableHeader></TableHeader>
34
177
  </TableRow>
35
178
  </TableHead>
36
179
  <TableBody>
37
- {"everyone" in data && typeof data.everyone === "string" ? (
38
- <TableRow>
39
- <TableCell>everyone</TableCell>
40
- <TableCell>{data.everyone}</TableCell>
180
+ {everyone.map((member) => (
181
+ <TableRow key={member.id}>
182
+ <TableCell>{member.id}</TableCell>
183
+ <TableCell>{member.role}</TableCell>
184
+ <TableCell>
185
+ {member.role !== "revoked" && (
186
+ <Button
187
+ variant="secondary"
188
+ onClick={() => onRemoveMember(member.id)}
189
+ >
190
+ <Icon name="delete" />
191
+ </Button>
192
+ )}
193
+ </TableCell>
194
+ </TableRow>
195
+ ))}
196
+ {members.map((member) => (
197
+ <TableRow key={member.id}>
198
+ <TableCell>
199
+ <AccountOrGroupText
200
+ coId={member.id}
201
+ node={node}
202
+ showId
203
+ onClick={() => {
204
+ onNavigate([{ coId: member.id, name: member.id }]);
205
+ }}
206
+ />
207
+ </TableCell>
208
+ <TableCell>{member.role}</TableCell>
209
+ <TableCell>
210
+ {member.role !== "revoked" && (
211
+ <Button
212
+ variant="secondary"
213
+ onClick={() => onRemoveMember(member.id)}
214
+ >
215
+ <Icon name="delete" />
216
+ </Button>
217
+ )}
218
+ </TableCell>
219
+ </TableRow>
220
+ ))}
221
+ {parentGroups.map((group) => (
222
+ <TableRow key={group.id}>
223
+ <TableCell>
224
+ <AccountOrGroupText
225
+ coId={group.id}
226
+ node={node}
227
+ showId
228
+ onClick={() => {
229
+ onNavigate([{ coId: group.id, name: group.id }]);
230
+ }}
231
+ />
232
+ </TableCell>
233
+ <TableCell>{group.role}</TableCell>
234
+ <TableCell>
235
+ {group.role !== "revoked" && (
236
+ <Button
237
+ variant="secondary"
238
+ onClick={() => onRemoveGroup(group.id)}
239
+ >
240
+ <Icon name="delete" />
241
+ </Button>
242
+ )}
243
+ </TableCell>
41
244
  </TableRow>
42
- ) : null}
245
+ ))}
246
+ </TableBody>
247
+ </Table>
43
248
 
44
- {Object.entries(data).map(([key, value]) =>
45
- isCoId(key) ? (
46
- <TableRow key={key}>
249
+ <div
250
+ style={{
251
+ display: "flex",
252
+ justifyContent: "flex-end",
253
+ gap: "0.75rem",
254
+ marginTop: "1rem",
255
+ }}
256
+ >
257
+ <Button variant="primary" onClick={() => setAddMemberType("account")}>
258
+ Add Account
259
+ </Button>
260
+ <Button variant="primary" onClick={() => setAddMemberType("group")}>
261
+ Add Group
262
+ </Button>
263
+ </div>
264
+
265
+ {childGroups.length > 0 && (
266
+ <Table>
267
+ <TableHead>
268
+ <TableRow>
269
+ <TableHeader>Member of</TableHeader>
270
+ </TableRow>
271
+ </TableHead>
272
+ <TableBody>
273
+ {childGroups.map((group) => (
274
+ <TableRow key={group.id}>
47
275
  <TableCell>
48
276
  <AccountOrGroupText
49
- coId={key as CoID<RawAccount>}
277
+ coId={group.id}
50
278
  node={node}
51
279
  showId
52
280
  onClick={() => {
53
- onNavigate([{ coId: key, name: key }]);
281
+ onNavigate([{ coId: group.id, name: group.id }]);
54
282
  }}
55
283
  />
56
284
  </TableCell>
57
- <TableCell>{value as string}</TableCell>
58
285
  </TableRow>
59
- ) : null,
60
- )}
61
- </TableBody>
62
- </Table>
286
+ ))}
287
+ </TableBody>
288
+ </Table>
289
+ )}
63
290
 
64
291
  <RawDataCard data={data} />
292
+
293
+ <Modal
294
+ isOpen={addMemberType !== null}
295
+ onClose={() => setAddMemberType(null)}
296
+ heading={addMemberType === "account" ? "Add Account" : "Add Group"}
297
+ showButtons={false}
298
+ >
299
+ <form onSubmit={handleAddMemberSubmit}>
300
+ <div
301
+ style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
302
+ >
303
+ <Input
304
+ name="memberId"
305
+ label={addMemberType === "account" ? "Account ID" : "Group ID"}
306
+ placeholder={
307
+ addMemberType === "account"
308
+ ? "Enter account ID"
309
+ : "Enter group ID"
310
+ }
311
+ required
312
+ />
313
+ <Select name="role" label="Role">
314
+ <option value="reader">Reader</option>
315
+ <option value="writer">Writer</option>
316
+ <option value="admin">Admin</option>
317
+ {addMemberType === "account" ? (
318
+ <>
319
+ <option value="writeOnly">Write Only</option>
320
+ </>
321
+ ) : (
322
+ <>
323
+ <option value="inherit">Inherit</option>
324
+ </>
325
+ )}
326
+ </Select>
327
+ <div
328
+ style={{
329
+ display: "flex",
330
+ gap: "0.75rem",
331
+ justifyContent: "flex-end",
332
+ marginTop: "0.5rem",
333
+ }}
334
+ >
335
+ <Button
336
+ type="button"
337
+ variant="secondary"
338
+ onClick={() => setAddMemberType(null)}
339
+ >
340
+ Cancel
341
+ </Button>
342
+ <Button type="submit" variant="primary">
343
+ Add
344
+ </Button>
345
+ </div>
346
+ </div>
347
+ </form>
348
+ </Modal>
65
349
  </>
66
350
  );
67
351
  }
@@ -91,8 +91,8 @@ export function JazzInspectorInternal({
91
91
  <InspectorContainer as={GlobalStyles} style={{ zIndex: 999 }}>
92
92
  <HeaderContainer>
93
93
  <Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
94
- <Form onSubmit={handleCoValueIdSubmit}>
95
- {path.length !== 0 && (
94
+ {path.length !== 0 && (
95
+ <Form onSubmit={handleCoValueIdSubmit}>
96
96
  <Input
97
97
  label="CoValue ID"
98
98
  style={{ fontFamily: "monospace" }}
@@ -101,8 +101,8 @@ export function JazzInspectorInternal({
101
101
  value={coValueId}
102
102
  onChange={(e) => setCoValueId(e.target.value as CoID<RawCoValue>)}
103
103
  />
104
- )}
105
- </Form>
104
+ </Form>
105
+ )}
106
106
  <DeleteLocalData />
107
107
  <Button variant="plain" type="button" onClick={() => setOpen(false)}>
108
108
  Close
@@ -1,4 +1,11 @@
1
- import { CoID, LocalNode, RawCoList, RawCoStream, RawCoValue } from "cojson";
1
+ import {
2
+ CoID,
3
+ LocalNode,
4
+ RawCoList,
5
+ RawCoStream,
6
+ RawCoValue,
7
+ RawGroup,
8
+ } from "cojson";
2
9
  import { styled } from "goober";
3
10
  import React from "react";
4
11
  import { Badge } from "../ui/badge.js";
@@ -117,7 +124,14 @@ function View(
117
124
  }
118
125
 
119
126
  if (extendedType === "group") {
120
- return <GroupView data={snapshot} node={node} onNavigate={onNavigate} />;
127
+ return (
128
+ <GroupView
129
+ coValue={value}
130
+ data={snapshot}
131
+ node={node}
132
+ onNavigate={onNavigate}
133
+ />
134
+ );
121
135
  }
122
136
 
123
137
  if (extendedType === "account") {
@@ -51,6 +51,13 @@ export type ImageProps = Omit<
51
51
  * ```
52
52
  */
53
53
  height?: number | "original";
54
+ /**
55
+ * A custom placeholder to display while an image is loading. This will
56
+ * be passed as the src of the img tag, so a data URL works well here.
57
+ * This will be used as a fallback if no images are ready and no placeholder
58
+ * is available otherwise.
59
+ */
60
+ placeholder?: string;
54
61
  };
55
62
 
56
63
  /**
@@ -69,11 +76,13 @@ export type ImageProps = Omit<
69
76
  * height={100}
70
77
  * alt="Avatar"
71
78
  * style={{ borderRadius: "50%", objectFit: "cover" }}
79
+ placeholder={myPlaceholder}
72
80
  * />
73
81
  * );
74
82
  * }
75
83
  * ```
76
84
  */
85
+
77
86
  export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
78
87
  { imageId, width, height, ...props },
79
88
  ref,
@@ -150,7 +159,7 @@ export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
150
159
  dimensions.height || dimensions.width || 9999,
151
160
  );
152
161
 
153
- if (!bestImage) return image.placeholderDataURL;
162
+ if (!bestImage) return image.placeholderDataURL ?? props?.placeholder;
154
163
  if (lastBestImage.current?.[0] === bestImage.image.$jazz.id)
155
164
  return lastBestImage.current?.[1];
156
165
 
@@ -163,7 +172,7 @@ export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
163
172
  return url;
164
173
  }
165
174
 
166
- return image.placeholderDataURL;
175
+ return image.placeholderDataURL ?? props?.placeholder;
167
176
  }, [image, dimensions.width, dimensions.height, waitingLazyLoading]);
168
177
 
169
178
  const onThresholdReached = useCallback(() => {
@@ -101,6 +101,100 @@ describe("Image", async () => {
101
101
  expect(img!.src).toBe(placeholderDataUrl);
102
102
  });
103
103
 
104
+ it("should not override actual placeholders", async () => {
105
+ const placeholderDataUrl =
106
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
107
+ const customPlaceholder =
108
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXVzZXItaWNvbiBsdWNpZGUtdXNlciI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=";
109
+
110
+ const original = FileStream.create({ owner: account });
111
+ original.start({ mimeType: "image/jpeg" });
112
+ // Don't end original, so it has no chunks
113
+
114
+ const im = ImageDefinition.create(
115
+ {
116
+ original,
117
+ originalSize: [100, 100],
118
+ progressive: false,
119
+ placeholderDataURL: placeholderDataUrl,
120
+ },
121
+ {
122
+ owner: account,
123
+ },
124
+ );
125
+
126
+ const { container } = render(
127
+ <Image
128
+ imageId={im.$jazz.id}
129
+ alt="test"
130
+ placeholder={customPlaceholder}
131
+ />,
132
+ {
133
+ account,
134
+ },
135
+ );
136
+
137
+ const img = container.querySelector("img");
138
+ expect(img).toBeDefined();
139
+ expect(img!.src).toBe(placeholderDataUrl);
140
+ });
141
+
142
+ it("should show custom placeholder while loading and replace with loaded image", async () => {
143
+ const createObjectURLSpy = vi
144
+ .spyOn(URL, "createObjectURL")
145
+ .mockImplementation((blob) => {
146
+ if (!(blob instanceof Blob)) {
147
+ throw new Error("Blob expected");
148
+ }
149
+ return `blob:test-${blob.size}`;
150
+ });
151
+
152
+ const customPlaceholder =
153
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXVzZXItaWNvbiBsdWNpZGUtdXNlciI+PHBhdGggZD0iTTE5IDIxdi0yYTQgNCAwIDAgMC00LTRIOWE0IDQgMCAwIDAtNCA0djIiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjciIHI9IjQiLz48L3N2Zz4=";
154
+
155
+ // Create an image with no chunks initially (loading state)
156
+ const original = FileStream.create({ owner: account });
157
+ original.start({ mimeType: "image/jpeg" });
158
+ // Don't end original, so it has no chunks
159
+
160
+ const im = ImageDefinition.create(
161
+ {
162
+ original,
163
+ originalSize: [100, 100],
164
+ progressive: false,
165
+ },
166
+ {
167
+ owner: account,
168
+ },
169
+ );
170
+
171
+ const { container } = render(
172
+ <Image
173
+ imageId={im.$jazz.id}
174
+ alt="test-loading-custom-placeholder"
175
+ placeholder={customPlaceholder}
176
+ />,
177
+ { account },
178
+ );
179
+
180
+ // Initially should show custom placeholder
181
+ let img = container.querySelector("img");
182
+ expect(img).toBeDefined();
183
+ expect(img!.src).toBe(customPlaceholder);
184
+
185
+ // Now add the actual image data
186
+ const imageData = await createDummyFileStream(100, account);
187
+ im.$jazz.set("100x100", imageData);
188
+
189
+ // Wait for the image to load and replace the placeholder
190
+ await waitFor(() => {
191
+ img = container.querySelector("img");
192
+ expect(img!.src).toBe("blob:test-100");
193
+ });
194
+
195
+ expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
196
+ });
197
+
104
198
  it("should render the original image once loaded", async () => {
105
199
  const createObjectURLSpy = vi
106
200
  .spyOn(URL, "createObjectURL")
@@ -35,6 +35,13 @@ export type ImageProps = Omit<RNImageProps, "width" | "height" | "source"> & {
35
35
  * ```
36
36
  */
37
37
  height?: number | "original";
38
+ /**
39
+ * A custom placeholder to display while an image is loading. This will
40
+ * be passed as the src of the img tag, so a data URL works well here.
41
+ * This will be used as a fallback if no images are ready and no placeholder
42
+ * is available otherwise.
43
+ */
44
+ placeholder?: string;
38
45
  };
39
46
 
40
47
  /**
@@ -53,6 +60,7 @@ export type ImageProps = Omit<RNImageProps, "width" | "height" | "source"> & {
53
60
  * width={100}
54
61
  * height={100}
55
62
  * resizeMode="cover"
63
+ * placeholder="/placeholder.png"
56
64
  * />
57
65
  * );
58
66
  * }
@@ -65,7 +73,7 @@ export type ImageProps = Omit<RNImageProps, "width" | "height" | "source"> & {
65
73
  * ```
66
74
  */
67
75
  export const Image = forwardRef<RNImage, ImageProps>(function Image(
68
- { imageId, width, height, ...props },
76
+ { imageId, width, height, placeholder, ...props },
69
77
  ref,
70
78
  ) {
71
79
  const image = useCoState(ImageDefinition, imageId);
@@ -117,7 +125,7 @@ export const Image = forwardRef<RNImage, ImageProps>(function Image(
117
125
  if (!image) return;
118
126
 
119
127
  let lastBestImage: FileStream | string | undefined =
120
- image.placeholderDataURL;
128
+ image?.placeholderDataURL ?? placeholder;
121
129
 
122
130
  const unsub = image.$jazz.subscribe({}, (update) => {
123
131
  if (lastBestImage === undefined && update.placeholderDataURL) {
@@ -146,7 +154,7 @@ export const Image = forwardRef<RNImage, ImageProps>(function Image(
146
154
  return unsub;
147
155
  }, [image]);
148
156
 
149
- if (!image) {
157
+ if (!src) {
150
158
  return null;
151
159
  }
152
160
 
@@ -5,7 +5,7 @@
5
5
  import { CoState } from "../jazz.class.svelte";
6
6
  import type { ImageProps } from "./image.types.js";
7
7
 
8
- const { imageId, width, height, ...rest }: ImageProps = $props();
8
+ const { imageId, width, height, placeholder, ...rest }: ImageProps = $props();
9
9
 
10
10
  const imageState = new CoState(ImageDefinition, () => imageId);
11
11
  let lastBestImage: [string, string] | null = null;
@@ -69,7 +69,10 @@
69
69
 
70
70
  const image = imageState.current;
71
71
  if (image === undefined)
72
- return "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
72
+ return (
73
+ placeholder ??
74
+ "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
75
+ );
73
76
 
74
77
  if (!image) return undefined;
75
78
 
@@ -79,7 +82,7 @@
79
82
  dimensions.height || dimensions.width || 9999,
80
83
  );
81
84
 
82
- if (!bestImage) return image.placeholderDataURL;
85
+ if (!bestImage) return image.placeholderDataURL ?? placeholder;
83
86
  if (lastBestImage?.[0] === bestImage.image.$jazz.id)
84
87
  return lastBestImage?.[1];
85
88
 
@@ -92,7 +95,7 @@
92
95
  return url;
93
96
  }
94
97
 
95
- return image.placeholderDataURL;
98
+ return image.placeholderDataURL ?? placeholder;
96
99
  });
97
100
 
98
101
  // Cleanup object URL on component destroy
@@ -4,4 +4,5 @@ export interface ImageProps extends Omit<HTMLImgAttributes, "width" | "height">
4
4
  imageId: string;
5
5
  width?: number | "original";
6
6
  height?: number | "original";
7
+ placeholder?: string;
7
8
  }