Compare commits

..

174 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
Guido D'Orsi
5a48c9c44c chore: improve tests titles and add comments 2025-08-01 10:14:24 +02:00
Giordano Ricci
5c98ff4e4f use object.values 2025-07-30 19:24:45 +01: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
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
Giordano Ricci
25be055a51 wip: basica ingress/egress metering 2025-07-29 16:39:34 +01: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
232 changed files with 13641 additions and 3945 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,35 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.109",
"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";
@@ -21,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) {
@@ -29,7 +28,7 @@ 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({
@@ -59,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))

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

@@ -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

@@ -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,41 @@
# 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

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.16.3",
"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,39 @@
# 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

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.16.3",
"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,39 @@
# 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

View File

@@ -1,11 +1,12 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.16.3",
"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,37 @@
# 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

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.16.3",
"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);
}

View File

@@ -2,6 +2,7 @@ import { getGroupDependentKey } from "../ids.js";
import { RawCoID, SessionID } from "../ids.js";
import { Stringified, parseJSON } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { NewContentMessage } from "../sync.js";
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
import { isAccountID } from "../typeUtils/isAccountID.js";
import { CoValueHeader, Transaction } from "./verifiedState.js";

View File

@@ -1,6 +1,10 @@
import { Result, err, ok } from "neverthrow";
import { AnyRawCoValue } from "../coValue.js";
import { MAX_RECOMMENDED_TX_SIZE } from "../config.js";
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
CryptoProvider,
Encrypted,
@@ -14,7 +18,6 @@ import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { Stringified } from "../jsonStringify.js";
import { JsonObject, JsonValue } from "../jsonValue.js";
import { PermissionsDef as RulesetDef } from "../permissions.js";
import { getPriorityFromHeader } from "../priority.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
import { InvalidHashError, InvalidSignatureError } from "./coValueCore.js";
import { TryAddTransactionsError } from "./coValueCore.js";
@@ -62,6 +65,7 @@ export class VerifiedState {
private _cachedKnownState?: CoValueKnownState;
private _cachedNewContentSinceEmpty: NewContentMessage[] | undefined;
private streamingKnownState?: CoValueKnownState["sessions"];
public lastAccessed: number | undefined;
constructor(
id: RawCoID,
@@ -151,6 +155,17 @@ export class VerifiedState {
return ok(true as const);
}
getLastSignatureCheckpoint(sessionID: SessionID): number {
const sessionLog = this.sessions.get(sessionID);
if (!sessionLog?.signatureAfter) return -1;
return Object.keys(sessionLog.signatureAfter).reduce(
(max, idx) => Math.max(max, parseInt(idx)),
-1,
);
}
private doAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
@@ -165,24 +180,14 @@ export class VerifiedState {
}
const signatureAfter = sessionLog?.signatureAfter ?? {};
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
-1,
);
const lastInbetweenSignatureIdx =
this.getLastSignatureCheckpoint(sessionID);
const sizeOfTxsSinceLastInbetweenSignature = transactions
.slice(lastInbetweenSignatureIdx + 1)
.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
.reduce((sum, tx) => sum + getTransactionSize(tx), 0);
if (sizeOfTxsSinceLastInbetweenSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(sizeOfTxsSinceLastInbetweenSignature)) {
signatureAfter[transactions.length - 1] = newSignature;
}
@@ -242,13 +247,11 @@ export class VerifiedState {
return this._cachedNewContentSinceEmpty;
}
let currentPiece: NewContentMessage = {
action: "content",
id: this.id,
header: knownState?.header ? undefined : this.header,
priority: getPriorityFromHeader(this.header),
new: {},
};
let currentPiece: NewContentMessage = createContentMessage(
this.id,
this.header,
!knownState?.header,
);
const pieces = [currentPiece];
@@ -299,25 +302,16 @@ export class VerifiedState {
const oldPieceSize = pieceSize;
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
const tx = log.transactions[txIdx]!;
pieceSize +=
tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length;
pieceSize += getTransactionSize(tx);
}
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(pieceSize)) {
if (!currentPiece.expectContentUntil && pieces.length === 1) {
currentPiece.expectContentUntil =
this.knownStateWithStreaming().sessions;
}
currentPiece = {
action: "content",
id: this.id,
header: undefined,
new: {},
priority: getPriorityFromHeader(this.header),
};
currentPiece = createContentMessage(this.id, this.header, false);
pieces.push(currentPiece);
pieceSize = pieceSize - oldPieceSize;
}

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