Compare commits

..

204 Commits

Author SHA1 Message Date
Matteo Manchi
621e809fad Merge pull request #2722 from garden-co/changeset-release/main
Version Packages 0.17
2025-08-11 15:45:03 +02:00
github-actions[bot]
d6600d9322 Version Packages 2025-08-11 13:26:38 +00:00
Matteo Manchi
2b08bd77c1 Merge pull request #2624 from garden-co/feat/new-image-apis
New Image management API
2025-08-11 15:24:33 +02:00
Guido D'Orsi
c1c6e31711 Merge pull request #2719 from garden-co/changeset-release/main
Version Packages
2025-08-11 14:15:38 +02:00
github-actions[bot]
0b16085f3c Version Packages 2025-08-11 12:07:42 +00:00
Guido D'Orsi
e53db2e96a chore: format 2025-08-11 14:04:22 +02:00
Guido D'Orsi
384f0e23c0 Merge pull request #2701 from garden-co/feat/better-async-storage
feat: support multiple instances of storage
2025-08-11 14:03:39 +02:00
Guido D'Orsi
daaf1789d9 Merge pull request #2721 from garden-co/fix/char-chunking-coplaintext
Fix local transactions streaming and implement chunking for CoPlainText
2025-08-11 14:02:49 +02:00
Guido D'Orsi
1f9e20e753 Merge pull request #2705 from garden-co/chore/biome-2
chore: bump biome version to 2.1.3
2025-08-11 14:01:16 +02:00
Guido D'Orsi
ce9ca54f5c feat: content chunking on CoPlainText 2025-08-11 14:00:20 +02:00
Guido D'Orsi
67e0968809 fix: fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE 2025-08-11 14:00:17 +02:00
Giordano Ricci
96a922cceb Merge pull request #2711 from garden-co/gio/usage-metering 2025-08-11 12:22:00 +01:00
Sammii
0a98b826f1 Merge pull request #2675 from garden-co/feat/quint-add-full-button-suite
Feat/quint add full button suite
2025-08-11 10:59:10 +01:00
Sammii
62a3854c41 Update packages/quint-ui/src/components/button.tsx
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
2025-08-11 10:45:57 +01:00
Matteo Manchi
f22ef4e646 chore: fix import ordering 2025-08-11 11:40:10 +02:00
Matteo Manchi
6c35d0031d chore(jazz-tools/svelte): refactor image component + svelte testing 2025-08-11 11:39:06 +02:00
Guido D'Orsi
7bdb6f4279 chore: simplify drawWaveform 2025-08-11 11:33:49 +02:00
Matteo Manchi
93f3fb231b fix(jazz-tools/media): fix resize calcs 2025-08-11 11:24:45 +02:00
Matteo Manchi
01d13d5df2 feat(jazz-tools/react): generate Image blobs on lazy loading 2025-08-11 11:24:45 +02:00
Matteo Manchi
944e725b95 fix(jazz-tools/react): disable revokeObjectURL on Image unmounts in development env 2025-08-11 11:24:45 +02:00
Matteo Manchi
16024fec8e chore(jazz-tools/react): show always the img element 2025-08-11 11:24:45 +02:00
Matteo Manchi
f90414ab95 chore(jazz-tools/react-native): move Image component from react-native to react-native-core 2025-08-11 11:24:45 +02:00
Guido D'Orsi
492eecb46a docs: add jsDoc comment to Image and createImage 2025-08-11 11:24:45 +02:00
Matteo Manchi
51144ec832 docs: add 0.17 upgrade guide 2025-08-11 11:24:44 +02:00
Matteo Manchi
fcaf4b9c30 chore: add changeset 2025-08-11 11:24:44 +02:00
Matteo Manchi
afae2649f5 docs: new docs for Image Management 2025-08-11 11:24:44 +02:00
Matteo Manchi
b5b0284c61 feat(jazz-tools/svelte): new Image component based on new image management API 2025-08-11 11:24:44 +02:00
Matteo Manchi
bf1475a143 feat(jazz-tools/react-native): new Image component based on new image management API 2025-08-11 11:24:44 +02:00
Matteo Manchi
e82cb80ca4 chore(example/image-upload): refactor using the new image management API 2025-08-11 11:24:44 +02:00
Matteo Manchi
32c2a617d6 feat(jazz-tools/react): new Image component based on new image management API 2025-08-11 11:24:42 +02:00
Matteo Manchi
d3c2a41c81 feat(jazz-tools/media): new media API for image management 2025-08-11 11:23:55 +02:00
Guido D'Orsi
4b99ff1fe3 feat: support multiple storage instances 2025-08-11 11:21:22 +02:00
Guido D'Orsi
3ebf8258a0 Merge pull request #2692 from garden-co/feat/garbage-collector
feat: added a TTL-based garbage collection
2025-08-11 10:54:58 +02:00
Guido D'Orsi
4809d14f6d chore: restore CI quality check 2025-08-11 10:45:04 +02:00
Guido D'Orsi
5ae1f33127 chore: disable importOrder and format the codebase 2025-08-11 10:44:03 +02:00
Guido D'Orsi
ca5d84f6a9 Merge pull request #2720 from garden-co/fix/vitest-nested-projects
chore: removed nested projects in vitest.config
2025-08-11 10:33:59 +02:00
Guido D'Orsi
6e6acc3404 chore: revert the homepage formatting 2025-08-11 10:26:24 +02:00
Guido D'Orsi
b17b7b6481 Merge remote-tracking branch 'origin/main' into chore/biome-2 2025-08-11 10:22:41 +02:00
Guido D'Orsi
5341646301 chore: revert formatting, remove the code-quality CI check 2025-08-11 10:21:33 +02:00
Guido D'Orsi
5416165d28 Merge remote-tracking branch 'origin/main' into feat/garbage-collector 2025-08-11 10:18:37 +02:00
Guido D'Orsi
b5a9f681c5 Merge pull request #2696 from garden-co/feat/chat-pagination
feat(chat): implement lazy-loading
2025-08-11 10:17:32 +02:00
Matteo Manchi
7dffc006eb chore: removed nested projects in vitest.config 2025-08-11 00:09:19 +02:00
Guido D'Orsi
cd3cc5b0ab Merge pull request #2716 from garden-co/fix/co-record-key-deep-loading
Fix return type on deep loaded co.record() when using single keys
2025-08-10 22:54:54 +02:00
Guido D'Orsi
ceab75eb4d Merge pull request #2718 from garden-co/feat/nice-music-player
fix: fix UnknownError: Unknown transaction on IndexedDB
2025-08-10 22:40:50 +02:00
Guido D'Orsi
103d1b41f7 fix: fix unknown transaction error on IndexedDB 2025-08-10 22:32:23 +02:00
Guido D'Orsi
b87cc6973e Merge pull request #2717 from garden-co/feat/nice-music-player
feat: improve music player UI
2025-08-10 22:23:41 +02:00
Guido D'Orsi
3d541ca241 feat: improve music player controls bar 2025-08-10 22:21:03 +02:00
Matteo Manchi
e72bfec884 fixup! fix(jazz-tools/coValues): fix return type on deep loaded co.record() when using string keys 2025-08-10 20:37:37 +02:00
Matteo Manchi
19c7ad27d9 fix(jazz-tools/coValues): fix return type on deep loaded co.record() when using string keys 2025-08-10 15:22:24 +02:00
Guido D'Orsi
0bc7bfc5cc test: cover string loading and unavailable props 2025-08-09 17:31:49 +02:00
Matteo Manchi
2c8120d46f fix(jazz-tools/coValues): fix return type on deep loaded co.record() when using single keys 2025-08-09 16:58:49 +02:00
Guido D'Orsi
c936c8c611 Merge pull request #2708 from garden-co/changeset-release/main
Version Packages
2025-08-08 19:29:12 +02:00
github-actions[bot]
58c6013770 Version Packages 2025-08-08 16:28:02 +00:00
Guido D'Orsi
3eb3291a97 Merge pull request #2714 from garden-co/revert-2712-fix/invalid-signature-allowlist
Revert "feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9"
2025-08-08 18:25:32 +02:00
Guido D'Orsi
6b659f2df3 Revert "feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9" 2025-08-08 18:25:22 +02:00
Guido D'Orsi
dcc9c9a5ec Merge pull request #2712 from garden-co/fix/invalid-signature-allowlist
feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9
2025-08-08 18:14:58 +02:00
Guido D'Orsi
fe9a244363 Merge pull request #2710 from garden-co/fix/missing-child-rotation
fix: handle missing child groups when rotating key
2025-08-08 18:12:11 +02:00
Guido D'Orsi
9440bbc058 Merge pull request #2713 from garden-co/fix/nested-discriminated-union
fix: fix nested discriminated unions
2025-08-08 18:08:34 +02:00
Guido D'Orsi
1c92cc2997 chore: improve the key fallback 2025-08-08 18:07:15 +02:00
Guido D'Orsi
33ebbf0bdd fix: fix nested discriminated unions 2025-08-08 17:58:50 +02:00
Guido D'Orsi
d630b5bde5 Merge pull request #2704 from garden-co/fix/everyone-readkey-rotation
fix: skip rotateKey when everyone has read access
2025-08-08 17:31:14 +02:00
Guido D'Orsi
1c6ae12cd9 feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9 2025-08-08 14:53:58 +02:00
Giordano Ricci
ac5d20d159 Revert "Merge pull request #2709 from garden-co/revert-2682-gio/usage-metering"
This reverts commit b3d1ad7201, reversing
changes made to fbc29f2f17.
2025-08-08 12:35:16 +01:00
Guido D'Orsi
21bcaabd5a chore: update failing snapshot 2025-08-08 13:13:45 +02:00
Guido D'Orsi
17b4d5b668 chore: update failing snapshot 2025-08-08 13:12:51 +02:00
Guido D'Orsi
3cd15862d5 fix: handle missing child groups when rotating key 2025-08-08 13:11:40 +02:00
Guido D'Orsi
b3d1ad7201 Merge pull request #2709 from garden-co/revert-2682-gio/usage-metering
Revert "feat: add ingress/egress metering on cojosn-transport-ws"
2025-08-08 12:32:58 +02:00
Guido D'Orsi
d87df11795 fix: fallback to the latest available readkey when key_for_everyone was not being revealed when everyone has access 2025-08-08 12:16:55 +02:00
Giordano Ricci
82c2a62b2a Revert "feat: add ingress/egress metering on cojosn-transport-ws" 2025-08-08 11:03:48 +01:00
Guido D'Orsi
0a9112506e fix: fixes cilrcular import issue on group.test.ts 2025-08-08 11:40:24 +02:00
Giordano Ricci
fbc29f2f17 Merge pull request #2682 from garden-co/gio/usage-metering 2025-08-08 11:16:59 +02:00
Brad Anderson
3915bbbf3c fix: update tests due to sync protocol improvements 2025-08-06 17:08:00 -04:00
Brad Anderson
0b471c4e89 fix: undo organizeImports that broke tests - jazz-tools 2025-08-06 12:34:29 -04:00
Brad Anderson
09077d37ef chore: code-quality version bump, biome to catalog for examples 2025-08-06 10:40:42 -04:00
Brad Anderson
afe06b4fa6 chore: format-and-lint:fix 2025-08-06 10:29:38 -04:00
Brad Anderson
d89b6e488a chore: bump biome version to 2.1.3 2025-08-06 10:26:33 -04:00
Guido D'Orsi
f6361ee43b Merge pull request #2703 from didier/patch-2
Update Svelte setup doc to be more accurate for Svelte 5
2025-08-06 15:41:16 +02:00
Guido D'Orsi
726dbfb6df fix: heal groups with missing key for everyone 2025-08-06 13:44:10 +02:00
Guido D'Orsi
267f689f10 fix: skip rotateKey when everyone has read access 2025-08-06 12:26:36 +02:00
Giordano Ricci
893ad3ae23 comment out flaky assertion 2025-08-06 12:25:06 +02:00
Giordano Ricci
f5590b1be8 remove duplicated import 2025-08-06 12:14:10 +02:00
Giordano Ricci
17a01f57e8 move utils 2025-08-06 12:12:08 +02:00
Giordano Ricci
7318d86f52 Merge branch 'main' into gio/usage-metering 2025-08-06 12:05:05 +02:00
Didier Catz
1c8403e87a Update to new schema syntax instead of classes 2025-08-06 10:23:35 +02:00
Didier Catz
dd747c068a Use consistent quotes / semis 2025-08-06 00:30:00 +02:00
Didier Catz
1f0f230fe2 Newline 2025-08-06 00:28:37 +02:00
Didier Catz
da655cbff5 Typo 2025-08-06 00:24:45 +02:00
Didier Catz
02f6c6220e Update svelte.mdx 2025-08-06 00:10:31 +02:00
Didier Catz
0755cd198e Update svelte.mdx 2025-08-06 00:02:30 +02:00
Didier Catz
c4a8227b66 Update Svelte setup doc to be more accurate for Svelte 5 2025-08-06 00:00:55 +02:00
Giordano Ricci
86f0302233 add meta 2025-08-05 13:13:06 +01:00
Sammii
a5ece15797 adding defaults to button 2025-08-05 12:04:41 +01:00
Sammii
9f8877202e creating color-highlight var in quint 2025-08-05 10:56:08 +01:00
Sammii
d190097ed9 creating tempory nav 2025-08-05 10:55:16 +01:00
Sammii
9841617c66 adding colours to homepage 2025-08-05 10:54:56 +01:00
Guido D'Orsi
165a6170cd Merge pull request #2700 from garden-co/changeset-release/main
Version Packages
2025-08-04 21:13:54 +02:00
github-actions[bot]
5148419df9 Version Packages 2025-08-04 19:11:13 +00:00
Guido D'Orsi
fc0ecb0968 chore: changeset 2025-08-04 21:07:48 +02:00
Guido D'Orsi
802b5a3060 chore: changeset 2025-08-04 21:06:23 +02:00
Guido D'Orsi
e47af262b3 Merge pull request #2673 from garden-co/feat/storage-wal
fix: ensure that transactions are synced in the correct order
2025-08-04 20:54:53 +02:00
Sammii
688a4850a4 add svg sizes to button and amend icons docs page 2025-08-04 16:09:39 +01:00
Sammii
e87fef751e remove old icon pages 2025-08-04 16:08:01 +01:00
Sammii
8f714440f8 create icons page 2025-08-04 16:07:28 +01:00
Sammii
70cd09170e updating button docs page 2025-08-04 16:02:28 +01:00
Guido D'Orsi
e98b610fd0 Merge pull request #2698 from garden-co/feat/comap-pick-and-partial
feat: Add `.pick()` and `.partial()` methods to CoMapSchema
2025-08-04 14:53:38 +02:00
Guido D'Orsi
b554983558 Merge pull request #2699 from garden-co/fix/extend-circular-check
fix: fixes error when extending a group without having child groups loaded
2025-08-04 14:53:15 +02:00
Guido D'Orsi
4c63334299 chore: add comments 2025-08-04 14:48:17 +02:00
Guido D'Orsi
4aef7cdac5 Update .changeset/ten-cobras-fetch.md
Co-authored-by: Joe Innes <joe@joeinn.es>
2025-08-04 14:39:26 +02:00
Guido D'Orsi
76adeb0d53 chore: clean up implementation 2025-08-04 14:03:51 +02:00
Guido D'Orsi
d95dcbe7db fix: align pick to the Zod API 2025-08-04 13:24:44 +02:00
Guido D'Orsi
f9d538f049 fix: fixes error when extending a group without having child groups loaded 2025-08-04 12:37:53 +02:00
Guido D'Orsi
40c7336c09 chore: update lucide-react 2025-08-04 11:21:18 +02:00
Guido D'Orsi
e0d2723615 fix: router update when calling navitate 2025-08-04 11:17:49 +02:00
Guido D'Orsi
93e68c62f5 docs: fix a missing type alias 2025-08-04 10:52:06 +02:00
Guido D'Orsi
dadee9dcc5 test: fix flaky test 2025-08-04 10:40:03 +02:00
Guido D'Orsi
6724c4bd83 feat: add docs, remove lodash-es dependency and add tests for recursive types with pick and partial 2025-08-04 10:34:43 +02:00
NicoR
1942bd5de4 Replace lodash with lodash-es 2025-08-04 01:44:27 -03:00
NicoR
16764f6365 Add changeset 2025-08-04 01:23:00 -03:00
NicoR
b56cfc2e1f Add TS docs 2025-08-04 01:21:32 -03:00
NicoR
7091bcf9c0 Add CoMapSchema.partial 2025-08-04 01:17:25 -03:00
NicoR
436cbfa095 Add CoMapSchema.pick 2025-08-04 01:00:57 -03:00
Guido D'Orsi
c19a25f928 feat(chat): implement lazy-loading 2025-08-03 17:20:46 +02:00
Guido D'Orsi
104e664bbb fix: fix build errors on music player 2025-08-03 17:20:15 +02:00
Guido D'Orsi
f199b451eb chore: use inline JSON when creating covalues 2025-08-03 17:09:02 +02:00
Guido D'Orsi
70bc48458e Merge pull request #2695 from garden-co/feat/music-player-refresh
docs: exclude upgrade guides from llm.txt
2025-08-02 14:37:56 +02:00
Guido D'Orsi
f28b2a6135 docs: exclude upgrade guides 2025-08-02 14:36:55 +02:00
Guido D'Orsi
55b770b7c9 Merge pull request #2694 from garden-co/feat/music-player-refresh
feat: improve the music player UI
2025-08-02 14:29:35 +02:00
Guido D'Orsi
e6838dfb98 feat: make the music-player a PWA 2025-08-02 14:22:36 +02:00
Guido D'Orsi
5e34061fdc feat: improve the music player UI 2025-08-02 14:19:24 +02:00
Guido D'Orsi
6d9b77195a chore: clean up code 2025-08-02 12:36:56 +02:00
Guido D'Orsi
9bf7946ee6 feat: added a TTL-based garbage collection 2025-08-01 19:58:11 +02:00
Guido D'Orsi
acecffaeb2 test: fix flaky tests on the created and update time 2025-08-01 19:57:33 +02:00
Nico Rainhart
0a98d6aaf2 Merge pull request #2691 from garden-co/changeset-release/main
Version Packages
2025-08-01 12:57:33 -03:00
github-actions[bot]
4ea1a63a0a Version Packages 2025-08-01 15:47:51 +00:00
Nico Rainhart
41a4c3bc95 Merge pull request #2683 from garden-co/feat/json-create-and-set
feat: create CoValues using plain JSON objects
2025-08-01 12:45:30 -03:00
Guido D'Orsi
60d0027f9d Merge pull request #2690 from garden-co/docs/useCoState-jsDoc
docs: adds jsDocs to useCoState and useAccount react hooks
2025-08-01 16:12:39 +02:00
Guido D'Orsi
f2f5b55dbf Update packages/jazz-tools/src/react-core/hooks.ts
Co-authored-by: Nico Rainhart <nmrainhart@gmail.com>
2025-08-01 15:42:24 +02:00
Guido D'Orsi
896ee3460f docs: adds jsDocs to useCoState and useAccount react hooks 2025-08-01 15:37:02 +02:00
Guido D'Orsi
5a48c9c44c chore: improve tests titles and add comments 2025-08-01 10:14:24 +02:00
NicoR
c564fbb02e test: add permission tests for creating nested CoValues from JSON 2025-07-31 15:03:25 -03:00
NicoR
fd2d247ff5 docs: improve examples 2025-07-31 13:08:50 -03:00
NicoR
9e9ea029b2 fix: move alert out of CodeGroup 2025-07-30 17:32:56 -03:00
NicoR
a0da272dcd fix: add missing import in docs 2025-07-30 16:51:23 -03:00
NicoR
72fbcc3262 chore: remove unnecessary import from form example 2025-07-30 15:52:07 -03:00
NicoR
f4c8cc858b docs: add sections for creating CoValues from JSON and permissions 2025-07-30 15:48:55 -03:00
Giordano Ricci
5c98ff4e4f use object.values 2025-07-30 19:24:45 +01:00
NicoR
4cbda689c4 refactor: update form example to use new API 2025-07-30 14:54:08 -03:00
NicoR
771b0ed914 fix: cannot create empty plain text when nested 2025-07-30 14:53:38 -03:00
NicoR
79913c3136 fix: simplify CoMapInit schema 2025-07-30 13:37:10 -03:00
NicoR
43d3511d15 Add changeset 2025-07-30 12:40:25 -03:00
NicoR
928ef14086 feat: support deeply nested optional primitive fields 2025-07-30 12:33:28 -03:00
NicoR
048dd7def0 feat: support deeply nested optional CoValue fields 2025-07-30 12:33:09 -03:00
Guido D'Orsi
51fcb8a44b test: improve the client subscription test 2025-07-30 17:29:01 +02:00
Guido D'Orsi
c5888c39f5 perf: update parent before updating children to favor batching 2025-07-30 17:14:45 +02:00
Guido D'Orsi
2defcfae67 test: mark retry unavailable states as flaky 2025-07-30 17:10:50 +02:00
NicoR
873b146d15 feat: create a child group for each created CoValue 2025-07-30 11:03:38 -03:00
Guido D'Orsi
213de11c3b feat: preserve transaction order on sync 2025-07-30 15:37:58 +02:00
Sammii
2f24d35471 amending comments for button tv 2025-07-30 12:49:49 +01:00
Sammii
42667c81bb imrprove icon docs 2025-07-30 12:42:36 +01:00
Giordano Ricci
1b881cc89f cleanup tests 2025-07-30 12:00:54 +01:00
Guido D'Orsi
af295d816a chore: add comments and rename CoValueSyncQueue in LocalTransactionsSyncQueue 2025-07-30 12:54:46 +02:00
Guido D'Orsi
fe8d3497c0 chore: fix the peer attribution on storage corrections tests 2025-07-30 12:37:19 +02:00
Giordano Ricci
c2899e94ca add changeset 2025-07-30 11:36:17 +01:00
Giordano Ricci
f4be67e9b6 Merge branch 'main' into gio/usage-metering 2025-07-30 11:34:37 +01:00
Guido D'Orsi
ba9ad295b6 fix: don't consider -1 as a valid signature checkpoint 2025-07-30 12:32:07 +02:00
Giordano Ricci
9ed5a96ef8 lockfile update 2025-07-30 11:28:44 +01:00
Giordano Ricci
4272ea9019 refactor: use getTransactionSize util 2025-07-30 11:28:17 +01:00
Giordano Ricci
9509307ed1 cleanup and add egress tests 2025-07-30 11:27:24 +01:00
Giordano Ricci
be08921bc5 cleanup and add ingress tests 2025-07-30 11:01:10 +01:00
Sammii
77e3c21cbd format globals css 2025-07-30 10:58:18 +01:00
NicoR
ab1798c7bd feat: make CoValue creation from JSON type safe 2025-07-29 16:41:48 -03:00
NicoR
26ae69a242 refactor: reuse TypeOfZodSchema 2025-07-29 12:44:42 -03:00
Giordano Ricci
25be055a51 wip: basica ingress/egress metering 2025-07-29 16:39:34 +01:00
NicoR
21ad3767b9 refactor: avoid InstanceOrPrimitiveOfSchemaCoValuesNullable duplication 2025-07-29 11:48:46 -03:00
NicoR
a9383516c1 refactor: avoid InstanceOrPrimitiveOfSchema duplication 2025-07-29 11:38:09 -03:00
NicoR
bffc516c68 refactor: extract TypeOfZodSchema util 2025-07-29 11:16:14 -03:00
NicoR
9e7c0d9887 chore: clean up instantiateRefEncodedWithInit's implementation 2025-07-29 10:56:32 -03:00
NicoR
99b44d5780 feat: create CoMap with JSON discriminated union fields 2025-07-29 10:36:33 -03:00
NicoR
02db5f3b1d feat: create CoMap with JSON CoFeed fields 2025-07-29 09:13:01 -03:00
NicoR
1949a5fcd9 feat: create CoMap with JSON CoList fields 2025-07-28 17:15:43 -03:00
NicoR
dcd3b022cc feat: create CoMap with JSON plain and rich text fields 2025-07-28 16:56:43 -03:00
NicoR
a7b837c7e1 feat: create CoMap with JSON CoMap fields 2025-07-28 16:47:51 -03:00
Guido D'Orsi
b173e0884a feat: improve local transactions streaming calculation 2025-07-28 19:45:31 +02:00
Guido D'Orsi
231947c97a fix(sync): start a new content message when the size exceeds the recommended value 2025-07-28 18:44:13 +02:00
Guido D'Orsi
d5b57ad1fc fix: fix priority for content 2025-07-28 17:53:33 +02:00
Guido D'Orsi
0bf5c53bec fix: disable code coverage check on CI 2025-07-28 16:59:02 +02:00
Guido D'Orsi
e7b1550003 feat: perserve insert order when storing transactions on multiple covalues 2025-07-28 16:59:02 +02:00
Sammii
f5039cefc1 addig icon button to docs page and icons pagr tidy 2025-07-28 14:09:46 +01:00
Sammii
6540893caf adding default, strong and muted to css 2025-07-28 13:51:23 +01:00
Sammii
bfc85c4573 refactoring icon and icon page 2025-07-28 13:50:57 +01:00
Sammii
e9076313ab amending Button page, adding title 2025-07-28 13:50:44 +01:00
Sammii
c6afd8ae36 adding placeholder favicon 2025-07-28 13:40:52 +01:00
Sammii
370f20d13d refactoring css and button component 2025-07-28 13:40:31 +01:00
Sammii
f9b3116deb adding custom color steps to all tailwind css colours in design system 2025-07-28 13:10:42 +01:00
Sammii
352d34979f create icon component 2025-07-25 17:34:29 +01:00
Sammii
7ff736ace4 improving gradient on muted, default and strong intent buttons 2025-07-25 16:23:23 +01:00
Sammii
5bab466fd0 adding default/hover/active states for all intents 2025-07-25 16:20:55 +01:00
Sammii
329b8c3d6a switching muted and default styles 2025-07-25 15:29:01 +01:00
Sammii
c0aeb7baf9 porting over variant/intent styles 2025-07-25 11:21:17 +01:00
Sammii
8a14de10d7 fix(quint-ui): correct hover and active states for button variants 2025-07-25 11:13:39 +01:00
Sammii
b585b39a86 porting over glass styles with specular borders 2025-07-25 10:15:57 +01:00
Sammii
e9b2860e74 button tv refactor 2025-07-23 16:32:07 +01:00
Sammii
6327d74f68 mapping over button suite from old design system to quint 2025-07-23 16:24:19 +01:00
Sammii
bedbabdcb4 styling the layout of quint docs 2025-07-23 15:18:48 +01:00
255 changed files with 14604 additions and 4302 deletions

View File

@@ -22,7 +22,7 @@ jobs:
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: 1.9.4
version: 2.1.3
- name: Run Biome
run: biome ci .

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -7,39 +7,35 @@
},
"files": {
"ignoreUnknown": false,
"ignore": [
"jazz-tools.json",
"**/ios/**",
"**/android/**",
"tests/jazz-svelte/src/**",
"examples/*svelte*/**",
"starters/*svelte*/**",
"examples/server-worker-inbox/src/routeTree.gen.ts",
"homepage/homepage/**",
"**/package.json"
"includes": [
"**",
"!**/jazz-tools.json",
"!**/ios/**",
"!**/android/**",
"!**/tests/jazz-svelte/src/**",
"!**/examples/**/*svelte*/**",
"!**/starters/**/*svelte*/**",
"!**/examples/server-worker-inbox/src/routeTree.gen.ts",
"!**/homepage/homepage/**",
"!**/package.json",
"!**/*svelte*/**"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"organizeImports": {
"enabled": true
},
"assist": { "actions": { "source": { "organizeImports": "off" } } },
"linter": {
"enabled": false,
"rules": {
"recommended": true,
"correctness": {
"useExhaustiveDependencies": "off",
"useImportExtensions": {
"level": "error",
"options": {
"suggestedExtensions": {
"ts": {
"module": "js",
"component": "jsx"
}
}
"forceJsExtensions": true
}
}
}
@@ -47,7 +43,7 @@
},
"overrides": [
{
"include": ["packages/**/src/**"],
"includes": ["**/packages/**/src/**"],
"linter": {
"enabled": true,
"rules": {
@@ -56,7 +52,10 @@
}
},
{
"include": ["packages/cojson/src/storage/*/**", "cojson-transport-ws/**"],
"includes": [
"**/packages/cojson/src/storage/**/*/**",
"**/cojson-transport-ws/**"
],
"linter": {
"enabled": true,
"rules": {
@@ -65,7 +64,7 @@
}
},
{
"include": ["**/tests/**"],
"includes": ["**/tests/**"],
"linter": {
"rules": {
"correctness": {
@@ -75,7 +74,7 @@
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "info"
"noExplicitAny": "off"
}
}
}

View File

@@ -76,7 +76,9 @@ export function ChatScreen({ navigation }: { navigation: any }) {
const renderMessageItem = ({
item,
}: { item: Loaded<typeof Message, { text: true }> }) => {
}: {
item: Loaded<typeof Message, { text: true }>;
}) => {
const isMe = item._edits?.text?.by?.isMe;
return (
<View

View File

@@ -3,11 +3,7 @@ import React from "react";
import { Text } from "react-native";
import { Chat } from "./schema";
export function HandleInviteScreen({
navigation,
}: {
navigation: any;
}) {
export function HandleInviteScreen({ navigation }: { navigation: any }) {
useAcceptInviteNative({
invitedObjectSchema: Chat,
onAccept: async (chatId) => {

View File

@@ -1,5 +1,42 @@
# passkey-svelte
## 0.0.113
### Patch Changes
- Updated dependencies [fcaf4b9]
- jazz-tools@0.17.0
## 0.0.112
### Patch Changes
- Updated dependencies [67e0968]
- Updated dependencies [2c8120d]
- jazz-tools@0.16.6
## 0.0.111
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [33ebbf0]
- jazz-tools@0.16.5
## 0.0.110
### Patch Changes
- Updated dependencies [16764f6]
- jazz-tools@0.16.4
## 0.0.109
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
## 0.0.108
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.108",
"version": "0.0.113",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { ImageDefinition, type Loaded } from 'jazz-tools';
import { useProgressiveImg } from '$lib/utils/useProgressiveImage.svelte';
import { Image } from 'jazz-tools/svelte';
let { image }: { image: Loaded<typeof ImageDefinition> } = $props();
const { src } = $derived(
useProgressiveImg({
image
})
);
</script>
<img class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1" {src} alt="" />
<Image
imageId={image.id}
alt=""
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
/>

View File

@@ -1,53 +0,0 @@
import { ImageDefinition, type Loaded } from 'jazz-tools';
import { onDestroy } from 'svelte';
export function useProgressiveImg({
image,
maxWidth,
targetWidth
}: {
image: Loaded<typeof ImageDefinition> | null | undefined;
maxWidth?: number;
targetWidth?: number;
}) {
let current = $state<{
src?: string;
res?: `${number}x${number}` | 'placeholder';
}>();
const originalSize = $state(image?.originalSize);
const unsubscribe = image?.subscribe({}, (update: Loaded<typeof ImageDefinition>) => {
const highestRes = ImageDefinition.highestResAvailable(update, { maxWidth, targetWidth });
if (highestRes) {
if (highestRes.res !== current?.res) {
const blob = highestRes.stream.toBlob();
if (blob) {
const blobURI = URL.createObjectURL(blob);
current = { src: blobURI, res: highestRes.res };
setTimeout(() => URL.revokeObjectURL(blobURI), 200);
}
}
} else {
current = {
src: update?.placeholderDataURL,
res: 'placeholder'
};
}
});
onDestroy(() => () => {
unsubscribe?.();
});
return {
get src() {
return current?.src;
},
get res() {
return current?.res;
},
originalSize
};
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { createImage } from 'jazz-tools/browser-media-images';
import { createImage } from 'jazz-tools/media';
import { AccountCoState, CoState } from 'jazz-tools/svelte';
import { Account, CoPlainText, type ID } from 'jazz-tools';

View File

@@ -15,7 +15,7 @@
"clsx": "^2.0.0",
"hash-slash": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"lucide-react": "^0.536.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "3.25.76"

View File

@@ -1,6 +1,7 @@
import { Account, co } from "jazz-tools";
import { createImage, useAccount, useCoState } from "jazz-tools/react";
import { useState } from "react";
import { Account } from "jazz-tools";
import { createImage } from "jazz-tools/media";
import { useAccount, useCoState } from "jazz-tools/react";
import { useEffect, useState } from "react";
import { Chat, Message } from "./schema.ts";
import {
BubbleBody,
@@ -15,14 +16,17 @@ import {
TextInput,
} from "./ui.tsx";
export function ChatScreen(props: { chatID: string }) {
const chat = useCoState(Chat, props.chatID, {
resolve: { $each: { text: true } },
});
const { me } = useAccount();
const [showNLastMessages, setShowNLastMessages] = useState(30);
const INITIAL_MESSAGES_TO_SHOW = 30;
if (!chat)
export function ChatScreen(props: { chatID: string }) {
const chat = useCoState(Chat, props.chatID);
const { me } = useAccount();
const [showNLastMessages, setShowNLastMessages] = useState(
INITIAL_MESSAGES_TO_SHOW,
);
const isLoading = useMessagesPreload(props.chatID);
if (!chat || isLoading)
return (
<div className="flex-1 flex justify-center items-center">Loading...</div>
);
@@ -37,11 +41,15 @@ export function ChatScreen(props: { chatID: string }) {
return;
}
createImage(file, { owner: chat._owner }).then((image) => {
createImage(file, {
owner: chat._owner,
progressive: true,
placeholder: "blur",
}).then((image) => {
chat.push(
Message.create(
{
text: co.plainText().create(file.name, chat._owner),
text: file.name,
image: image,
},
chat._owner,
@@ -59,9 +67,14 @@ export function ChatScreen(props: { chatID: string }) {
<ChatBody>
{chat.length > 0 ? (
chat
// We call slice before reverse to avoid mutating the original array
.slice(-showNLastMessages)
.reverse() // this plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
.map((msg) => <ChatBubble me={me} msg={msg} key={msg.id} />)
// Reverse plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
.reverse()
.map(
(msg) =>
msg?.text && <ChatBubble me={me} msg={msg} key={msg.id} />,
)
) : (
<EmptyChatMessage />
)}
@@ -80,12 +93,7 @@ export function ChatScreen(props: { chatID: string }) {
<TextInput
onSubmit={(text) => {
chat.push(
Message.create(
{ text: co.plainText().create(text, chat._owner) },
chat._owner,
),
);
chat.push(Message.create({ text }, chat._owner));
}}
/>
</InputBar>
@@ -93,10 +101,7 @@ export function ChatScreen(props: { chatID: string }) {
);
}
function ChatBubble(props: {
me: Account;
msg: co.loaded<typeof Message, { text: true }>;
}) {
function ChatBubble(props: { me: Account; msg: Message }) {
if (!props.me.canRead(props.msg) || !props.msg.text?.toString()) {
return (
<BubbleContainer fromMe={false}>
@@ -126,3 +131,35 @@ function ChatBubble(props: {
</BubbleContainer>
);
}
/**
* Warms the local cache with the initial messages to load only the initial messages
* and avoid flickering
*/
function useMessagesPreload(chatID: string) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
preloadChatMessages(chatID).finally(() => {
setIsLoading(false);
});
}, [chatID]);
return isLoading;
}
async function preloadChatMessages(chatID: string) {
const chat = await Chat.load(chatID);
if (!chat?._refs) return;
const promises = [];
for (const msg of Array.from(chat._refs)
.reverse()
.slice(0, INITIAL_MESSAGES_TO_SHOW)) {
promises.push(Message.load(msg.id, { resolve: { text: true } }));
}
await Promise.all(promises);
}

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { CoPlainText, ImageDefinition } from "jazz-tools";
import { ProgressiveImg } from "jazz-tools/react";
import { Image } from "jazz-tools/react";
import { ImageIcon } from "lucide-react";
import { useId, useRef } from "react";
@@ -83,14 +83,12 @@ export function BubbleText(props: {
export function BubbleImage(props: { image: ImageDefinition }) {
return (
<ProgressiveImg image={props.image}>
{({ src }) => (
<img
className="h-auto max-h-80 max-w-full rounded-t-xl mb-1"
src={src}
/>
)}
</ProgressiveImg>
<Image
imageId={props.image.id}
className="h-auto max-h-80 max-w-full rounded-t-xl mb-1"
height="original"
width="original"
/>
);
}
@@ -112,7 +110,9 @@ export function InputBar(props: { children: React.ReactNode }) {
export function ImageInput({
onImageChange,
}: { onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void }) {
}: {
onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const onUploadClick = () => {

View File

@@ -10,7 +10,9 @@ import {
export function SignInScreen({
setPage,
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
}: {
setPage: (page: "sign-in" | "sign-up") => void;
}) {
const { signIn, setActive, isLoaded } = useSignIn();
const [emailAddress, setEmailAddress] = useState("");

View File

@@ -10,7 +10,9 @@ import {
export function SignUpScreen({
setPage,
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
}: {
setPage: (page: "sign-in" | "sign-up") => void;
}) {
const { isLoaded, signUp, setActive } = useSignUp();
const [emailAddress, setEmailAddress] = React.useState("");

View File

@@ -1,5 +1,4 @@
import { useIframeHashRouter } from "hash-slash";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { useState } from "react";
import { Errors } from "./Errors.tsx";
@@ -9,7 +8,6 @@ import {
BubbleTeaOrder,
DraftBubbleTeaOrder,
JazzAccount,
ListOfBubbleTeaAddOns,
validateDraftOrder,
} from "./schema.ts";
@@ -22,7 +20,7 @@ export function CreateOrder() {
if (!me?.root) return;
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
const onSave = (draft: DraftBubbleTeaOrder) => {
const validation = validateDraftOrder(draft);
setErrors(validation.errors);
if (validation.errors.length > 0) {
@@ -30,11 +28,11 @@ export function CreateOrder() {
}
// turn the draft into a real order
me.root.orders.push(draft as Loaded<typeof BubbleTeaOrder>);
me.root.orders.push(draft as BubbleTeaOrder);
// reset the draft
me.root.draft = DraftBubbleTeaOrder.create({
addOns: ListOfBubbleTeaAddOns.create([]),
addOns: [],
});
router.navigate("/");
@@ -60,7 +58,7 @@ function CreateOrderForm({
onSave,
}: {
id: string;
onSave: (draft: Loaded<typeof DraftBubbleTeaOrder>) => void;
onSave: (draft: DraftBubbleTeaOrder) => void;
}) {
const draft = useCoState(DraftBubbleTeaOrder, id, {
resolve: { addOns: true, instructions: true },

View File

@@ -1,4 +1,4 @@
import { CoPlainText, Loaded } from "jazz-tools";
import { CoPlainText } from "jazz-tools";
import {
BubbleTeaAddOnTypes,
BubbleTeaBaseTeaTypes,
@@ -10,7 +10,7 @@ export function OrderForm({
order,
onSave,
}: {
order: Loaded<typeof BubbleTeaOrder> | Loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
// Handles updates to the instructions field of the order.

View File

@@ -1,11 +1,6 @@
import { Loaded } from "jazz-tools";
import { BubbleTeaOrder } from "./schema.ts";
export function OrderThumbnail({
order,
}: {
order: Loaded<typeof BubbleTeaOrder>;
}) {
export function OrderThumbnail({ order }: { order: BubbleTeaOrder }) {
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
const date = deliveryDate.toLocaleDateString();

View File

@@ -1,4 +1,4 @@
import { Loaded, co, z } from "jazz-tools";
import { co, z } from "jazz-tools";
export const BubbleTeaAddOnTypes = [
"Pearl",
@@ -18,8 +18,9 @@ export const BubbleTeaBaseTeaTypes = [
export const ListOfBubbleTeaAddOns = co.list(
z.literal([...BubbleTeaAddOnTypes]),
);
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
function hasAddOnsChanges(list?: Loaded<typeof ListOfBubbleTeaAddOns> | null) {
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
return list && Object.entries(list._raw.insertions).length > 0;
}
@@ -30,16 +31,12 @@ export const BubbleTeaOrder = co.map({
withMilk: z.boolean(),
instructions: co.optional(co.plainText()),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
addOns: co.optional(ListOfBubbleTeaAddOns),
deliveryDate: z.optional(z.date()),
withMilk: z.optional(z.boolean()),
instructions: co.optional(co.plainText()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!order.baseTea) {
@@ -52,7 +49,7 @@ export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
return { errors };
}
export function hasChanges(order?: Loaded<typeof DraftBubbleTeaOrder> | null) {
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
return (
!!order &&
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
@@ -73,15 +70,9 @@ export const JazzAccount = co
})
.withMigration((account) => {
if (!account.root) {
const orders = co.list(BubbleTeaOrder).create([], account);
const draft = DraftBubbleTeaOrder.create(
{
addOns: ListOfBubbleTeaAddOns.create([], account),
instructions: co.plainText().create("", account),
},
account.root = AccountRoot.create(
{ draft: { addOns: [], instructions: "" }, orders: [] },
account,
);
account.root = AccountRoot.create({ draft, orders }, account);
}
});

View File

@@ -1,10 +1,24 @@
import ImageUpload from "./ImageUpload.tsx";
import ProfileImageComponent from "./ProfileImageComponent.tsx";
import ProfileImageImperative from "./ProfileImageImperative.tsx";
function App() {
return (
<>
<main className="max-w-3xl mx-auto px-3 py-16">
<ImageUpload />
<main className="max-w-6xl mx-auto px-3 py-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h2 className="text-xl font-semibold mb-4">Upload Image</h2>
<ImageUpload />
</div>
<div>
<h2>Profile Image - imperative way</h2>
<ProfileImageImperative />
<hr />
<h2>Profile Image - component</h2>
<ProfileImageComponent />
</div>
</div>
</main>
</>
);

View File

@@ -1,4 +1,5 @@
import { ProgressiveImg, createImage, useAccount } from "jazz-tools/react";
import { createImage } from "jazz-tools/media";
import { useAccount } from "jazz-tools/react";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { JazzAccount } from "./schema";
@@ -35,9 +36,14 @@ export default function ImageUpload() {
setImagePreviewUrl(objectUrl);
try {
const startTime = performance.now();
me.profile.image = await createImage(file, {
owner: me.profile._owner,
progressive: true,
placeholder: "blur",
});
const endTime = performance.now();
console.log(`Image upload took ${endTime - startTime} milliseconds`);
} catch (error) {
console.error("Error uploading image:", error);
} finally {
@@ -47,29 +53,6 @@ export default function ImageUpload() {
}
};
const deleteImage = () => {
if (!me?.profile) return;
me.profile.image = undefined;
};
if (me?.profile?.image) {
return (
<>
<ProgressiveImg image={me.profile.image as any /* TODO: fix this */}>
{({ src }) => <img alt="" src={src} className="w-full h-auto" />}
</ProgressiveImg>
<button
type="button"
onClick={deleteImage}
className="mt-5 bg-blue-600 text-white py-2 px-3 rounded"
>
Delete image
</button>
</>
);
}
if (imagePreviewUrl) {
return (
<div className="relative">

View File

@@ -0,0 +1,35 @@
import { Image, useAccount } from "jazz-tools/react";
import { JazzAccount } from "./schema";
export default function ProfileImage() {
const { me } = useAccount(JazzAccount, { resolve: { profile: true } });
const deleteImage = () => {
if (!me?.profile) return;
me.profile.image = undefined;
};
if (!me?.profile?.image) {
return (
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
<p className="text-gray-500">No profile image</p>
</div>
);
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Profile Image</h2>
<div className="border rounded-lg overflow-hidden">
<Image imageId={me.profile.image.id} alt="Profile" width={600} />
</div>
<button
type="button"
onClick={deleteImage}
className="bg-red-600 text-white py-2 px-3 rounded hover:bg-red-700"
>
Delete image
</button>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import {
highestResAvailable,
// loadImage,
// loadImageBySize,
} from "jazz-tools/media";
import { useAccount } from "jazz-tools/react";
import { useEffect, useState } from "react";
import { JazzAccount } from "./schema";
export default function ProfileImageImperative() {
const [image, setImage] = useState<string | undefined>(undefined);
const { me } = useAccount(JazzAccount, { resolve: { profile: true } });
useEffect(() => {
if (!me?.profile?.image) return;
// `loadImage` returns always the original image
// loadImage(me.profile.image).then((image) => {
// if(image === null) {
// console.error('Unable to load image');
// return;
// }
// console.log('loadImage', {w: image.width, h: image.height, ready: image.image.getChunks() ? 'ready' : 'not ready'});
// });
// `loadImageBySize` returns the best available image for the given size
// loadImageBySize(me.profile.image.id, 1024, 1024).then((image) => {
// if(image === null) {
// console.error('Unable to load image');
// return;
// }
// console.log('loadImageBySize', {w: image.width, h: image.height, ready: image.image.getChunks() ? 'ready' : 'not ready'});
// });
// keep it synced and return the best _loaded_ image for the given size
const unsub = me.profile.image.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 1024, 1024);
console.info(bestImage ? "Blob is ready" : "Blob is not ready");
if (bestImage) {
const blob = bestImage.image.toBlob();
if (blob) {
setImage(URL.createObjectURL(blob));
}
}
});
return () => {
unsub();
};
}, [me?.profile?.image]);
const deleteImage = () => {
if (!me?.profile) return;
me.profile.image = undefined;
};
if (!me?.profile?.image) {
return (
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
<p className="text-gray-500">No profile image</p>
</div>
);
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Profile Image</h2>
<div className="border rounded-lg overflow-hidden">
<img alt="Profile" src={image} className="w-full h-auto" />
</div>
<button
type="button"
onClick={deleteImage}
className="bg-red-600 text-white py-2 px-3 rounded hover:bg-red-700"
>
Delete image
</button>
</div>
);
}

View File

@@ -12,7 +12,11 @@ function Avatar({
name,
color,
active,
}: { name: string; color: string; active: boolean }) {
}: {
name: string;
color: string;
active: boolean;
}) {
return (
<span
title={name}

View File

@@ -47,7 +47,9 @@ button {
font-family: inherit;
background-color: transparent;
cursor: pointer;
transition: all 0.05s ease, border-color 0.1s ease;
transition:
all 0.05s ease,
border-color 0.1s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
button:hover {
@@ -93,8 +95,9 @@ button:active {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
rgba(0, 0, 0, 0.06);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
width: 28rem;
}

View File

@@ -22,7 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"lucide-react": "^0.536.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-router": "^6.16.0",
@@ -38,6 +38,7 @@
"postcss": "^8.4.27",
"tailwindcss": "^4.1.10",
"typescript": "5.6.2",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.2"
}
}

View File

@@ -84,15 +84,14 @@ export const MusicaAccount = co
* You can use it to set up the account root and any other initial CoValues you need.
*/
if (account.root === undefined) {
const tracks = co.list(MusicTrack).create([]);
const rootPlaylist = Playlist.create({
tracks,
tracks: [],
title: "",
});
account.root = MusicaAccountRoot.create({
rootPlaylist,
playlists: co.list(Playlist).create([]),
playlists: [],
activeTrack: undefined,
activePlaylist: rootPlaylist,
exampleDataLoaded: false,

View File

@@ -15,7 +15,7 @@ import { SidebarProvider } from "@/components/ui/sidebar";
import { JazzReactProvider } from "jazz-tools/react";
import { onAnonymousAccountDiscarded } from "./4_actions";
import { KeyboardListener } from "./components/PlayerControls";
import { useUploadExampleData } from "./lib/useUploadExampleData";
import { usePrepareAppState } from "./lib/usePrepareAppState";
/**
* Walkthrough: The top-level provider `<JazzReactProvider/>`
@@ -31,7 +31,7 @@ import { useUploadExampleData } from "./lib/useUploadExampleData";
function Main() {
const mediaPlayer = useMediaPlayer();
useUploadExampleData();
const isReady = usePrepareAppState(mediaPlayer);
const router = createHashRouter([
{
@@ -48,6 +48,8 @@ function Main() {
},
]);
if (!isReady) return null;
return (
<>
<RouterProvider router={router} />

View File

@@ -11,6 +11,7 @@ import { uploadMusicTracks } from "./4_actions";
import { MediaPlayer } from "./5_useMediaPlayer";
import { FileUploadButton } from "./components/FileUploadButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlayerControls } from "./components/PlayerControls";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { SidePanel } from "./components/SidePanel";
import { Button } from "./components/ui/button";
@@ -42,7 +43,11 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playlistId = params.playlistId ?? me?.root._refs.rootPlaylist.id;
const playlist = useCoState(Playlist, playlistId, {
resolve: { tracks: true },
resolve: {
tracks: {
$each: true,
},
},
});
const isRootPlaylist = !params.playlistId;
@@ -64,16 +69,16 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const isAuthenticated = useIsAuthenticated();
return (
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
<SidebarInset className="flex flex-col h-screen text-gray-800">
<div className="flex flex-1 overflow-hidden">
<SidePanel mediaPlayer={mediaPlayer} />
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden">
<SidebarTrigger />
<div className="flex items-center justify-between mb-6">
<SidePanel />
<main className="flex-1 px-2 py-4 md:px-6 overflow-y-auto overflow-x-hidden relative sm:h-[calc(100vh-80px)] bg-white h-[calc(100vh-165px)]">
<SidebarTrigger className="md:hidden" />
<div className="flex flex-row items-center justify-between mb-4 pl-1 md:pl-10 pr-2 md:pr-0 mt-2 md:mt-0 w-full">
{isRootPlaylist ? (
<h1 className="text-2xl font-bold text-blue-800">All tracks</h1>
) : (
<PlaylistTitleInput playlistId={playlistId} />
<PlaylistTitleInput className="w-full" playlistId={playlistId} />
)}
<div className="flex items-center space-x-4">
{isRootPlaylist && (
@@ -90,14 +95,14 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
)}
</div>
</div>
<ul className="flex flex-col max-w-full">
<ul className="flex flex-col max-w-full sm:gap-1">
{playlist?.tracks?.map(
(track) =>
(track, index) =>
track && (
<MusicTrackRow
trackId={track.id}
key={track.id}
isLoading={mediaPlayer.loading === track.id}
index={index}
isPlaying={
mediaPlayer.activeTrackId === track.id &&
isActivePlaylist &&
@@ -106,12 +111,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
onClick={() => {
mediaPlayer.setActiveTrack(track, playlist);
}}
showAddToPlaylist={isRootPlaylist}
/>
),
)}
</ul>
</main>
<PlayerControls mediaPlayer={mediaPlayer} />
</div>
</SidebarInset>
);

View File

@@ -1,11 +1,6 @@
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
import { FileStream, Group, co } from "jazz-tools";
import {
MusicTrack,
MusicTrackWaveform,
MusicaAccount,
Playlist,
} from "./1_schema";
import { FileStream, Group } from "jazz-tools";
import { MusicTrack, MusicaAccount, Playlist } from "./1_schema";
/**
* Walkthrough: Actions
@@ -51,7 +46,7 @@ export async function uploadMusicTracks(
{
file: fileStream,
duration: data.duration,
waveform: MusicTrackWaveform.create({ data: data.waveform }, group),
waveform: { data: data.waveform },
title: file.name,
isExampleTrack,
},
@@ -73,18 +68,10 @@ export async function createNewPlaylist() {
},
});
// Since playlists are meant to be shared we associate them
// to a group which will contain the keys required to get
// access to the "owned" values
const playlistGroup = Group.create();
const playlist = Playlist.create(
{
title: "New Playlist",
tracks: co.list(MusicTrack).create([], playlistGroup),
},
playlistGroup,
);
const playlist = Playlist.create({
title: "New Playlist",
tracks: [],
});
// Again, we associate the new playlist to the
// user by pushing it into the playlists CoList
@@ -129,7 +116,7 @@ export async function removeTrackFromPlaylist(
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
const trackGroup = track._owner;
await trackGroup.removeMember(playlist._owner);
trackGroup.removeMember(playlist._owner);
const index =
playlist.tracks?.findIndex(

View File

@@ -5,6 +5,7 @@ import { FileStream } from "jazz-tools";
import { useAccount } from "jazz-tools/react";
import { useRef, useState } from "react";
import { updateActivePlaylist, updateActiveTrack } from "./4_actions";
import { useAudioManager } from "./lib/audio/AudioManager";
import { getNextTrack, getPrevTrack } from "./lib/getters";
export function useMediaPlayer() {
@@ -12,6 +13,7 @@ export function useMediaPlayer() {
resolve: { root: true },
});
const audioManager = useAudioManager();
const playState = usePlayState();
const playMedia = usePlayMedia();
@@ -24,8 +26,10 @@ export function useMediaPlayer() {
async function loadTrack(track: MusicTrack) {
lastLoadedTrackId.current = track.id;
audioManager.unloadCurrentAudio();
setLoading(track.id);
updateActiveTrack(track);
const file = await FileStream.loadAsBlob(track._refs.file!.id); // TODO: see if we can avoid !
@@ -40,8 +44,6 @@ export function useMediaPlayer() {
return;
}
updateActiveTrack(track);
await playMedia(file);
setLoading(null);
@@ -85,6 +87,7 @@ export function useMediaPlayer() {
playNextTrack,
playPrevTrack,
loading,
loadTrack,
};
}

View File

@@ -0,0 +1,59 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ConfirmDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
variant?: "default" | "destructive";
}
export function ConfirmDialog({
isOpen,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
variant = "destructive",
}: ConfirmDialogProps) {
function handleConfirm() {
onConfirm();
onOpenChange(false);
}
function handleCancel() {
onOpenChange(false);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{cancelText}
</Button>
<Button variant={variant} onClick={handleConfirm}>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,31 +9,41 @@ import {
import { cn } from "@/lib/utils";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { MoreHorizontal } from "lucide-react";
import { Fragment } from "react/jsx-runtime";
import { MusicTrackTitleInput } from "./MusicTrackTitleInput";
import { MoreHorizontal, Pause, Play } from "lucide-react";
import { Fragment, useCallback, useState } from "react";
import { EditTrackDialog } from "./RenameTrackDialog";
import { Waveform } from "./Waveform";
import { Button } from "./ui/button";
function isPartOfThePlaylist(
trackId: string,
playlist: Loaded<typeof Playlist, { tracks: true }>,
) {
return Array.from(playlist.tracks._refs).some((t) => t.id === trackId);
}
export function MusicTrackRow({
trackId,
isLoading,
isPlaying,
onClick,
showAddToPlaylist,
index,
}: {
trackId: string;
isLoading: boolean;
isPlaying: boolean;
onClick: (track: Loaded<typeof MusicTrack>) => void;
showAddToPlaylist: boolean;
index: number;
}) {
const track = useCoState(MusicTrack, trackId);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
resolve: { root: { playlists: { $each: { tracks: true } } } },
});
const playlists = me?.root.playlists ?? [];
const isActiveTrack = trackId === me?.root._refs.activeTrack?.id;
function handleTrackClick() {
if (!track) return;
@@ -60,71 +70,118 @@ export function MusicTrackRow({
}
}
function handleEdit() {
setIsEditDialogOpen(true);
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDropdownOpen(true);
}, []);
const showWaveform = isHovered || isActiveTrack;
return (
<li
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
onClick={handleTrackClick}
className={cn(
"flex gap-1 hover:bg-slate-200 group py-2 cursor-pointer rounded-lg",
isActiveTrack && "bg-slate-200",
)}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
>
<button
className={cn(
"flex items-center justify-center bg-transparent w-8 h-8 ",
!isPlaying && "group-hover:bg-slate-300 rounded-full",
"flex items-center justify-center bg-transparent w-8 h-8 transition-opacity cursor-pointer",
// Show play button on hover or when active, hide otherwise
"md:opacity-0 opacity-50 group-hover:opacity-100",
isActiveTrack && "md:opacity-100 opacity-100",
)}
onClick={handleTrackClick}
aria-label={`${isPlaying ? "Pause" : "Play"} ${track?.title}`}
>
{isLoading ? (
<div className="animate-spin">߷</div>
) : isPlaying ? (
"⏸️"
{isPlaying ? (
<Pause height={16} width={16} fill="currentColor" />
) : (
"▶️"
<Play height={16} width={16} fill="currentColor" />
)}
</button>
<MusicTrackTitleInput trackId={trackId} />
{/* Show track index when play button is hidden - hidden on mobile */}
<div
className={cn(
"hidden md:flex items-center justify-center w-8 h-8 text-sm text-gray-500 font-mono transition-opacity",
)}
>
{index + 1}
</div>
<button
onContextMenu={handleContextMenu}
onClick={handleTrackClick}
className="flex items-center overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer flex-1 min-w-0"
>
{track?.title}
</button>
{/* Waveform that appears on hover */}
{track && showWaveform && (
<div className="flex-1 min-w-0 px-2 items-center hidden md:flex">
<Waveform
track={track}
height={20}
className="opacity-70 w-full"
showProgress={isActiveTrack}
/>
</div>
)}
<div onClick={(evt) => evt.stopPropagation()}>
{showAddToPlaylist && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
key={`delete`}
onSelect={async () => {
if (!track) return;
deleteTrack();
}}
>
Delete
</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
{playlists.map((playlist, playlistIndex) => (
<Fragment key={playlistIndex}>
{isPartOfThePlaylist(trackId, playlist) ? (
<DropdownMenuItem
key={`add-${index}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
<DropdownMenuItem
key={`remove-${index}`}
key={`remove-${playlistIndex}`}
onSelect={() => handleRemoveFromPlaylist(playlist)}
>
Remove from {playlist.title}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
) : (
<DropdownMenuItem
key={`add-${playlistIndex}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
)}
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{track && isEditDialogOpen && (
<EditTrackDialog
track={track}
isOpen={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onDelete={deleteTrack}
/>
)}
</li>
);
}

View File

@@ -5,7 +5,8 @@ import { usePlayState } from "@/lib/audio/usePlayState";
import { useKeyboardListener } from "@/lib/useKeyboardListener";
import { useAccount, useCoState } from "jazz-tools/react";
import { Pause, Play, SkipBack, SkipForward } from "lucide-react";
import { Waveform } from "./Waveform";
import WaveformCanvas from "./WaveformCanvas";
import { Button } from "./ui/button";
export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playState = usePlayState();
@@ -15,51 +16,61 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
resolve: { root: { activePlaylist: true } },
}).me?.root.activePlaylist;
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
resolve: { waveform: true },
});
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId);
if (!activeTrack) return null;
const activeTrackTitle = activeTrack.title;
return (
<footer className="flex items-center justify-between p-4 gap-4 bg-white border-t border-gray-200 fixed bottom-0 left-0 right-0 w-full">
<div className="flex justify-center items-center space-x-2">
<div className="flex items-center space-x-4">
<button
<footer className="flex flex-wrap sm:flex-nowrap items-center justify-between pt-4 p-2 sm:p-4 gap-4 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
{/* Player Controls - Always on top */}
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0 w-full sm:w-auto order-1 sm:order-none">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
onClick={mediaPlayer.playPrevTrack}
className="text-blue-600 hover:text-blue-800"
aria-label="Previous track"
>
<SkipBack size={20} />
</button>
<button
<SkipBack className="h-5 w-5" fill="currentColor" />
</Button>
<Button
size="icon"
onClick={playState.toggle}
className="w-[42px] h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
className="bg-blue-600 text-white hover:bg-blue-700"
aria-label={isPlaying ? "Pause active track" : "Play active track"}
>
{isPlaying ? (
<Pause size={24} fill="currentColor" />
<Pause className="h-5 w-5" fill="currentColor" />
) : (
<Play size={24} fill="currentColor" />
<Play className="h-5 w-5" fill="currentColor" />
)}
</button>
<button
</Button>
<Button
variant="ghost"
size="icon"
onClick={mediaPlayer.playNextTrack}
className="text-blue-600 hover:text-blue-800"
aria-label="Next track"
>
<SkipForward size={20} />
</button>
<SkipForward className="h-5 w-5" fill="currentColor" />
</Button>
</div>
</div>
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
<Waveform track={activeTrack} height={30} />
</div>
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
<h4 className="font-medium text-blue-800">{activeTrackTitle}</h4>
<p className="text-sm text-gray-600">
{/* Waveform - Below controls on mobile, between controls and info on desktop */}
<WaveformCanvas
className="order-1 sm:order-none"
track={activeTrack}
height={50}
/>
{/* Track Info - Below waveform on mobile, on the right on desktop */}
<div className="flex flex-col gap-1 min-w-fit sm:flex-shrink-0 text-center w-full sm:text-right items-center sm:items-end sm:w-auto order-0 sm:order-none">
<h4 className="font-medium text-blue-800 text-base sm:text-base truncate max-w-80 sm:max-w-80">
{activeTrackTitle}
</h4>
<p className="hidden sm:block text-xs sm:text-sm text-gray-600 truncate sm:max-w-80">
{activePlaylist?.title || "All tracks"}
</p>
</div>
@@ -69,7 +80,9 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
export function KeyboardListener({
mediaPlayer,
}: { mediaPlayer: MediaPlayer }) {
}: {
mediaPlayer: MediaPlayer;
}) {
const playState = usePlayState();
useMediaEndListener(mediaPlayer.playNextTrack);

View File

@@ -1,12 +1,15 @@
import { Playlist } from "@/1_schema";
import { updatePlaylistTitle } from "@/4_actions";
import { cn } from "@/lib/utils";
import { useCoState } from "jazz-tools/react";
import { ChangeEvent, useState } from "react";
export function PlaylistTitleInput({
playlistId,
className,
}: {
playlistId: string | undefined;
className?: string;
}) {
const playlist = useCoState(Playlist, playlistId);
const [isEditing, setIsEditing] = useState(false);
@@ -33,7 +36,10 @@ export function PlaylistTitleInput({
<input
value={inputValue}
onChange={handleTitleChange}
className="text-2xl font-bold text-blue-800 bg-transparent"
className={cn(
"text-2xl font-bold text-blue-800 bg-transparent",
className,
)}
onFocus={handleFoucsIn}
onBlur={handleFocusOut}
aria-label={`Playlist title`}

View File

@@ -0,0 +1,108 @@
import { MusicTrack } from "@/1_schema";
import { updateMusicTrackTitle } from "@/4_actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Loaded } from "jazz-tools";
import { useState } from "react";
import { ConfirmDialog } from "./ConfirmDialog";
interface EditTrackDialogProps {
track: Loaded<typeof MusicTrack>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onDelete: () => void;
}
export function EditTrackDialog({
track,
isOpen,
onOpenChange,
onDelete,
}: EditTrackDialogProps) {
const [newTitle, setNewTitle] = useState(track.title);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
function handleSave() {
if (track && newTitle.trim()) {
updateMusicTrackTitle(track, newTitle.trim());
onOpenChange(false);
}
}
function handleCancel() {
setNewTitle(track?.title || "");
onOpenChange(false);
}
function handleDeleteClick() {
setIsDeleteConfirmOpen(true);
}
function handleDeleteConfirm() {
onDelete();
onOpenChange(false);
}
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Enter") {
handleSave();
} else if (event.key === "Escape") {
handleCancel();
}
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Track</DialogTitle>
<DialogDescription>Edit "{track?.title}".</DialogDescription>
</DialogHeader>
<form className="py-4" onSubmit={handleSave}>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter track name..."
autoFocus
/>
</form>
<DialogFooter className="flex justify-between">
<Button
variant="destructive"
onClick={handleDeleteClick}
className="mr-auto"
>
Delete Track
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!newTitle.trim()}>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
<ConfirmDialog
isOpen={isDeleteConfirmOpen}
onOpenChange={setIsDeleteConfirmOpen}
title="Delete Track"
description={`Are you sure you want to delete "${track.title}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteConfirm}
variant="destructive"
/>
</Dialog>
);
}

View File

@@ -1,10 +1,8 @@
import { MusicTrack, MusicaAccount } from "@/1_schema";
import { MusicaAccount } from "@/1_schema";
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
import { MediaPlayer } from "@/5_useMediaPlayer";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
@@ -14,22 +12,18 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { usePlayState } from "@/lib/audio/usePlayState";
import { useAccount, useCoState } from "jazz-tools/react";
import { Home, Music, Pause, Play, Plus, Trash2 } from "lucide-react";
import { useAccount } from "jazz-tools/react";
import { Home, Music, Plus, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { AuthButton } from "./AuthButton";
export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
export function SidePanel() {
const { playlistId } = useParams();
const navigate = useNavigate();
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
});
const playState = usePlayState();
const isPlaying = playState.value === "play";
function handleAllTracksClick() {
navigate(`/`);
}
@@ -50,12 +44,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
navigate(`/playlist/${playlist.id}`);
}
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
resolve: { waveform: true },
});
const activeTrackTitle = activeTrack?.title;
return (
<Sidebar>
<SidebarHeader>
@@ -137,29 +125,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{activeTrack && (
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem className="flex justify-end">
<SidebarMenuButton
onClick={playState.toggle}
aria-label={
isPlaying ? "Pause active track" : "Play active track"
}
>
<div className="w-[28px] h-[28px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700">
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" />
)}
</div>
<span>{activeTrackTitle}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
)}
</Sidebar>
);
}

View File

@@ -7,6 +7,8 @@ import { useCoState } from "jazz-tools/react";
export function Waveform(props: {
track: Loaded<typeof MusicTrack>;
height: number;
className?: string;
showProgress?: boolean;
}) {
const { track, height } = props;
const waveformData = useCoState(
@@ -28,29 +30,24 @@ export function Waveform(props: {
}
const barCount = waveformData.length;
const activeBar = Math.ceil(barCount * (currentTime.value / duration));
function seek(i: number) {
currentTime.setValue((i / barCount) * duration);
}
const activeBar = props.showProgress
? Math.ceil(barCount * (currentTime.value / duration))
: -1;
return (
<div
className="flex justify-center items-end w-full"
className={cn("flex justify-center items-end w-full", props.className)}
style={{
height,
gap: 1,
}}
>
{waveformData.map((value, i) => (
<button
type="button"
key={i}
onClick={() => seek(i)}
className={cn(
"w-1 transition-colors rounded-none rounded-t-lg min-h-1",
activeBar >= i ? "bg-gray-500" : "bg-gray-300",
"hover:bg-black hover:border hover:border-solid hover:border-black",
activeBar >= i ? "bg-gray-800" : "bg-gray-400",
"focus-visible:outline-black focus:outline-hidden",
)}
style={{

View File

@@ -0,0 +1,282 @@
"use client";
import { MusicTrack, MusicTrackWaveform } from "@/1_schema";
import { AudioManager, useAudioManager } from "@/lib/audio/AudioManager";
import {
getPlayerCurrentTime,
setPlayerCurrentTime,
subscribeToPlayerCurrentTime,
usePlayerCurrentTime,
} from "@/lib/audio/usePlayerCurrentTime";
import { cn } from "@/lib/utils";
import { Loaded } from "jazz-tools";
import type React from "react";
import { useEffect, useRef } from "react";
type Props = {
track: Loaded<typeof MusicTrack>;
height?: number;
barColor?: string;
progressColor?: string;
backgroundColor?: string;
className?: string;
};
const DEFAULT_HEIGHT = 96;
// Downsample PCM into N peaks (abs max in window)
function buildPeaks(channelData: number[], samples: number): Float32Array {
const length = channelData.length;
if (channelData.length < samples) {
// Create a peaks array that interpolates the channelData
const interpolatedPeaks = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const index = Math.floor(i * (length / samples));
interpolatedPeaks[i] = channelData[index];
}
return interpolatedPeaks;
}
const blockSize = Math.floor(length / samples);
const peaks = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const start = i * blockSize;
let end = start + blockSize;
if (end > length) end = length;
let max = 0;
for (let j = start; j < end; j++) {
const v = Math.abs(channelData[j]);
if (v > max) max = v;
}
peaks[i] = max;
}
return peaks;
}
type DrawWaveformCanvasProps = {
canvas: HTMLCanvasElement;
waveformData: number[] | undefined;
duration: number;
currentTime: number;
barColor?: string;
progressColor?: string;
backgroundColor?: string;
isAnimating: boolean;
animationProgress: number;
progress: number;
};
function drawWaveform(props: DrawWaveformCanvasProps) {
const {
canvas,
waveformData,
isAnimating,
animationProgress,
barColor = "hsl(215, 16%, 47%)",
progressColor = "hsl(142, 71%, 45%)",
backgroundColor = "transparent",
progress,
} = props;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.clientWidth;
const cssHeight = canvas.clientHeight;
canvas.width = Math.floor(cssWidth * dpr);
canvas.height = Math.floor(cssHeight * dpr);
ctx.scale(dpr, dpr);
// Background
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, cssWidth, cssHeight);
if (!waveformData || !waveformData.length) {
// Draw placeholder line
ctx.strokeStyle = "hsl(215, 20%, 65%)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, cssHeight / 2);
ctx.lineTo(cssWidth, cssHeight / 2);
ctx.stroke();
return;
}
const midY = cssHeight / 2;
const barWidth = 2; // px
const gap = 1;
const totalBars = Math.floor(cssWidth / (barWidth + gap));
const ds = buildPeaks(waveformData, totalBars);
const draw = (color: string, untilBar: number, start = 0) => {
ctx.fillStyle = color;
for (let i = start; i < untilBar; i++) {
const v = ds[i] || 0;
const h = Math.max(2, v * (cssHeight - 8)); // margin
const x = i * (barWidth + gap);
// Apply staggered animation
if (isAnimating) {
const barProgress = Math.max(0, Math.min(1, animationProgress / 0.2));
const animatedHeight = h * barProgress;
ctx.globalAlpha = barProgress;
ctx.fillRect(x, midY - animatedHeight / 2, barWidth, animatedHeight);
} else {
ctx.fillRect(x, midY - h / 2, barWidth, h);
}
}
};
// Progress overlay
const progressBars = Math.floor(
totalBars * Math.max(0, Math.min(1, progress || 0)),
);
draw(progressColor, progressBars);
// Base waveform
draw(barColor, totalBars, progressBars);
}
type WaveformCanvasProps = {
audioManager: AudioManager;
canvas: HTMLCanvasElement;
waveformId: string;
duration: number;
barColor?: string;
progressColor?: string;
backgroundColor?: string;
};
async function renderWaveform(props: WaveformCanvasProps) {
const { audioManager, canvas, waveformId, duration } = props;
let mounted = true;
let currentTime = getPlayerCurrentTime(audioManager);
let waveformData: undefined | number[] = undefined;
let isAnimating = true;
const startTime = performance.now();
let animationProgress = 0;
const animationDuration = 800;
function draw() {
const progress = currentTime / duration;
drawWaveform({
canvas,
waveformData,
duration,
currentTime,
isAnimating,
animationProgress,
progress,
});
}
const animate = (currentTime: number) => {
if (!mounted) return;
const elapsed = currentTime - startTime;
animationProgress = Math.min(elapsed / animationDuration, 1);
if (animationProgress < 1) {
requestAnimationFrame(animate);
} else {
isAnimating = false;
}
draw();
};
requestAnimationFrame(animate);
const unsubscribeFromCurrentTime = subscribeToPlayerCurrentTime(
audioManager,
(time) => {
currentTime = time;
draw();
},
);
const unsubscribeFromWaveform = MusicTrackWaveform.subscribe(
waveformId,
{},
(newResult) => {
waveformData = newResult.data;
draw();
},
);
return () => {
mounted = false;
unsubscribeFromCurrentTime();
unsubscribeFromWaveform();
};
}
export default function WaveformCanvas({
track,
height = DEFAULT_HEIGHT,
barColor, // muted-foreground-ish
progressColor, // green
backgroundColor,
className,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const audioManager = useAudioManager();
const duration = track.duration;
const waveformId = track._refs.waveform?.id;
// Animation effect
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (!waveformId) return;
renderWaveform({
audioManager,
canvas,
waveformId,
duration,
barColor,
progressColor,
backgroundColor,
});
}, [audioManager, canvasRef, waveformId, duration]);
const onPointer = (e: React.PointerEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
const time = Math.max(0, Math.min(1, ratio)) * duration;
setPlayerCurrentTime(audioManager, time);
};
const currentTime = usePlayerCurrentTime();
const progress = currentTime.value / duration;
return (
<div className={cn("w-full", className)}>
<div
className="w-full rounded-md bg-background"
style={{ height }}
role="slider"
aria-label="Waveform scrubber"
aria-valuenow={Math.round((progress || 0) * 100)}
aria-valuemin={0}
aria-valuemax={100}
>
<canvas
ref={canvasRef}
className="w-full h-full rounded-md cursor-pointer"
onPointerDown={onPointer}
onPointerMove={(e) => {
if (e.buttons === 1) onPointer(e);
}}
/>
</div>
</div>
);
}

View File

@@ -532,7 +532,8 @@ const sidebarMenuButtonVariants = cva(
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default:
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},

View File

@@ -2,6 +2,12 @@
@custom-variant dark (&:is(.dark *));
html {
overflow: hidden;
position: relative;
background-color: hsl(0 0% 99%);
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(20 14.3% 4.1%);

View File

@@ -15,6 +15,7 @@ export class AudioManager {
if (this.audioObjectURL) {
URL.revokeObjectURL(this.audioObjectURL);
this.audioObjectURL = null;
this.mediaElement.src = "";
}
}

View File

@@ -1,22 +1,14 @@
import { useLayoutEffect, useState } from "react";
import { useAudioManager } from "./AudioManager";
import { AudioManager, useAudioManager } from "./AudioManager";
export function usePlayerCurrentTime() {
const audioManager = useAudioManager();
const [value, setValue] = useState<number>(0);
useLayoutEffect(() => {
setValue(audioManager.mediaElement.currentTime);
setValue(getPlayerCurrentTime(audioManager));
const onTimeUpdate = () => {
setValue(audioManager.mediaElement.currentTime);
};
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
return () => {
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
};
return subscribeToPlayerCurrentTime(audioManager, setValue);
}, [audioManager]);
function setCurrentTime(time: number) {
@@ -31,3 +23,26 @@ export function usePlayerCurrentTime() {
setValue: setCurrentTime,
};
}
export function setPlayerCurrentTime(audioManager: AudioManager, time: number) {
audioManager.mediaElement.currentTime = time;
}
export function getPlayerCurrentTime(audioManager: AudioManager): number {
return audioManager.mediaElement.currentTime;
}
export function subscribeToPlayerCurrentTime(
audioManager: AudioManager,
callback: (time: number) => void,
) {
const onTimeUpdate = () => {
callback(audioManager.mediaElement.currentTime);
};
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
return () => {
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
};
}

View File

@@ -0,0 +1,54 @@
import { MusicaAccount, MusicaAccountRoot } from "@/1_schema";
import { MediaPlayer } from "@/5_useMediaPlayer";
import { co } from "jazz-tools";
import { useAccount } from "jazz-tools/react";
import { useEffect, useState } from "react";
import { uploadMusicTracks } from "../4_actions";
export function usePrepareAppState(mediaPlayer: MediaPlayer) {
const [isReady, setIsReady] = useState(false);
const { agent } = useAccount();
useEffect(() => {
loadInitialData(mediaPlayer).then(() => {
setIsReady(true);
});
}, [agent]);
return isReady;
}
async function loadInitialData(mediaPlayer: MediaPlayer) {
const me = await MusicaAccount.getMe().ensureLoaded({
resolve: {
root: {
rootPlaylist: { tracks: { $each: true } },
activeTrack: true,
activePlaylist: true,
},
},
});
uploadOnboardingData(me.root);
// Load the active track in the AudioManager
if (me.root.activeTrack) {
mediaPlayer.loadTrack(me.root.activeTrack);
}
}
async function uploadOnboardingData(root: co.loaded<typeof MusicaAccountRoot>) {
if (root.exampleDataLoaded) return;
root.exampleDataLoaded = true;
try {
const trackFile = await (await fetch("/example.mp3")).blob();
await uploadMusicTracks([new File([trackFile], "Example song")], true);
} catch (error) {
root.exampleDataLoaded = false;
throw error;
}
}

View File

@@ -1,31 +0,0 @@
import { MusicaAccount } from "@/1_schema";
import { useAccount } from "jazz-tools/react";
import { useEffect } from "react";
import { uploadMusicTracks } from "../4_actions";
export function useUploadExampleData() {
const { agent } = useAccount();
useEffect(() => {
uploadOnboardingData();
}, [agent]);
}
async function uploadOnboardingData() {
const me = await MusicaAccount.getMe().ensureLoaded({
resolve: { root: true },
});
if (me.root.exampleDataLoaded) return;
me.root.exampleDataLoaded = true;
try {
const trackFile = await (await fetch("/example.mp3")).blob();
await uploadMusicTracks([new File([trackFile], "Example song")], true);
} catch (error) {
me.root.exampleDataLoaded = false;
throw error;
}
}

View File

@@ -55,10 +55,20 @@ export class HomePage {
async editTrackTitle(trackTitle: string, newTitle: string) {
await this.page
.getByRole("textbox", {
name: `Edit track title: ${trackTitle}`,
.getByRole("button", {
name: `Open ${trackTitle} menu`,
})
.fill(newTitle);
.click();
await this.page
.getByRole("menuitem", {
name: `Edit`,
})
.click();
await this.page.getByPlaceholder("Enter track name...").fill(newTitle);
await this.page.getByRole("button", { name: "Save" }).click();
}
async createPlaylist() {

View File

@@ -1,10 +1,21 @@
import path from "path";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
strategies: "generateSW",
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
maximumFileSizeToCacheInBytes: 1024 * 1024 * 5,
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View File

@@ -13,7 +13,7 @@
},
"dependencies": {
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"lucide-react": "^0.536.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-router": "^6.16.0",

View File

@@ -6,7 +6,9 @@ import { Organization } from "../schema.ts";
export function InviteLink({
organization,
}: { organization: Loaded<typeof Organization> }) {
}: {
organization: Loaded<typeof Organization>;
}) {
let [copyCount, setCopyCount] = useState(0);
let copied = copyCount > 0;

View File

@@ -4,7 +4,9 @@ import { Organization } from "../schema.ts";
export function OrganizationMembers({
organization,
}: { organization: Loaded<typeof Organization> }) {
}: {
organization: Loaded<typeof Organization>;
}) {
const group = organization._owner.castAs(Group);
return (
@@ -25,7 +27,11 @@ function MemberItem({
account,
role,
group,
}: { account: Account; role: string; group: Group }) {
}: {
account: Account;
role: string;
group: Group;
}) {
const { me } = useAccount();
const canRemoveMember = group.myRole() === "admin" && account.id !== me?.id;

View File

@@ -79,8 +79,9 @@ main {
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
rgba(0, 0, 0, 0.06);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
width: 28rem;
}

View File

@@ -35,7 +35,9 @@ export function ReactionsScreen(props: { id: string }) {
const ReactionButtons = ({
reactions,
}: { reactions: Loaded<typeof Reactions> }) => (
}: {
reactions: Loaded<typeof Reactions>;
}) => (
<div className="reaction-buttons">
{ReactionTypes.map((reactionType) => (
<button
@@ -56,7 +58,9 @@ const ReactionButtons = ({
const ReactionOverview = ({
reactions,
}: { reactions: Loaded<typeof Reactions> }) => (
}: {
reactions: Loaded<typeof Reactions>;
}) => (
<>
{Object.values(reactions.perAccount).map((reaction) => (
<div key={reaction.by?.id} className="reaction-row">

View File

@@ -17,7 +17,7 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<JazzReactProvider
sync={{
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
peer: `ws://localhost:4200/?key=${apiKey}`,
}}
AccountSchema={JazzAccount}
>

View File

@@ -16,7 +16,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"lucide-react": "^0.536.0",
"qrcode": "^1.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",

View File

@@ -2,11 +2,7 @@
import { JazzReactProvider } from "jazz-tools/react";
export default function CovaluesLayout({
children,
}: {
children: any;
}) {
export default function CovaluesLayout({ children }: { children: any }) {
return (
<JazzReactProvider sync={{ when: "never" }}>{children}</JazzReactProvider>
);

View File

@@ -3,7 +3,10 @@ import { clsx } from "clsx";
export function Card({
children,
className,
}: { children: React.ReactNode; className?: string }) {
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={clsx(className, "border rounded-xl shadow-sm")}>
{children}

View File

@@ -4,7 +4,11 @@ export function Label({
label,
htmlFor,
className,
}: { label: string; htmlFor: string; className?: string }) {
}: {
label: string;
htmlFor: string;
className?: string;
}) {
return (
<LabelRadix.Root className={className} htmlFor={htmlFor}>
{label}

View File

@@ -4,7 +4,11 @@ export function IconCoFeed({
className,
size,
strokeWidth,
}: { className?: string; size?: number; strokeWidth: number }) {
}: {
className?: string;
size?: number;
strokeWidth: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -4,7 +4,12 @@ export function IconCoRecord({
className,
size,
strokeWidth,
}: { className?: string; size?: number; color?: string; strokeWidth: number }) {
}: {
className?: string;
size?: number;
color?: string;
strokeWidth: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -4,7 +4,11 @@ export function JazzLogo({
className,
width = undefined,
height = undefined,
}: { className?: string; width?: number; height?: number }) {
}: {
className?: string;
width?: number;
height?: number;
}) {
return (
<svg
viewBox="0 0 386 146"

View File

@@ -93,7 +93,11 @@ const TableDataContainer = ({
children,
className,
isCopyable,
}: { children: React.ReactNode; className?: string; isCopyable?: boolean }) => {
}: {
children: React.ReactNode;
className?: string;
isCopyable?: boolean;
}) => {
return (
<div
className={clsx("flex gap-2", className, isCopyable && "cursor-pointer")}

View File

@@ -247,7 +247,13 @@ data-lsp {
}
.tag-container .twoslash-annotation {
position: absolute;
font-family: "JetBrains Mono", Menlo, Monaco, Consolas, Courier New, monospace;
font-family:
"JetBrains Mono",
Menlo,
Monaco,
Consolas,
Courier New,
monospace;
right: -10px;
/** Default annotation text to 200px */
width: 200px;

View File

@@ -6,7 +6,9 @@ import Router from "next/router";
export default async function TeamMemberPage({
params,
}: { params: Promise<{ member: string }> }) {
}: {
params: Promise<{ member: string }>;
}) {
const { member } = await params;
const memberInfo = team.find((m) => m.slug === member);

View File

@@ -54,10 +54,10 @@ import { co, z, CoMap } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
```
</CodeGroup>
@@ -73,17 +73,17 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
// ---cut---
// OrderForm.tsx
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -118,16 +118,16 @@ import * as React from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -177,10 +177,10 @@ import { useState, useEffect } from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -218,7 +218,7 @@ export function OrderForm({
// CreateOrder.tsx
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
@@ -228,7 +228,7 @@ export function CreateOrder() {
e.preventDefault();
if (!draft || !draft.name) return;
const order = draft as co.loaded<typeof BubbleTeaOrder>; // TODO: this should narrow correctly
const order = draft as BubbleTeaOrder; // TODO: this should narrow correctly
console.log("Order created:", order);
};
@@ -251,11 +251,15 @@ Update the schema to include a `validateDraftOrder` helper.
import { co, z } from "jazz-tools";
// ---cut---
// schema.ts
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:9]
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: DraftBubbleTeaOrder) { // [!code ++:9]
const errors: string[] = [];
if (!draft.name) {
@@ -279,12 +283,12 @@ import { useState, useEffect } from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -307,7 +311,7 @@ export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -330,7 +334,7 @@ export function OrderForm({
// CreateOrder.tsx
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
@@ -346,7 +350,7 @@ export function CreateOrder() {
return;
}
const order = draft as co.loaded<typeof BubbleTeaOrder>;
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
};
@@ -372,10 +376,10 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({ // [!code ++:15]
draft: DraftBubbleTeaOrder,
@@ -403,10 +407,10 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -452,12 +456,12 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -492,7 +496,7 @@ export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -533,7 +537,7 @@ export function CreateOrder() {
return;
}
const order = draft as co.loaded<typeof BubbleTeaOrder>;
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
// create a new empty draft
@@ -577,11 +581,15 @@ Simply add a `hasChanges` helper to your schema.
import { co, z } from "jazz-tools";
// ---cut---
// schema.ts
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -591,7 +599,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
return { errors };
};
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) { // [!code ++:3]
export function hasChanges(draft?: DraftBubbleTeaOrder) { // [!code ++:3]
return draft ? Object.keys(draft._edits).length : false;
};
```
@@ -608,12 +616,12 @@ import * as React from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!draft.name) {
@@ -623,7 +631,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
return { errors };
};
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) {
export function hasChanges(draft?: DraftBubbleTeaOrder) {
return draft ? Object.keys(draft._edits).length : false;
};

View File

@@ -109,6 +109,11 @@ export const docNavigationItems = [
// collapse: true,
prefix: "/docs/upgrade",
items: [
{
name: "0.17.0 - New image APIs",
href: "/docs/upgrade/0-17-0",
done: 100,
},
{
name: "0.16.0 - Cleaner separation between Zod and CoValue schemas",
href: "/docs/upgrade/0-16-0",
@@ -230,6 +235,7 @@ export const docNavigationItems = [
"react-native": 100,
"react-native-expo": 100,
vanilla: 100,
svelte: 100,
},
},
{

View File

@@ -205,6 +205,101 @@ console.log(containingGroup.getParentGroups()); // [addedGroup]
```
</CodeGroup>
## Group hierarchy on CoValue creation
When creating CoValues that contain other CoValues using plain JSON objects, Jazz not only creates
the necessary CoValues automatically but it will also manage their group ownership.
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
// ---cut---
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
const board = Board.create({
title: "My board",
columns: [
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
],
});
```
</CodeGroup>
For each created column and task CoValue, Jazz also creates a new group as its owner and
adds the referencing CoValue's owner as a member of that group. This means permissions for nested CoValues
are inherited from the CoValue that references them, but can also be modified independently for each CoValue
if needed.
<CodeGroup>
```ts twoslash
import { co, z, Group, Account } from "jazz-tools";
const alice = {} as unknown as Account;
const bob = {} as unknown as Account;
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
// ---cut---
const writeAccess = Group.create();
writeAccess.addMember(bob, "writer");
// Give Bob write access to the board, columns and tasks
const board = Board.create({
title: "My board",
columns: [
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
],
}, writeAccess);
// Give Alice read access to one specific task
const task = board.columns[0][0];
const taskGroup = task._owner.castAs(Group);
taskGroup.addMember(alice, "reader");
```
</CodeGroup>
If you prefer to manage permissions differently, you can always create CoValues explicitly:
<CodeGroup>
```ts twoslash
import { co, Group, z, Account } from "jazz-tools";
const bob = {} as unknown as Account;
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
// ---cut---
const writeAccess = Group.create();
writeAccess.addMember(bob, "writer");
const readAccess = Group.create();
readAccess.addMember(bob, "reader");
// Give Bob read access to the board and write access to the columns and tasks
const board = Board.create({
title: "My board",
columns: co.list(Column).create([
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
], writeAccess),
}, readAccess);
```
</CodeGroup>
## Example: Team Hierarchy
Here's a practical example of using group inheritance for team permissions:

View File

@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
<CodeGroup>
```ts
// src/lib/schema.ts
import { Account, Profile, coField } from "jazz-tools";
import { co, z } from "jazz-tools"
export class MyProfile extends Profile {
name = coField.string;
counter = coField.number; // This will be publically visible
}
export const MyProfile = co.profile({
name: z.string(),
counter: z.number()
});
export class MyAccount extends Account {
profile = coField.ref(MyProfile);
export const root = co.map({});
// ...
}
export const UserAccount = co.account({
root,
profile: MyProfile
});
```
</CodeGroup>
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
<CodeGroup>
```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { JazzSvelteProvider } from 'jazz-tools/svelte';
import { JazzSvelteProvider } from "jazz-tools/svelte";
let { children } = $props();
// Example configuration for authentication and peer connection
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
let AccountSchema = MyAccount;
</script>
<JazzSvelteProvider {sync} {AccountSchema}>
<App />
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
{@render children?.()}
</JazzSvelteProvider>
```
</CodeGroup>
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
```svelte
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { useCoState, useAccount } from 'jazz-tools/svelte';
import { MyProfile } from './schema';
import { CoState, AccountCoState } from "jazz-tools/svelte";
import { MyProfile, UserAccount } from "$lib/schema";
const { me } = useAccount();
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
const me = new AccountCoState(UserAccount);
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
function increment() {
if (!profile.current) return;
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
}
</script>
<button on:click={increment}>
<button onclick={increment}>
Count: {profile.current?.counter}
</button>
```

View File

@@ -3,6 +3,7 @@ export const metadata = {
};
import { CodeGroup, ComingSoon } from "@/components/forMdx";
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
# Defining schemas: CoValues
@@ -80,6 +81,40 @@ const project = TodoProject.create(
</CodeGroup>
When creating CoValues that contain other CoValues, you can pass in a plain JSON object.
Jazz will automatically create the CoValues for you.
<CodeGroup>
```ts twoslash
// @filename: schema.ts
import { co, z, CoMap, CoList } from "jazz-tools";
export const ListOfTasks = co.list(z.string());
export const TodoProject = co.map({
title: z.string(),
tasks: ListOfTasks,
});
// @filename: app.ts
// ---cut---
// app.ts
import { Group } from "jazz-tools";
import { TodoProject, ListOfTasks } from "./schema";
const group = Group.create().makePublic();
const project = TodoProject.create({
title: "New Project",
tasks: [], // Permissions are inherited, so the tasks list will also be public
}, group);
```
</CodeGroup>
<Alert variant="info" className="flex gap-2 items-center my-4">
To learn more about how permissions work when creating nested CoValues with plain JSON objects,
refer to [Group hierarchy on CoValue creation](/docs/groups/inheritance#group-hierarchy-on-covalue-creation).
</Alert>
## Types of CoValues
### `CoMap` (declaration)

View File

@@ -0,0 +1,98 @@
import { CodeGroup } from '@/components/forMdx'
# Jazz 0.17.0 - New Image APIs
This release introduces a comprehensive refactoring of the image API, from creation to consumption. The result is a more flexible set of components and lower-level primitives that provide better developer experience and performance.
## Motivation
Before 0.17.0, the image APIs had several limitations:
- Progressive loading created confusion in usage patterns, and the API lacked flexibility to support all use cases
- The resize methods were overly opinionated, and the chosen library had compatibility issues in incognito mode
- The imperative functions for loading images were unnecessarily complex for simple use cases
## Breaking Changes
- The `createImage` options have been restructured, and the function has been moved to the `jazz-tools/media` namespace for both React and React Native
- The `<ProgressiveImg>` component has been replaced with `<Image>` from `jazz-tools/react`
- The `<ProgressiveImgNative>` component has been replaced with `<Image>` from `jazz-tools/react-native`
- The `highestResAvailable` function has been moved from `ImageDefinition.highestResAvailable` to `import { highestResAvailable } from "jazz-tools/media"`
- Existing image data remains compatible and accessible
- Progressive images created with previous versions will continue to work
## Changes
### `createImage` Function
The `createImage` function has been refactored to allow opt-in specific features and moved to the `jazz-tools/media` namespace.
<CodeGroup>
```tsx
export type CreateImageOptions = {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
};
```
</CodeGroup>
- By default, images are now created with only the original size saved (no progressive loading or placeholder)
- The `maxSize` property is no longer restricted and affects the original size saved
- Placeholder generation is now a configurable property, disabled by default. Currently, only `"blur"` is supported, with more built-in options planned for future releases
- The `progressive` property creates internal resizes used exclusively via public APIs. Direct manipulation of internal resize state is no longer recommended
The `pica` library used internally for browser image resizing has been replaced with a simpler canvas-based implementation. Since every image manipulation library has trade-offs, we've chosen the simplest solution while providing flexibility through `createImageFactory`. This new factory function allows you to create custom `createImage` instances with your preferred libraries for resizing, placeholder generation, and source reading. It's used internally to create default instances for browsers, React Native, and Node.js environments.
### Replaced `<ProgressiveImg>` Component with `<Image>`
The `<ProgressiveImg>` component has been replaced with `<Image>` component for both React and React Native.
<CodeGroup>
```tsx
// Before
import { ProgressiveImg } from "jazz-tools/react";
<ProgressiveImg image={me.profile.image}>
{({ src }) => <img alt="" src={src} className="w-full h-auto" />}
</ProgressiveImg>
// After
import { Image } from "jazz-tools/react";
<Image imageId={me.profile.image.id} alt="Profile" width={600} />
```
</CodeGroup>
The `width` and `height` props are now used internally to load the optimal image size, but only if progressive loading was enabled during image creation.
For detailed usage examples and API reference, see the [Image component documentation](/docs/react/using-covalues/imagedef#displaying-images).
### New `Image` Component for Svelte
A new `Image` component has been added for Svelte, featuring the same API as the React and React Native components.
<CodeGroup>
```svelte
<script lang="ts">
import { Image } from 'jazz-tools/svelte';
</script>
<Image
imageId={image.id}
alt=""
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
width={600}
/>
```
</CodeGroup>
For detailed usage examples and API reference, see the [Image component documentation](/docs/svelte/using-covalues/imagedef#displaying-images).
### New Image Loading Utilities
Two new utility functions are now available from the `jazz-tools/media` package:
- `loadImage` - Fetches the original image file by ID
- `loadImageBySize` - Fetches the best stored size for a given width and height
For detailed usage examples and API reference, see the [Image component documentation](/docs/vanilla/using-covalues/imagedef#displaying-images).

View File

@@ -228,6 +228,54 @@ export type Project = co.loaded<typeof Project>;
```
</CodeGroup>
### Partial
For convenience Jazz provies a dedicated API for making all the properties of a CoMap optional:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
});
const ProjectDraft = Project.partial();
// The fields are all optional now
const project = ProjectDraft.create({});
```
</CodeGroup>
### Pick
You can also pick specific fields from a CoMap:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
});
const ProjectStep1 = Project.pick({
name: true,
startDate: true,
});
// We don't provide the status field
const project = ProjectStep1.create({
name: "My project",
startDate: new Date("2025-04-01"),
});
```
</CodeGroup>
### Working with Record CoMaps
For record-type CoMaps, you can access values using bracket notation:

View File

@@ -1,18 +1,18 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting multiple resolutions of the same image and progressive loading patterns.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
We also offer [`createImage()`](#creating-images), a higher-level function to create an `ImageDefinition` from a file.
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`loadImage`, `loadImageBySize`, `highestResAvailable`](#displaying-images) - functions to load and display images
If you're building with React, we recommend starting with our [React-specific image documentation](/docs/react/using-covalues/imagedef) which covers higher-level components and hooks for working with images.
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of `ImageDefinition`.
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
## Creating Images
@@ -20,314 +20,258 @@ The easiest way to create and use images in your Jazz application is with the `c
<CodeGroup>
```ts twoslash
import { Group, co, z } from "jazz-tools";
import { Account, Group, ImageDefinition } from "jazz-tools";
const MyProfile = co.profile({
name: z.string(),
image: co.optional(co.image()),
});
const MyAccount = co.account({
root: co.map({}),
profile: MyProfile,
});
MyAccount.withMigration((account, creationProps) => {
if (account.profile === undefined) {
const profileGroup = Group.create();
profileGroup.makePublic();
account.profile = MyProfile.create(
{
name: creationProps?.name ?? "New user",
},
profileGroup,
);
}
});
const me = await MyAccount.create({ creationProps: { name: "John Doe" } });
const myGroup = Group.create();
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/browser-media-images";
import { createImage } from "jazz-tools/media";
// Create an image from a file input
async function handleFileUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImage(file, { owner: myGroup });
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
const image = await createImage(file, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
// Store the image in your application data
me.profile.image = image;
}
}
```
</CodeGroup>
**Note:** `createImage()` requires a browser environment as it uses browser APIs to process images.
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
- Returns the ID of the created `ImageDefinition`
- Returns the created `ImageDefinition`
### Configuration Options
You can configure `createImage()` with additional options:
<CodeGroup>
```ts
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myBlob: Blob;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myBlob);
const thumbnail = await createImage(myBlob, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
// ---cut---
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 as 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImage(file, options);
```
</CodeGroup>
### Ownership
Like other CoValues, you can specify ownership when creating image definitions.
<CodeGroup>
```ts twoslash
import { Group } from "jazz-tools";
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const colleagueAccount = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
// ---cut---
const teamGroup = Group.create();
teamGroup.addMember(colleagueAccount, "writer");
// Create an image with shared ownership
const teamImage = await createImage(file, { owner: teamGroup });
```
</CodeGroup>
See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
## Creating ImageDefinitions
Create an `ImageDefinition` by specifying the original dimensions and an optional placeholder:
<CodeGroup>
```ts twoslash
import { ImageDefinition } from "jazz-tools";
// Create with original dimensions
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
// With a placeholder for immediate display
const imageWithPlaceholder = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJ...",
});
```
</CodeGroup>
### Structure
`ImageDefinition` stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`, typically a tiny base64-encoded preview)
- Multiple resolution variants of the same image as [`FileStream`s](./using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
<CodeGroup>
```ts twoslash
import { ImageDefinition, co, z } from "jazz-tools";
const Gallery = co.map({
title: z.string(),
images: co.list(co.image()),
});
```
</CodeGroup>
## Adding Image Resolutions
Add multiple resolutions to an `ImageDefinition` by creating `FileStream`s for each size:
<CodeGroup>
```ts twoslash
import { FileStream, ImageDefinition } from "jazz-tools";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const fullSizeBlob = new Blob([], { type: "image/jpeg" });
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
const thumbnailBlob = new Blob([], { type: "image/jpeg" });
const image = ImageDefinition.create({
originalSize: [1920, 1080],
}, { owner: me });
// ---cut---
// Create FileStreams for different resolutions
const fullRes = await FileStream.createFromBlob(fullSizeBlob);
const mediumRes = await FileStream.createFromBlob(mediumSizeBlob);
const thumbnailRes = await FileStream.createFromBlob(thumbnailBlob);
// Add to the ImageDefinition with appropriate resolution keys
image["1920x1080"] = fullRes;
image["800x450"] = mediumRes;
image["320x180"] = thumbnailRes;
```
</CodeGroup>
## Retrieving Images
The `highestResAvailable` method helps select the best image resolution for the current context:
<CodeGroup>
```ts twoslash
import { ImageDefinition, FileStream } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
// Simple document environment
global.document = {
createElement: () =>
({ src: "", onload: null }) as unknown as HTMLImageElement,
} as unknown as Document;
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
// Setup
const fakeBlob = new Blob(["fake image data"], { type: "image/jpeg" });
const me = await createJazzTestAccount();
const image = ImageDefinition.create(
{ originalSize: [1920, 1080] },
{ owner: me },
);
image["1920x1080"] = await FileStream.createFromBlob(fakeBlob, { owner: me });
const imageElement = document.createElement("img");
// ---cut---
// Get highest resolution available (unconstrained)
const highestRes = ImageDefinition.highestResAvailable(image);
console.log(highestRes);
if (highestRes) {
const blob = highestRes.stream.toBlob();
if (blob) {
// Create a URL for the blob
const url = URL.createObjectURL(blob);
imageElement.src = url;
// Revoke the URL when the image is loaded
imageElement.onload = () => URL.revokeObjectURL(url);
}
}
// Get appropriate resolution for specific width
const appropriateRes = ImageDefinition.highestResAvailable(image, {
targetWidth: window.innerWidth,
});
```
</CodeGroup>
### Fallback Behavior
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
<CodeGroup>
```ts twoslash
import { ImageDefinition, FileStream } from "jazz-tools";
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
// ---cut---
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const highestRes = ImageDefinition.highestResAvailable(image);
console.log(highestRes?.res); // 800x450
```
</CodeGroup>
## Progressive Loading Patterns
`ImageDefinition` supports simple progressive loading with placeholders and resolution selection:
<CodeGroup>
```ts twoslash
import { FileStream, ImageDefinition } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
// Simple document environment
global.document = {
createElement: () =>
({ src: "", onload: null }) as unknown as HTMLImageElement,
} as unknown as Document;
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
const me = await createJazzTestAccount();
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
const image = ImageDefinition.create(
{
originalSize: [1920, 1080],
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
{ owner: me },
);
image["1920x1080"] = await FileStream.createFromBlob(mediumSizeBlob, {
owner: me,
});
const imageElement = document.createElement("img");
// ---cut---
// Start with placeholder for immediate display
if (image.placeholderDataURL) {
imageElement.src = image.placeholderDataURL;
}
// Then load the best resolution for the current display
const screenWidth = window.innerWidth;
const bestRes = ImageDefinition.highestResAvailable(image, {
targetWidth: screenWidth,
});
if (bestRes) {
const blob = bestRes.stream.toBlob();
if (blob) {
const url = URL.createObjectURL(blob);
imageElement.src = url;
// Remember to revoke the URL when no longer needed
imageElement.onload = () => {
URL.revokeObjectURL(url);
};
}
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
}
```
</CodeGroup>
## Best Practices
- **Generate resolutions server-side** when possible for optimal quality
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/vanilla/using-covalues/filestreams#reading-from-filestreams) documentation.
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
const image = await loadImage(imageDefinitionOrId);
if(image === null) {
throw new Error("Image not found");
}
const img = document.createElement("img");
img.width = image.width;
img.height = image.height;
img.src = URL.createObjectURL(image.image.toBlob()!);
img.onload = () => URL.revokeObjectURL(img.src);
```
</CodeGroup>
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load(imageId);
if(image === null) {
throw new Error("Image not found");
}
const img = document.createElement("img");
img.width = 600;
img.height = 600;
// start with the placeholder
if(image.placeholderDataURL) {
img.src = image.placeholderDataURL;
}
// then listen to the image changes
image.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
}
}
});
```
</CodeGroup>
### Best Practices
- **Set image sizes** when possible to avoid layout shifts
- **Use placeholders** (like LQIP - Low Quality Image Placeholders) for instant rendering
- **Prioritize loading** the resolution appropriate for the current viewport
- **Consider device pixel ratio** (window.devicePixelRatio) for high-DPI displays
- **Always call URL.revokeObjectURL** after the image loads to prevent memory leaks
## Image manipulation custom implementation
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -1,63 +1,75 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
export const metadata = {
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz. It extends beyond basic file storage by supporting multiple resolutions of the same image, optimized for mobile devices.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
**Note**: This guide applies to both Expo and framework-less React Native implementations. The functionality described here is identical regardless of which implementation you're using
**Note**: This guide applies to both Expo and framework-less React Native implementations.
Jazz offers several tools to work with images in React Native:
- [`createImageNative()`](#creating-images) - function to create an `ImageDefinition` from a base64 image data URI
- [`ProgressiveImgNative`](#displaying-images-with-progressiveimgnative) - React component to display an image with progressive loading
- [`useProgressiveImgNative`](#using-useprogressiveimgnative-hook) - React hook to load an image in your own component
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`Image`](#displaying-images) - React Native component to display a stored image
For examples of use, see our example apps:
- [React Native Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn) (Framework-less implementation)
- [Expo Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn-expo) (Expo implementation)
- [Expo Clerk](https://github.com/gardencmp/jazz/tree/main/examples/clerk-expo) (Expo with Clerk-based authentication)
## Installation
The Jazz's images implementation is based on `@bam.tech/react-native-image-resizer`. Check the [installation guide](/docs/react-native-expo/project-setup#install-dependencies) for more details.
## Creating Images
The easiest way to create and use images in your Jazz application is with the `createImageNative()` function:
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
<CodeGroup>
```tsx
import { createImageNative } from "jazz-tools/expo-media-images";
import * as ImagePicker from 'expo-image-picker';
```ts
import { Account, Group, ImageDefinition } from "jazz-tools";
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/media";
import { launchImageLibrary } from 'react-native-image-picker';
async function handleImagePicker() {
try {
// Launch the image picker
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
base64: true,
quality: 1,
// Use your favorite image picker library to get the image URI
const result = await launchImageLibrary({
mediaType: 'photo',
quality: 1,
});
if (!result.didCancel && result.assets && result.assets.length > 0) {
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically.
// See the options below for more details.
const image = await createImage(result.assets[0].uri, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
if (!result.canceled) {
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImageNative(base64Uri, {
owner: me.profile._owner,
maxSize: 2048, // Optional: limit maximum resolution
});
// Store the image
me.profile.image = image;
}
} catch (error) {
console.error("Error creating image:", error);
// Store the image
me.profile.image = image;
}
}
```
</CodeGroup>
The `createImageNative()` function:
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
@@ -65,49 +77,96 @@ The `createImageNative()` function:
### Configuration Options
You can configure `createImageNative()` with additional options:
<CodeGroup>
```tsx
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImageNative(base64Uri, options);
```ts twoslash
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
## Displaying Images with `ProgressiveImgNative`
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myFile: string;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myFile);
const thumbnail = await createImage(myFile, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
The Image component is the best way to let Jazz handle the image loading.
For a complete progressive loading experience, use the `ProgressiveImgNative` component:
<CodeGroup>
```tsx
import { ProgressiveImgNative } from "jazz-tools/expo";
import { Image, StyleSheet } from "react-native";
import { Image } from "jazz-tools/expo";
import { StyleSheet } from "react-native";
function GalleryView({ image }) {
return (
<ProgressiveImgNative
image={image} // The image definition to load
targetWidth={800} // Looks for the best available resolution for a 800px image
>
{({ src }) => (
<Image
source={{ uri: src }}
style={styles.galleryImage}
resizeMode="cover"
/>
)}
</ProgressiveImgNative>
<Image
imageId={image.id}
style={styles.galleryImage}
width={400}
resizeMode="cover"
/>
);
}
@@ -121,120 +180,178 @@ const styles = StyleSheet.create({
```
</CodeGroup>
The `ProgressiveImgNative` component handles:
- Showing a placeholder while loading
- Automatically selecting the appropriate resolution
- Progressive enhancement as higher resolutions become available
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
## Using `useProgressiveImgNative` Hook
The component's props are:
For more control over image loading, you can implement your own progressive image component:
<CodeGroup>
```ts
export type ImageProps = Omit<
RNImageProps,
"width" | "height" | "source"
> & {
imageId: string;
width?: number | "original";
height?: number | "original";
};
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```tsx
import { useProgressiveImgNative } from "jazz-tools/expo";
import { Image, View, Text, ActivityIndicator } from "react-native";
<Image imageId="123" />
// <RNImage src={...} /> with the highest resolution available
function CustomImageComponent({ image }) {
const {
src, // Data URI containing the image data as a base64 string,
// or a placeholder image URI
res, // The current resolution
originalSize // The original size of the image
} = useProgressiveImgNative({
image: image, // The image definition to load
targetWidth: 800 // Limit to resolutions up to 800px wide
<Image imageId="123" width="original" height="original" />
// <RNImage width="1920" height="1080" />
<Image imageId="123" width="600" />
// <RNImage width="600" /> BAD! See https://reactnative.dev/docs/images#network-images
<Image imageId="123" width="600" height="original" />
// <RNImage width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <RNImage width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <RNImage width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { ImageDefinition } from "jazz-tools";
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
// When image is not available yet
if (!src) {
return (
<View style={{ height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' }}>
<ActivityIndicator size="small" color="#0000ff" />
<Text style={{ marginTop: 10 }}>Loading image...</Text>
</View>
);
}
// When using placeholder
if (res === "placeholder") {
return (
<View style={{ position: 'relative' }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: 200, opacity: 0.7 }}
resizeMode="cover"
/>
<ActivityIndicator
size="large"
color="#ffffff"
style={{ position: 'absolute', top: '50%', left: '50%', marginLeft: -20, marginTop: -20 }}
/>
</View>
);
}
// Full image display with custom overlay
return (
<View style={{ position: 'relative', width: '100%', height: 200 }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.5)', padding: 8 }}>
<Text style={{ color: 'white' }}>Resolution: {res}</Text>
</View>
</View>
);
}
```
</CodeGroup>
## Understanding ImageDefinition
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`) for immediate display
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```tsx
// Structure of an ImageDefinition
const image = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
// Accessing the highest available resolution
const highestRes = image.highestResAvailable();
if (highestRes) {
console.log(`Found resolution: ${highestRes.res}`);
console.log(`Stream: ${highestRes.stream}`);
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
### Fallback Behavior
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```tsx
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
const highestRes = image.highestResAvailable();
console.log(highestRes.res); // 800x450
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
## Image manipulation custom implementation
As mentioned, to manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On react-native, the image manipulation is done using the `@bam.tech/react-native-image-resizer` library. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -1,63 +1,75 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
export const metadata = {
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz. It extends beyond basic file storage by supporting multiple resolutions of the same image, optimized for mobile devices.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
**Note**: This guide applies to both Expo and framework-less React Native implementations.
Jazz offers several tools to work with images in React Native:
- [`createImageNative()`](#creating-images) - function to create an `ImageDefinition` from a base64 image data URI
- [`ProgressiveImgNative`](#displaying-images-with-progressiveimgnative) - React component to display an image with progressive loading
- [`useProgressiveImgNative`](#using-useprogressiveimgnative-hook) - React hook to load an image in your own component
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`Image`](#displaying-images) - React Native component to display a stored image
For examples of use, see our example apps:
- [React Native Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn) (Framework-less implementation)
- [Expo Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn-expo) (Expo implementation)
- [Expo Clerk](https://github.com/gardencmp/jazz/tree/main/examples/clerk-expo) (Expo with Clerk-based authentication)
## Installation
The Jazz's images implementation is based on `@bam.tech/react-native-image-resizer`. Check the [installation guide](/docs/react-native/project-setup#install-dependencies) for more details.
## Creating Images
The easiest way to create and use images in your Jazz application is with the `createImageNative()` function:
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
<CodeGroup>
```tsx
import { createImageNative } from "jazz-tools/react-native-media-images";
```ts
import { Account, Group, ImageDefinition } from "jazz-tools";
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/media";
import { launchImageLibrary } from 'react-native-image-picker';
async function handleImagePicker() {
try {
// Launch the image picker
const result = await launchImageLibrary({
mediaType: 'photo',
includeBase64: true,
quality: 1,
// Use your favorite image picker library to get the image URI
const result = await launchImageLibrary({
mediaType: 'photo',
quality: 1,
});
if (!result.didCancel && result.assets && result.assets.length > 0) {
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically.
// See the options below for more details.
const image = await createImage(result.assets[0].uri, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
if (!result.canceled) {
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImageNative(base64Uri, {
owner: me.profile._owner,
maxSize: 2048, // Optional: limit maximum resolution
});
// Store the image
me.profile.image = image;
}
} catch (error) {
console.error("Error creating image:", error);
// Store the image
me.profile.image = image;
}
}
```
</CodeGroup>
The `createImageNative()` function:
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
@@ -65,49 +77,96 @@ The `createImageNative()` function:
### Configuration Options
You can configure `createImageNative()` with additional options:
<CodeGroup>
```tsx
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImageNative(base64Uri, options);
```ts twoslash
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
## Displaying Images with `ProgressiveImgNative`
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myFile: string;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myFile);
const thumbnail = await createImage(myFile, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
The Image component is the best way to let Jazz handle the image loading.
For a complete progressive loading experience, use the `ProgressiveImgNative` component:
<CodeGroup>
```tsx
import { ProgressiveImgNative } from "jazz-tools/react-native";
import { Image, StyleSheet } from "react-native";
import { Image } from "jazz-tools/react-native";
import { StyleSheet } from "react-native";
function GalleryView({ image }) {
return (
<ProgressiveImgNative
image={image} // The image definition to load
targetWidth={800} // Looks for the best available resolution for a 800px image
>
{({ src }) => (
<Image
source={{ uri: src }}
style={styles.galleryImage}
resizeMode="cover"
/>
)}
</ProgressiveImgNative>
<Image
imageId={image.id}
style={styles.galleryImage}
width={400}
resizeMode="cover"
/>
);
}
@@ -121,120 +180,177 @@ const styles = StyleSheet.create({
```
</CodeGroup>
The `ProgressiveImgNative` component handles:
- Showing a placeholder while loading
- Automatically selecting the appropriate resolution
- Progressive enhancement as higher resolutions become available
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
## Using `useProgressiveImgNative` Hook
The component's props are:
For more control over image loading, you can implement your own progressive image component:
<CodeGroup>
```ts
export type ImageProps = Omit<
RNImageProps,
"width" | "height" | "source"
> & {
imageId: string;
width?: number | "original";
height?: number | "original";
};
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```tsx
import { useProgressiveImgNative } from "jazz-tools/react-native";
import { Image, View, Text, ActivityIndicator } from "react-native";
<Image imageId="123" />
// <RNImage src={...} /> with the highest resolution available
function CustomImageComponent({ image }) {
const {
src, // Data URI containing the image data as a base64 string,
// or a placeholder image URI
res, // The current resolution
originalSize // The original size of the image
} = useProgressiveImgNative({
image: image, // The image definition to load
targetWidth: 800 // Limit to resolutions up to 800px wide
<Image imageId="123" width="original" height="original" />
// <RNImage width="1920" height="1080" />
<Image imageId="123" width="600" />
// <RNImage width="600" /> BAD! See https://reactnative.dev/docs/images#network-images
<Image imageId="123" width="600" height="original" />
// <RNImage width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <RNImage width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <RNImage width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { ImageDefinition } from "jazz-tools";
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
// When image is not available yet
if (!src) {
return (
<View style={{ height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' }}>
<ActivityIndicator size="small" color="#0000ff" />
<Text style={{ marginTop: 10 }}>Loading image...</Text>
</View>
);
}
// When using placeholder
if (res === "placeholder") {
return (
<View style={{ position: 'relative' }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: 200, opacity: 0.7 }}
resizeMode="cover"
/>
<ActivityIndicator
size="large"
color="#ffffff"
style={{ position: 'absolute', top: '50%', left: '50%', marginLeft: -20, marginTop: -20 }}
/>
</View>
);
}
// Full image display with custom overlay
return (
<View style={{ position: 'relative', width: '100%', height: 200 }}>
<Image
source={{ uri: src }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.5)', padding: 8 }}>
<Text style={{ color: 'white' }}>Resolution: {res}</Text>
</View>
</View>
);
}
```
</CodeGroup>
## Understanding ImageDefinition
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`) for immediate display
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```tsx
// Structure of an ImageDefinition
const image = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
// Accessing the highest available resolution
const highestRes = image.highestResAvailable();
if (highestRes) {
console.log(`Found resolution: ${highestRes.res}`);
console.log(`Stream: ${highestRes.stream}`);
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
### Fallback Behavior
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```tsx
const image = ImageDefinition.create({
originalSize: [1920, 1080],
});
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
const highestRes = image.highestResAvailable();
console.log(highestRes.res); // 800x450
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
## Image manipulation custom implementation
As mentioned, to manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On react-native, the image manipulation is done using the `@bam.tech/react-native-image-resizer` library. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -1,19 +1,18 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting multiple resolutions of the same image and progressive loading patterns.
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
Beyond [`ImageDefinition`](#understanding-imagedefinition), Jazz offers higher-level functions and components that make it easier to use images:
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`ProgressiveImg`](#displaying-images-with-progressiveimg) - React component to display an image with progressive loading
- [`useProgressiveImg`](#using-useprogressiveimg-hook) - React hook to load an image in your own component
- [`Image`](#displaying-images) - React component to display a stored image
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of `ProgressiveImg` and `ImageDefinition`.
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
## Creating Images
@@ -21,54 +20,38 @@ The easiest way to create and use images in your Jazz application is with the `c
<CodeGroup>
```ts twoslash
import { Group, co, z } from "jazz-tools";
import { Account, Group, ImageDefinition } from "jazz-tools";
const MyProfile = co.profile({
name: z.string(),
image: co.optional(co.image()),
});
const MyAccount = co.account({
root: co.map({}),
profile: MyProfile,
});
MyAccount.withMigration((account, creationProps) => {
if (account.profile === undefined) {
const profileGroup = Group.create();
profileGroup.makePublic();
account.profile = MyProfile.create(
{
name: creationProps?.name ?? "New user",
},
profileGroup,
);
}
});
const me = await MyAccount.create({});
const myGroup = Group.create();
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/browser-media-images";
import { createImage } from "jazz-tools/media";
// Create an image from a file input
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
// Creates ImageDefinition with multiple resolutions automatically
const image = await createImage(file, { owner: myGroup });
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
const image = await createImage(file, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
// Store the image in your application data
me.profile.image = image;
}
}
```
</CodeGroup>
**Note:** `createImage()` requires a browser environment as it uses browser APIs to process images.
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
@@ -78,194 +61,285 @@ The `createImage()` function:
### Configuration Options
You can configure `createImage()` with additional options:
<CodeGroup>
```ts twoslash
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
```ts
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
// Configuration options
const options = {
owner: me, // Owner for access control
maxSize: 1024 as 1024 // Maximum resolution to generate
};
// Setting maxSize controls which resolutions are generated:
// 256: Only creates the smallest resolution (256px on longest side)
// 1024: Creates 256px and 1024px resolutions
// 2048: Creates 256px, 1024px, and 2048px resolutions
// undefined: Creates all resolutions including the original size
const image = await createImage(file, options);
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
### Ownership
#### `image`
Like other CoValues, you can specify ownership when creating image definitions.
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
import { Group } from "jazz-tools";
import { createImage } from "jazz-tools/browser-media-images";
import { createJazzTestAccount } from 'jazz-tools/testing';
const me = await createJazzTestAccount();
const colleagueAccount = await createJazzTestAccount();
const file = new File([], "test.jpg", { type: "image/jpeg" });
declare const myBlob: Blob;
// ---cut---
const teamGroup = Group.create();
teamGroup.addMember(colleagueAccount, "writer");
// Create an image with shared ownership
const teamImage = await createImage(file, { owner: teamGroup });
```
</CodeGroup>
See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
## Displaying Images with `ProgressiveImg`
For a complete progressive loading experience, use the `ProgressiveImg` component:
<CodeGroup>
```tsx twoslash
import * as React from "react";
// ---cut---
import { ProgressiveImg } from "jazz-tools/react";
import { co } from "jazz-tools";
const Image = co.image();
import { createImage } from "jazz-tools/media";
function GalleryView({ image }: { image: co.loaded<typeof Image> }) {
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myBlob);
const thumbnail = await createImage(myBlob, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
To use the stored ImageDefinition, there are two ways: the `Image` react component, and the helpers functions.
### `<Image>` component
The Image component is the best way to let Jazz handle the image loading.
<CodeGroup>
```tsx
import * as React from "react";
import { co } from "jazz-tools";
const ImageDef = co.image();
// ---cut---
import { Image } from "jazz-tools/react";
function GalleryView({ image }: { image: co.loaded<typeof ImageDef> }) {
return (
<div className="image-container">
<ProgressiveImg
image={image} // The image definition to load
targetWidth={800} // Looks for the best available resolution for a 800px image
>
{({ src }) => (
<img
src={src}
alt="Gallery image"
className="gallery-image"
/>
)}
</ProgressiveImg>
<Image imageId={image.id} alt="Profile" width={600} />
</div>
);
}
```
</CodeGroup>
The `ProgressiveImg` component handles:
- Showing a placeholder while loading
- Automatically selecting the appropriate resolution
- Progressive enhancement as higher resolutions become available
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
## Using `useProgressiveImg` Hook
The component's props are:
For more control over image loading, you can implement your own progressive image component:
<CodeGroup>
```ts
export type ImageProps = Omit<
JSX.IntrinsicElements["img"],
"src" | "srcSet" | "width" | "height"
> & {
imageId: string;
width?: number | "original";
height?: number | "original";
};
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```tsx
<Image imageId="123" />
// <img src={...} /> with the highest resolution available
<Image imageId="123" width="original" height="original" />
// <img width="1920" height="1080" />
<Image imageId="123" width="600" />
// <img width="600" /> leaving the browser to compute the height (might cause layout shift)
<Image imageId="123" width="600" height="original" />
// <img width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <img width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <img width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
#### Lazy loading
The `Image` component supports lazy loading based on [browser's strategy](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#loading). It will generate the blob url for the image when the browser's viewport reaches the image.
<CodeGroup>
```tsx
<Image imageId="123" width="original" height="original" loading="lazy" />
```
</CodeGroup>
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import * as React from "react";
import { co } from "jazz-tools";
const Image = co.image();
// ---cut---
import { useProgressiveImg } from "jazz-tools/react";
import { ImageDefinition } from "jazz-tools";
function CustomImageComponent({ image }: { image: co.loaded<typeof Image> }) {
const {
src, // Data URI containing the image data as a base64 string,
// or a placeholder image URI
res, // The current resolution
originalSize // The original size of the image
} = useProgressiveImg({
image: image, // The image definition to load
targetWidth: 800 // Limit to resolutions up to 800px wide
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
// When image is not available yet
if (!src) {
return <div className="image-loading-fallback">Loading image...</div>;
}
// When image is loading, show a placeholder
if (res === "placeholder") {
return <img src={src} alt="Loading..." className="blur-effect" />;
}
// Full image display with custom overlay
return (
<div className="custom-image-wrapper">
<img
src={src}
alt="Custom image"
className="custom-image"
/>
<div className="image-overlay">
<span className="image-caption">Resolution: {res}</span>
</div>
</div>
);
}
```
</CodeGroup>
## Understanding ImageDefinition
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
- The original image dimensions (`originalSize`)
- An optional placeholder (`placeholderDataURL`) for immediate display
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// Structure of an ImageDefinition
const image = ImageDefinition.create({
originalSize: [1920, 1080],
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
});
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
// Accessing the highest available resolution
const highestRes = ImageDefinition.highestResAvailable(image);
if (highestRes) {
console.log(`Found resolution: ${highestRes.res}`);
console.log(`Stream: ${highestRes.stream}`);
}
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
### Fallback Behavior
## Image manipulation custom implementation
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts twoslash
import { ImageDefinition, FileStream } from "jazz-tools";
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
```ts
import { createImageFactory } from "jazz-tools/media";
// ---cut---
const image = ImageDefinition.create({
originalSize: [1920, 1080],
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
image["1920x1080"] = FileStream.create(); // Empty image upload
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
const highestRes = ImageDefinition.highestResAvailable(image);
console.log(highestRes?.res); // 800x450
```
</CodeGroup>

View File

@@ -0,0 +1,331 @@
import { CodeGroup } from "@/components/forMdx";
export const metadata = {
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
};
# ImageDefinition
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
Beyond [`ImageDefinition`](#understanding-imagedefinition), Jazz offers higher-level functions and components that make it easier to use images:
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
- [`Image`](#displaying-images) - Svelte component to display a stored image
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
## Creating Images
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
<CodeGroup>
```ts twoslash
import { Account, Group, ImageDefinition } from "jazz-tools";
declare const me: {
_owner: Account | Group;
profile: {
image: ImageDefinition;
};
};
// ---cut---
import { createImage } from "jazz-tools/media";
// Create an image from a file input
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
const image = await createImage(file, {
owner: me._owner,
maxSize: 1024,
placeholder: "blur",
progressive: true,
});
// Store the image in your application data
me.profile.image = image;
}
}
```
</CodeGroup>
**Note:** `createImage()` currently supports browser and react-native environments.
The `createImage()` function:
- Creates an `ImageDefinition` with the right properties
- Generates a small placeholder for immediate display
- Creates multiple resolution variants of your image
- Returns the created `ImageDefinition`
### Configuration Options
<CodeGroup>
```ts twoslash
import type { ImageDefinition, Group, Account } from "jazz-tools";
// ---cut---
declare function createImage(
image: Blob | File | string,
options: {
owner?: Group | Account;
placeholder?: "blur" | false;
maxSize?: number;
progressive?: boolean;
}): Promise<ImageDefinition>
```
</CodeGroup>
#### `image`
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
#### `owner`
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
#### `placeholder`
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
#### `maxSize`
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
#### `progressive`
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
### Create multiple resized copies
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Heres an example of how you might implement this:
<CodeGroup>
```ts twoslash
declare const myBlob: Blob;
// ---cut---
import { co } from "jazz-tools";
import { createImage } from "jazz-tools/media";
// Jazz Schema
const ProductImage = co.map({
image: co.image(),
thumbnail: co.image(),
});
const mainImage = await createImage(myBlob);
const thumbnail = await createImage(myBlob, {
maxSize: 100,
});
// or, in case of migration, you can use the original stored image.
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
maxSize: 100,
});
const imageSet = ProductImage.create({
image: mainImage,
thumbnail,
});
```
</CodeGroup>
## Displaying Images
To use the stored ImageDefinition, there are two ways: the `Image` react component, and the helpers functions.
### `<Image>` component
The Image component is the best way to let Jazz handle the image loading.
<CodeGroup>
```svelte twoslash
<script lang="ts">
import { ImageDefinition, type Loaded } from 'jazz-tools';
import { Image } from 'jazz-tools/svelte';
let { image }: { image: Loaded<typeof ImageDefinition> } = $props();
</script>
<Image
imageId={image.id}
alt=""
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
/>
```
</CodeGroup>
The `Image` component handles:
- Showing a placeholder while loading, if generated
- Automatically selecting the appropriate resolution, if generated with progressive loading
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
- Determining the correct width/height attributes to avoid layout shifting
- Cleaning up resources when unmounted
The component's props are:
<CodeGroup>
```ts
interface ImageProps extends Omit<HTMLImgAttributes, "width" | "height"> {
imageId: string;
width?: number | "original";
height?: number | "original";
}
```
</CodeGroup>
#### Width and Height props
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
Let's say we have an image with a width of 1920px and a height of 1080px.
<CodeGroup>
```svelte
<Image imageId="123" />
// <img src={...} /> with the highest resolution available
<Image imageId="123" width="original" height="original" />
// <img width="1920" height="1080" />
<Image imageId="123" width="600" />
// <img width="600" /> leaving the browser to compute the height (might cause layout shift)
<Image imageId="123" width="600" height="original" />
// <img width="600" height="338" /> keeping the aspect ratio
<Image imageId="123" width="original" height="600" />
// <img width="1067" height="600" /> keeping the aspect ratio
<Image imageId="123" width="600" height="600" />
// <img width="600" height="600" />
```
</CodeGroup>
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
### Imperative usage
Like other CoValues, `ImageDefinition` can be used to load the object.
<CodeGroup>
```tsx twoslash
import { ImageDefinition } from "jazz-tools";
const image = await ImageDefinition.load("123", {
resolve: {
original: true,
},
});
if(image) {
console.log({
originalSize: image.originalSize,
placeholderDataUrl: image.placeholderDataURL,
original: image.original, // this FileStream may be not loaded yet
});
}
```
</CodeGroup>
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImage } from "jazz-tools/media";
const image = await loadImage(imageDefinitionOrId);
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
<CodeGroup>
```ts twoslash
declare const imageDefinitionOrId: string;
// ---cut---
import { loadImageBySize } from "jazz-tools/media";
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
if(image) {
console.log({
width: image.width,
height: image.height,
image: image.image,
});
}
```
</CodeGroup>
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
<CodeGroup>
```ts
import { ImageDefinition } from "jazz-tools";
// ---cut---
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
import { highestResAvailable } from "jazz-tools/media";
const image = await ImageDefinition.load("123");
image?.subscribe({}, (image) => {
const bestImage = highestResAvailable(image, 600, 600);
if(bestImage) {
// bestImage is again a FileStream
const blob = bestImage.image.toBlob();
if(blob) {
const url = URL.createObjectURL(blob);
// ...
}
}
});
```
</CodeGroup>
## Image manipulation custom implementation
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
<CodeGroup>
```ts
import { createImageFactory } from "jazz-tools/media";
const createImage = createImageFactory({
createFileStreamFromSource: async (source, owner) => {
// ...
},
getImageSize: async (image) => {
// ...
},
getPlaceholderBase64: async (image) => {
// ...
},
resize: async (image, width, height) => {
// ...
},
});
```
</CodeGroup>

View File

@@ -5,6 +5,8 @@ import { readFile, readdir } from "fs/promises";
import { DOC_SECTIONS } from "./utils/config.mjs";
import { writeDocsFile } from "./utils/index.mjs";
const exclude = [/\/upgrade\//];
async function readMdxContent(url) {
try {
// Special case for the introduction
@@ -31,12 +33,17 @@ async function readMdxContent(url) {
// If it's a directory, try to read all framework variants
const fullPath = path.join(baseDir, relativePath);
if (exclude.some((pattern) => pattern.test(fullPath))) {
return null;
}
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// Read all MDX files in the directory
const files = await fs.readdir(fullPath);
const mdxFiles = files.filter((f) => f.endsWith(".mdx"));
const mdxFiles = files.filter((f) => f.endsWith(".mdx")).filter((f) => !exclude.some((pattern) => pattern.test(f)));
if (mdxFiles.length === 0) return null;

View File

@@ -31,7 +31,7 @@
"build:packages": "turbo build --filter='./packages/*'",
"lint": "turbo lint && cd homepage/homepage && pnpm run lint",
"test": "vitest",
"test:ci": "vitest run --watch=false --coverage.enabled=true",
"test:ci": "vitest run --watch=false",
"test:coverage": "vitest --ui --coverage.enabled=true",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",

View File

@@ -1,5 +1,47 @@
# cojson-storage-indexeddb
## 0.17.0
### Patch Changes
- cojson@0.17.0
## 0.16.6
### Patch Changes
- 103d1b4: Fix Unknown transaction error on IndexedDB showing up sometimes when using a readwrite transaction to read values
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
- 4b99ff1: Add a multi-storage scheduler to avoid conflicting store operations when having multiple storage instances open on the same database
- Updated dependencies [67e0968]
- Updated dependencies [ce9ca54]
- Updated dependencies [4b99ff1]
- Updated dependencies [ac5d20d]
- Updated dependencies [9bf7946]
- cojson@0.16.6
## 0.16.5
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [267f689]
- cojson@0.16.5
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.16.2",
"version": "0.17.0",
"main": "dist/index.js",
"type": "module",
"types": "dist/index.d.ts",

View File

@@ -118,3 +118,43 @@ export class CoJsonIDBTransaction {
}
}
}
export function queryIndexedDbStore<T>(
db: IDBDatabase,
storeName: StoreName,
callback: (store: IDBObjectStore) => IDBRequest<T>,
) {
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(storeName, "readonly");
const request = callback(tx.objectStore(storeName));
request.onerror = () => {
reject(request.error);
};
request.onsuccess = () => {
resolve(request.result as T);
tx.commit();
};
});
}
export function putIndexedDbStore<T, O extends IDBValidKey>(
db: IDBDatabase,
storeName: StoreName,
value: T,
) {
return new Promise<O>((resolve, reject) => {
const tx = db.transaction(storeName, "readwrite");
const request = tx.objectStore(storeName).put(value);
request.onerror = () => {
reject(request.error);
};
request.onsuccess = () => {
resolve(request.result as O);
tx.commit();
};
});
}

View File

@@ -8,7 +8,12 @@ import type {
StoredSessionRow,
TransactionRow,
} from "cojson";
import { CoJsonIDBTransaction } from "./CoJsonIDBTransaction.js";
import {
CoJsonIDBTransaction,
putIndexedDbStore,
queryIndexedDbStore,
} from "./CoJsonIDBTransaction.js";
import { StoreName } from "./CoJsonIDBTransaction.js";
export class IDBClient implements DBClientInterfaceAsync {
private db;
@@ -39,17 +44,18 @@ export class IDBClient implements DBClientInterfaceAsync {
}
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
return this.makeRequest<StoredCoValueRow | undefined>((tx) =>
tx.getObjectStore("coValues").index("coValuesById").get(coValueId),
return queryIndexedDbStore(this.db, "coValues", (store) =>
store.index("coValuesById").get(coValueId),
);
}
async getCoValueRowID(coValueId: RawCoID): Promise<number | undefined> {
return this.getCoValue(coValueId).then((row) => row?.rowID);
}
async getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]> {
return this.makeRequest<StoredSessionRow[]>((tx) =>
tx
.getObjectStore("sessions")
.index("sessionsByCoValue")
.getAll(coValueRowId),
return queryIndexedDbStore(this.db, "sessions", (store) =>
store.index("sessionsByCoValue").getAll(coValueRowId),
);
}
@@ -57,11 +63,8 @@ export class IDBClient implements DBClientInterfaceAsync {
coValueRowId: number,
sessionID: SessionID,
): Promise<StoredSessionRow | undefined> {
return this.makeRequest<StoredSessionRow>((tx) =>
tx
.getObjectStore("sessions")
.index("uniqueSessions")
.get([coValueRowId, sessionID]),
return queryIndexedDbStore(this.db, "sessions", (store) =>
store.index("uniqueSessions").get([coValueRowId, sessionID]),
);
}
@@ -70,12 +73,10 @@ export class IDBClient implements DBClientInterfaceAsync {
fromIdx: number,
toIdx: number,
): Promise<TransactionRow[]> {
return this.makeRequest<TransactionRow[]>((tx) =>
tx
.getObjectStore("transactions")
.getAll(
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
),
return queryIndexedDbStore(this.db, "transactions", (store) =>
store.getAll(
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
),
);
}
@@ -83,32 +84,28 @@ export class IDBClient implements DBClientInterfaceAsync {
sessionRowId: number,
firstNewTxIdx: number,
): Promise<SignatureAfterRow[]> {
return this.makeRequest<SignatureAfterRow[]>((tx) =>
tx
.getObjectStore("signatureAfter")
.getAll(
IDBKeyRange.bound(
[sessionRowId, firstNewTxIdx],
[sessionRowId, Number.POSITIVE_INFINITY],
),
return queryIndexedDbStore(this.db, "signatureAfter", (store) =>
store.getAll(
IDBKeyRange.bound(
[sessionRowId, firstNewTxIdx],
[sessionRowId, Number.POSITIVE_INFINITY],
),
),
);
}
async addCoValue(
msg: CojsonInternalTypes.NewContentMessage,
): Promise<number> {
if (!msg.header) {
throw new Error(`Header is required, coId: ${msg.id}`);
async upsertCoValue(
id: RawCoID,
header?: CojsonInternalTypes.CoValueHeader,
): Promise<number | undefined> {
if (!header) {
return this.getCoValueRowID(id);
}
return (await this.makeRequest<IDBValidKey>((tx) =>
tx.getObjectStore("coValues").put({
id: msg.id,
// biome-ignore lint/style/noNonNullAssertion: TODO(JAZZ-561): Review
header: msg.header!,
} satisfies CoValueRow),
)) as number;
return putIndexedDbStore<CoValueRow, number>(this.db, "coValues", {
id,
header,
}).catch(() => this.getCoValueRowID(id));
}
async addSessionUpdate({

View File

@@ -179,8 +179,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 5",
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> CONTENT Group header: false new: After: 3 New: 2",
"client -> CONTENT Map header: true new: After: 0 New: 1",
]
`);
@@ -527,9 +528,8 @@ test("large coValue upload streaming", async () => {
[
"client -> LOAD Map sessions: empty",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> CONTENT Map header: true new: After: 0 New: 97",
"storage -> CONTENT Map header: true new: After: 97 New: 97",
"storage -> CONTENT Map header: true new: After: 194 New: 6",
"storage -> CONTENT Map header: true new: After: 0 New: 193",
"storage -> CONTENT Map header: true new: After: 193 New: 7",
]
`);
});
@@ -561,9 +561,10 @@ test("should sync and load accounts from storage", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Account header: true new: After: 0 New: 4",
"client -> CONTENT Account header: true new: After: 0 New: 3",
"client -> CONTENT ProfileGroup header: true new: After: 0 New: 5",
"client -> CONTENT Profile header: true new: After: 0 New: 1",
"client -> CONTENT Account header: false new: After: 3 New: 1",
]
`);

View File

@@ -36,12 +36,11 @@ export function trackMessages() {
};
StorageApiAsync.prototype.store = async function (data, correctionCallback) {
for (const msg of data) {
messages.push({
from: "client",
msg,
});
}
messages.push({
from: "client",
msg: data,
});
return originalStore.call(this, data, (msg) => {
messages.push({
from: "storage",
@@ -51,7 +50,18 @@ export function trackMessages() {
...msg,
},
});
correctionCallback(msg);
const correctionMessages = correctionCallback(msg);
if (correctionMessages) {
for (const msg of correctionMessages) {
messages.push({
from: "client",
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -1,5 +1,45 @@
# cojson-storage-sqlite
## 0.17.0
### Patch Changes
- cojson@0.17.0
## 0.16.6
### Patch Changes
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
- Updated dependencies [67e0968]
- Updated dependencies [ce9ca54]
- Updated dependencies [4b99ff1]
- Updated dependencies [ac5d20d]
- Updated dependencies [9bf7946]
- cojson@0.16.6
## 0.16.5
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [267f689]
- cojson@0.16.5
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.16.2",
"version": "0.17.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -211,8 +211,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 5",
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> CONTENT Group header: false new: After: 3 New: 2",
"client -> CONTENT Map header: true new: After: 0 New: 1",
]
`);
@@ -374,6 +375,8 @@ test("should recover from data loss", async () => {
[
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT Map header: true new: After: 0 New: 1",
"client -> CONTENT Map header: false new: After: 3 New: 1",
"storage -> KNOWN CORRECTION Map sessions: header/4",
"client -> CONTENT Map header: false new: After: 1 New: 3",
]
`);
@@ -455,10 +458,7 @@ test("should recover missing dependencies from storage", async () => {
data,
correctionCallback,
) {
if (
data[0]?.id &&
[group.core.id, account.core.id as string].includes(data[0].id)
) {
if ([group.core.id, account.core.id as string].includes(data.id)) {
return false;
}
@@ -658,9 +658,8 @@ test("large coValue upload streaming", async () => {
[
"client -> LOAD Map sessions: empty",
"storage -> CONTENT Group header: true new: After: 0 New: 3",
"storage -> CONTENT Map header: true new: After: 0 New: 97",
"storage -> CONTENT Map header: true new: After: 97 New: 97",
"storage -> CONTENT Map header: true new: After: 194 New: 6",
"storage -> CONTENT Map header: true new: After: 0 New: 193",
"storage -> CONTENT Map header: true new: After: 193 New: 7",
]
`);
});

View File

@@ -36,12 +36,11 @@ export function trackMessages() {
};
StorageApiSync.prototype.store = function (data, correctionCallback) {
for (const msg of data) {
messages.push({
from: "client",
msg,
});
}
messages.push({
from: "client",
msg: data,
});
return originalStore.call(this, data, (msg) => {
messages.push({
from: "storage",
@@ -51,7 +50,19 @@ export function trackMessages() {
...msg,
},
});
correctionCallback(msg);
const correctionMessages = correctionCallback(msg);
if (correctionMessages) {
for (const msg of correctionMessages) {
messages.push({
from: "client",
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -1,5 +1,45 @@
# cojson-transport-nodejs-ws
## 0.17.0
### Patch Changes
- cojson@0.17.0
## 0.16.6
### Patch Changes
- ac5d20d: Add ingress and egress metering
- Updated dependencies [67e0968]
- Updated dependencies [ce9ca54]
- Updated dependencies [4b99ff1]
- Updated dependencies [ac5d20d]
- Updated dependencies [9bf7946]
- cojson@0.16.6
## 0.16.5
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [267f689]
- cojson@0.16.5
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes

View File

@@ -1,11 +1,12 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.16.2",
"version": "0.17.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"cojson": "workspace:*"
},
"scripts": {
@@ -17,8 +18,9 @@
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist"
},
"devDependencies": {
"typescript": "catalog:",
"@opentelemetry/sdk-metrics": "^2.0.0",
"@types/ws": "8.5.10",
"typescript": "catalog:",
"ws": "^8.14.2"
}
}

View File

@@ -1,3 +1,4 @@
import { ValueType, metrics } from "@opentelemetry/api";
import type { DisconnectedError, SyncMessage } from "cojson";
import type { Peer } from "cojson";
import {
@@ -15,7 +16,7 @@ import {
waitForWebSocketOpen,
} from "./utils.js";
const { CO_VALUE_PRIORITY } = cojsonInternals;
const { CO_VALUE_PRIORITY, getContentMessageSize } = cojsonInternals;
export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000;
@@ -26,11 +27,22 @@ export class BatchedOutgoingMessages
private queue: PriorityBasedMessageQueue;
private processing = false;
private closed = false;
private counter = metrics
.getMeter("cojson-transport-ws")
.createCounter("jazz.usage.egress", {
description: "Total egress bytes",
unit: "bytes",
valueType: ValueType.INT,
});
constructor(
private websocket: AnyWebSocket,
private batching: boolean,
peerRole: Peer["role"],
/**
* Additional key-value pair of attributes to add to the egress metric.
*/
private meta?: Record<string, string | number>,
) {
this.queue = new PriorityBasedMessageQueue(
CO_VALUE_PRIORITY.HIGH,
@@ -39,6 +51,9 @@ export class BatchedOutgoingMessages
peerRole: peerRole,
},
);
// Initialize the counter by adding 0
this.counter.add(0, this.meta);
}
push(msg: SyncMessage | DisconnectedError) {
@@ -93,7 +108,11 @@ export class BatchedOutgoingMessages
this.processing = false;
}
processMessage(msg: SyncMessage) {
private processMessage(msg: SyncMessage) {
if (msg.action === "content") {
this.counter.add(getContentMessageSize(msg), this.meta);
}
if (!this.batching) {
this.websocket.send(JSON.stringify(msg));
return;
@@ -116,7 +135,7 @@ export class BatchedOutgoingMessages
}
}
sendMessagesInBulk() {
private sendMessagesInBulk() {
if (this.backlog.length > 0 && isWebSocketOpen(this.websocket)) {
this.websocket.send(this.backlog);
this.backlog = "";

View File

@@ -1,9 +1,10 @@
import { ValueType, metrics } from "@opentelemetry/api";
import { type Peer, type SyncMessage, cojsonInternals, logger } from "cojson";
import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
import { deserializeMessages } from "./serialization.js";
import type { AnyWebSocket } from "./types.js";
const { ConnectedPeerChannel } = cojsonInternals;
const { ConnectedPeerChannel, getContentMessageSize } = cojsonInternals;
export type CreateWebSocketPeerOpts = {
id: string;
@@ -15,6 +16,10 @@ export type CreateWebSocketPeerOpts = {
pingTimeout?: number;
onClose?: () => void;
onSuccess?: () => void;
/**
* Additional key-value attributes to add to the ingress metric.
*/
meta?: Record<string, string | number>;
};
function createPingTimeoutListener(
@@ -64,7 +69,19 @@ export function createWebSocketPeer({
pingTimeout = 10_000,
onSuccess,
onClose,
meta,
}: CreateWebSocketPeerOpts): Peer {
const totalIngressBytesCounter = metrics
.getMeter("cojson-transport-ws")
.createCounter("jazz.usage.ingress", {
description: "Total ingress bytes from peer",
unit: "bytes",
valueType: ValueType.INT,
});
// Initialize the counter by adding 0
totalIngressBytesCounter.add(0, meta);
const incoming = new ConnectedPeerChannel();
const emitClosedEvent = createClosedEventEmitter(onClose);
@@ -101,6 +118,7 @@ export function createWebSocketPeer({
websocket,
batchingByDefault,
role,
meta,
);
let isFirstMessage = true;
@@ -135,6 +153,10 @@ export function createWebSocketPeer({
for (const msg of messages) {
if (msg && "action" in msg) {
incoming.push(msg);
if (msg.action === "content") {
totalIngressBytesCounter.add(getContentMessageSize(msg), meta);
}
}
}
}

View File

@@ -0,0 +1,83 @@
import type { SyncMessage } from "cojson";
import { type Mocked, afterEach, describe, expect, test, vi } from "vitest";
import { BatchedOutgoingMessages } from "../BatchedOutgoingMessages";
import type { AnyWebSocket } from "../types";
import { createTestMetricReader, tearDownTestMetricReader } from "./utils.js";
describe("BatchedOutgoingMessages", () => {
describe("telemetry", () => {
afterEach(() => {
tearDownTestMetricReader();
});
test("should correctly measure egress", async () => {
const metricReader = createTestMetricReader();
const mockWebSocket = {
readyState: 1,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
close: vi.fn(),
send: vi.fn(),
} as unknown as Mocked<AnyWebSocket>;
const outgoing = new BatchedOutgoingMessages(
mockWebSocket,
true,
"server",
{ test: "test" },
);
const encryptedChanges = "Hello, world!";
vi.useFakeTimers();
outgoing.push({
action: "content",
new: {
someSessionId: {
newTransactions: [
{
privacy: "private",
encryptedChanges,
},
],
},
},
} as unknown as SyncMessage);
await vi.runAllTimersAsync();
vi.useRealTimers();
expect(
await metricReader.getMetricValue("jazz.usage.egress", {
test: "test",
}),
).toBe(encryptedChanges.length);
const trustingChanges = "Jazz is great!";
vi.useFakeTimers();
outgoing.push({
action: "content",
new: {
someSessionId: {
after: 0,
newTransactions: [
{
privacy: "trusting",
changes: trustingChanges,
},
],
},
},
} as unknown as SyncMessage);
await vi.runAllTimersAsync();
vi.useRealTimers();
expect(
await metricReader.getMetricValue("jazz.usage.egress", {
test: "test",
}),
).toBe(encryptedChanges.length + trustingChanges.length);
});
});
});

View File

@@ -1,6 +1,6 @@
import type { CojsonInternalTypes, SyncMessage } from "cojson";
import { cojsonInternals } from "cojson";
import { type Mocked, describe, expect, test, vi } from "vitest";
import { type Mocked, afterEach, describe, expect, test, vi } from "vitest";
import { MAX_OUTGOING_MESSAGES_CHUNK_BYTES } from "../BatchedOutgoingMessages.js";
import {
type CreateWebSocketPeerOpts,
@@ -8,6 +8,7 @@ import {
} from "../createWebSocketPeer.js";
import type { AnyWebSocket } from "../types.js";
import { BUFFER_LIMIT, BUFFER_LIMIT_POLLING_INTERVAL } from "../utils.js";
import { createTestMetricReader, tearDownTestMetricReader } from "./utils.js";
const { CO_VALUE_PRIORITY } = cojsonInternals;
@@ -520,6 +521,89 @@ describe("createWebSocketPeer", () => {
);
});
});
describe("telemetry", () => {
afterEach(() => {
tearDownTestMetricReader();
});
test("should initialize to 0 when creating a websocket peer", async () => {
const metricReader = createTestMetricReader();
setup({
meta: { test: "test" },
});
const measuredIngress = await metricReader.getMetricValue(
"jazz.usage.ingress",
{
test: "test",
},
);
expect(measuredIngress).toBe(0);
});
test("should correctly measure incoming ingress", async () => {
const metricReader = createTestMetricReader();
const { listeners } = setup({
meta: { label: "value" },
});
const messageHandler = listeners.get("message");
const encryptedChanges = "Hello, world!";
messageHandler?.(
new MessageEvent("message", {
data: JSON.stringify({
action: "content",
new: {
someSessionId: {
after: 0,
newTransactions: [
{
privacy: "private" as const,
madeAt: 0,
keyUsed: "key_zkey" as const,
encryptedChanges,
},
],
},
},
}),
}),
);
expect(
await metricReader.getMetricValue("jazz.usage.ingress", {
label: "value",
}),
).toBe(encryptedChanges.length);
const trustingChanges = "Jazz is great!";
messageHandler?.(
new MessageEvent("message", {
data: JSON.stringify({
action: "content",
new: {
someSessionId: {
newTransactions: [
{
privacy: "trusting",
changes: trustingChanges,
},
],
},
},
}),
}),
);
expect(
await metricReader.getMetricValue("jazz.usage.ingress", {
label: "value",
}),
).toBe(encryptedChanges.length + trustingChanges.length);
});
});
});
// biome-ignore lint/suspicious/noConfusingVoidType: Test helper

View File

@@ -1,3 +1,12 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
MetricReader,
} from "@opentelemetry/sdk-metrics";
import { expect } from "vitest";
// biome-ignore lint/suspicious/noConfusingVoidType: Test helper
export function waitFor(callback: () => boolean | void) {
return new Promise<void>((resolve, reject) => {
@@ -26,3 +35,71 @@ export function waitFor(callback: () => boolean | void) {
}, 100);
});
}
/**
* This is a test metric reader that uses an in-memory metric exporter and exposes a method to get the value of a metric given its name and attributes.
*
* This is useful for testing the values of metrics that are collected by the SDK.
*
* TODO: We may want to rethink how we access metrics (see `getMetricValue` method) to make it more flexible.
*/
class TestMetricReader extends MetricReader {
private _exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE,
);
protected onShutdown(): Promise<void> {
throw new Error("Method not implemented.");
}
protected onForceFlush(): Promise<void> {
throw new Error("Method not implemented.");
}
async getMetricValue(
name: string,
attributes: { [key: string]: string | number } = {},
) {
await this.collectAndExport();
const metric = this._exporter
.getMetrics()[0]
?.scopeMetrics[0]?.metrics.find((m) => m.descriptor.name === name);
const dp = metric?.dataPoints.find(
(dp) => JSON.stringify(dp.attributes) === JSON.stringify(attributes),
);
this._exporter.reset();
return dp?.value;
}
async collectAndExport(): Promise<void> {
const result = await this.collect();
await new Promise<void>((resolve, reject) => {
this._exporter.export(result.resourceMetrics, (result) => {
if (result.error != null) {
reject(result.error);
} else {
resolve();
}
});
});
}
}
export function createTestMetricReader() {
const metricReader = new TestMetricReader();
const success = metrics.setGlobalMeterProvider(
new MeterProvider({
readers: [metricReader],
}),
);
expect(success).toBe(true);
return metricReader;
}
export function tearDownTestMetricReader() {
metrics.disable();
}

View File

@@ -1,5 +1,4 @@
import { assert } from "node:console";
import { ControlledAgent, type CryptoProvider, LocalNode } from "cojson";
import { type CryptoProvider, LocalNode } from "cojson";
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { WebSocket } from "ws";

View File

@@ -1,5 +1,39 @@
# cojson
## 0.17.0
## 0.16.6
### Patch Changes
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
- ce9ca54: Chunk CoPlainText content to avoid generating bg messages when the user past a megabytes of text
- 4b99ff1: Add a multi-storage scheduler to avoid conflicting store operations when having multiple storage instances open on the same database
- ac5d20d: Add ingress and egress metering
- 9bf7946: Added a TTL based optional garbage collection for covalues
## 0.16.5
### Patch Changes
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
Also changes the Group.removeMember method to not return a Promise, because:
- All the locally available child groups are rotated immediately
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
- 267f689: Groups: fix the readkey not being revealed to everyone when doing a key rotation
## 0.16.4
### Patch Changes
- f9d538f: Fix the error raised when extending a group without having child groups loaded
- 802b5a3: Refactor local updates sync to ensure that the changes are synced respecting the insertion order
## 0.16.3
## 0.16.2
## 0.16.1

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.16.2",
"version": "0.17.0",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^2.0.0",
"libsql": "^0.5.13",

View File

@@ -0,0 +1,48 @@
import { CoValueCore } from "./coValueCore/coValueCore.js";
import { GARBAGE_COLLECTOR_CONFIG } from "./config.js";
import { RawCoID } from "./ids.js";
export class GarbageCollector {
private readonly interval: ReturnType<typeof setInterval>;
constructor(private readonly coValues: Map<RawCoID, CoValueCore>) {
this.interval = setInterval(() => {
this.collect();
}, GARBAGE_COLLECTOR_CONFIG.INTERVAL);
}
getCurrentTime() {
return performance.now();
}
trackCoValueAccess({ verified }: CoValueCore) {
if (verified) {
verified.lastAccessed = this.getCurrentTime();
}
}
collect() {
const currentTime = this.getCurrentTime();
for (const coValue of this.coValues.values()) {
const { verified } = coValue;
if (!verified?.lastAccessed) {
continue;
}
const timeSinceLastAccessed = currentTime - verified.lastAccessed;
if (timeSinceLastAccessed > GARBAGE_COLLECTOR_CONFIG.MAX_AGE) {
const unmounted = coValue.unmount();
if (unmounted) {
this.coValues.delete(coValue.id);
}
}
}
}
stop() {
clearInterval(this.interval);
}
}

View File

@@ -1,7 +1,4 @@
import {
AvailableCoValueCore,
CoValueCore,
} from "./coValueCore/coValueCore.js";
import { AvailableCoValueCore } from "./coValueCore/coValueCore.js";
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
import { RawCoList } from "./coValues/coList.js";
import { RawCoMap } from "./coValues/coMap.js";

View File

@@ -0,0 +1,86 @@
import {
CoValueHeader,
Transaction,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import { TRANSACTION_CONFIG } from "./config.js";
import { Signature } from "./crypto/crypto.js";
import { RawCoID, SessionID } from "./ids.js";
import { getPriorityFromHeader } from "./priority.js";
import { NewContentMessage, emptyKnownState } from "./sync.js";
export function createContentMessage(
id: RawCoID,
header: CoValueHeader,
includeHeader = true,
): NewContentMessage {
return {
action: "content",
id,
header: includeHeader ? header : undefined,
priority: getPriorityFromHeader(header),
new: {},
};
}
export function addTransactionToContentMessage(
content: NewContentMessage,
transaction: Transaction,
sessionID: SessionID,
signature: Signature,
txIdx: number,
) {
const sessionContent = content.new[sessionID];
if (sessionContent) {
sessionContent.newTransactions.push(transaction);
sessionContent.lastSignature = signature;
} else {
content.new[sessionID] = {
after: txIdx,
newTransactions: [transaction],
lastSignature: signature,
};
}
}
export function getTransactionSize(transaction: Transaction) {
return transaction.privacy === "private"
? transaction.encryptedChanges.length
: transaction.changes.length;
}
export function exceedsRecommendedSize(
baseSize: number,
transactionSize?: number,
) {
if (transactionSize === undefined) {
return baseSize > TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE;
}
return (
baseSize + transactionSize > TRANSACTION_CONFIG.MAX_RECOMMENDED_TX_SIZE
);
}
export function knownStateFromContent(content: NewContentMessage) {
const knownState = emptyKnownState(content.id);
for (const [sessionID, session] of Object.entries(content.new)) {
knownState.sessions[sessionID as SessionID] =
session.after + session.newTransactions.length;
}
return knownState;
}
export function getContentMessageSize(msg: NewContentMessage) {
return Object.values(msg.new).reduce((acc, sessionNewContent) => {
return (
acc +
sessionNewContent.newTransactions.reduce((acc, tx) => {
return acc + getTransactionSize(tx);
}, 0)
);
}, 0);
}

View File

@@ -1,10 +1,10 @@
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
import { Result, err } from "neverthrow";
import { PeerState } from "../PeerState.js";
import { RawCoValue } from "../coValue.js";
import { ControlledAccountOrAgent, RawAccountID } from "../coValues/account.js";
import { RawGroup } from "../coValues/group.js";
import { CO_VALUE_LOADING_CONFIG, MAX_RECOMMENDED_TX_SIZE } from "../config.js";
import type { PeerState } from "../PeerState.js";
import type { RawCoValue } from "../coValue.js";
import type { ControlledAccountOrAgent } from "../coValues/account.js";
import type { RawGroup } from "../coValues/group.js";
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
import { coreToCoValue } from "../coreToCoValue.js";
import {
CryptoProvider,
@@ -16,25 +16,15 @@ import {
SignerID,
StreamingHash,
} from "../crypto/crypto.js";
import {
RawCoID,
SessionID,
TransactionID,
getParentGroupId,
isParentGroupReference,
} from "../ids.js";
import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { parseJSON, stableStringify } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
import { logger } from "../logger.js";
import {
determineValidTransactions,
isKeyForKeyField,
} from "../permissions.js";
import { determineValidTransactions } from "../permissions.js";
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
import { isAccountID } from "../typeUtils/isAccountID.js";
import { getDependedOnCoValuesFromRawData } from "./utils.js";
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
@@ -53,8 +43,6 @@ export type DecryptedTransaction = {
trusting?: boolean;
};
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
export class CoValueCore {
@@ -81,7 +69,9 @@ export class CoValueCore {
}
private readonly peers = new Map<
PeerID,
| { type: "unknown" | "pending" | "available" | "unavailable" }
| {
type: "unknown" | "pending" | "available" | "unavailable";
}
| {
type: "errored";
error: TryAddTransactionsError;
@@ -90,9 +80,8 @@ export class CoValueCore {
// cached state and listeners
private _cachedContent?: RawCoValue;
private readonly listeners: Set<
(core: CoValueCore, unsub: () => void) => void
> = new Set();
readonly listeners: Set<(core: CoValueCore, unsub: () => void) => void> =
new Set();
private readonly _decryptionCache: {
[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined;
} = {};
@@ -213,6 +202,26 @@ export class CoValueCore {
}
}
unmount() {
// For simplicity, we don't unmount groups and accounts
if (this.verified?.header.ruleset.type === "group") {
return false;
}
if (this.listeners.size > 0) {
return false; // The coValue is still in use
}
this.counter.add(-1, { state: this.loadingState });
if (this.groupInvalidationSubscription) {
this.groupInvalidationSubscription();
this.groupInvalidationSubscription = undefined;
}
return true;
}
markNotFoundInPeer(peerId: PeerID) {
const previousState = this.loadingState;
this.peers.set(peerId, { type: "unavailable" });
@@ -380,7 +389,7 @@ export class CoValueCore {
}
knownStateWithStreaming(): CoValueKnownState {
if (this.isAvailable()) {
if (this.verified) {
return this.verified.knownStateWithStreaming();
} else {
return emptyKnownState(this.id);
@@ -388,7 +397,7 @@ export class CoValueCore {
}
knownState(): CoValueKnownState {
if (this.isAvailable()) {
if (this.verified) {
return this.verified.knownState();
} else {
return emptyKnownState(this.id);
@@ -605,16 +614,23 @@ export class CoValueCore {
)._unsafeUnwrap({ withStackTrace: true });
if (success) {
const session = this.verified.sessions.get(sessionID);
const txIdx = session ? session.transactions.length - 1 : 0;
this.node.syncManager.recordTransactionsSize([transaction], "local");
void this.node.syncManager.requestCoValueSync(this);
this.node.syncManager.syncLocalTransaction(
this.verified,
transaction,
sessionID,
signature,
txIdx,
);
}
return success;
}
getCurrentContent(options?: {
ignorePrivateTransactions: true;
}): RawCoValue {
getCurrentContent(options?: { ignorePrivateTransactions: true }): RawCoValue {
if (!this.verified) {
throw new Error(
"CoValueCore: getCurrentContent called on coValue without verified state",
@@ -759,20 +775,7 @@ export class CoValueCore {
}
if (this.verified.header.ruleset.type === "group") {
const content = expectGroup(this.getCurrentContent());
const currentKeyId = content.getCurrentReadKeyId();
if (!currentKeyId) {
throw new Error("No readKey set");
}
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
return expectGroup(this.getCurrentContent()).getCurrentReadKey();
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.verified.header.ruleset.group)
@@ -784,154 +787,36 @@ export class CoValueCore {
}
}
readKeyCache = new Map<KeyID, KeySecret>();
getReadKey(keyID: KeyID): KeySecret | undefined {
let key = readKeyCache.get(this)?.[keyID];
if (!key) {
key = this.getUncachedReadKey(keyID);
if (key) {
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = key;
}
}
return key;
}
// We want to check the cache here, to skip re-computing the group content
const cachedSecret = this.readKeyCache.get(keyID);
if (cachedSecret) {
return cachedSecret;
}
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
if (!this.verified) {
throw new Error(
"CoValueCore: getUncachedReadKey called on coValue without verified state",
);
}
// Getting the readKey from accounts
if (this.verified.header.ruleset.type === "group") {
const content = expectGroup(
this.getCurrentContent({ ignorePrivateTransactions: true }), // to prevent recursion
);
const keyForEveryone = content.get(`${keyID}_for_everyone`);
if (keyForEveryone) {
return keyForEveryone;
}
// Try to find key revelation for us
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
this.node.currentSessionID,
// load the account without private transactions, because we are here
// to be able to decrypt those
this.getCurrentContent({ ignorePrivateTransactions: true }),
);
// being careful here to avoid recursion
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
? this.id === currentAgentOrAccountID
? this.crypto.getAgentID(this.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
: currentAgentOrAccountID // current account ID
: currentAgentOrAccountID; // current agent ID
const lastReadyKeyEdit = content.lastEditAt(
`${keyID}_for_${lookupAccountOrAgentID}`,
);
if (lastReadyKeyEdit?.value) {
const revealer = lastReadyKeyEdit.by;
const revealerAgent = this.node
.resolveAccountAgent(revealer, "Expected to know revealer")
._unsafeUnwrap({ withStackTrace: true });
const secret = this.crypto.unseal(
lastReadyKeyEdit.value,
this.crypto.getAgentSealerSecret(this.node.agentSecret), // being careful here to avoid recursion
this.crypto.getAgentSealerID(revealerAgent),
{
in: this.id,
tx: lastReadyKeyEdit.tx,
},
);
if (secret) {
return secret as KeySecret;
}
}
// Try to find indirect revelation through previousKeys
for (const co of content.keys()) {
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
const encryptingKeyID = co.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
const encryptedPreviousKey = content.get(co)!;
const secret = this.crypto.decryptKeySecret(
{
encryptedID: keyID,
encryptingID: encryptingKeyID,
encrypted: encryptedPreviousKey,
},
encryptingKeySecret,
);
if (secret) {
return secret as KeySecret;
} else {
logger.warn(
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
);
}
}
}
// try to find revelation to parent group read keys
for (const co of content.keys()) {
if (isParentGroupReference(co)) {
const parentGroupID = getParentGroupId(co);
const parentGroup = this.node.expectCoValueLoaded(
parentGroupID,
"Expected parent group to be loaded",
);
const parentKeys = this.findValidParentKeys(
keyID,
content,
parentGroup,
);
for (const parentKey of parentKeys) {
const revelationForParentKey = content.get(
`${keyID}_for_${parentKey.id}`,
);
if (revelationForParentKey) {
const secret = parentGroup.crypto.decryptKeySecret(
{
encryptedID: keyID,
encryptingID: parentKey.id,
encrypted: revelationForParentKey,
},
parentKey.secret,
);
if (secret) {
return secret as KeySecret;
} else {
logger.warn(
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
);
}
}
}
}
}
return undefined;
return content.getReadKey(keyID);
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.verified.header.ruleset.group)
.getReadKey(keyID);
return expectGroup(
this.node
.expectCoValueLoaded(this.verified.header.ruleset.group)
.getCurrentContent(),
).getReadKey(keyID);
} else {
throw new Error(
"Only groups or values owned by groups have read secrets",
@@ -939,28 +824,6 @@ export class CoValueCore {
}
}
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
for (const co of group.keys()) {
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
const encryptingKeyID = co.split("_for_")[1] as KeyID;
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
validParentKeys.push({
id: encryptingKeyID,
secret: encryptingKeySecret,
});
}
}
return validParentKeys;
}
getGroup(): RawGroup {
if (!this.verified) {
throw new Error(
@@ -1007,9 +870,7 @@ export class CoValueCore {
}
}
waitForSync(options?: {
timeout?: number;
}) {
waitForSync(options?: { timeout?: number }) {
return this.node.syncManager.waitForSync(this.id, options?.timeout);
}

Some files were not shown because too many files have changed in this diff Show More