Compare commits
202 Commits
jazz-bette
...
jazz-bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c6e31711 | ||
|
|
0b16085f3c | ||
|
|
e53db2e96a | ||
|
|
384f0e23c0 | ||
|
|
daaf1789d9 | ||
|
|
1f9e20e753 | ||
|
|
ce9ca54f5c | ||
|
|
67e0968809 | ||
|
|
96a922cceb | ||
|
|
0a98b826f1 | ||
|
|
62a3854c41 | ||
|
|
7bdb6f4279 | ||
|
|
4b99ff1fe3 | ||
|
|
3ebf8258a0 | ||
|
|
4809d14f6d | ||
|
|
5ae1f33127 | ||
|
|
ca5d84f6a9 | ||
|
|
6e6acc3404 | ||
|
|
b17b7b6481 | ||
|
|
5341646301 | ||
|
|
5416165d28 | ||
|
|
b5a9f681c5 | ||
|
|
7dffc006eb | ||
|
|
cd3cc5b0ab | ||
|
|
ceab75eb4d | ||
|
|
103d1b41f7 | ||
|
|
b87cc6973e | ||
|
|
3d541ca241 | ||
|
|
e72bfec884 | ||
|
|
19c7ad27d9 | ||
|
|
0bc7bfc5cc | ||
|
|
2c8120d46f | ||
|
|
c936c8c611 | ||
|
|
58c6013770 | ||
|
|
3eb3291a97 | ||
|
|
6b659f2df3 | ||
|
|
dcc9c9a5ec | ||
|
|
fe9a244363 | ||
|
|
9440bbc058 | ||
|
|
1c92cc2997 | ||
|
|
33ebbf0bdd | ||
|
|
d630b5bde5 | ||
|
|
1c6ae12cd9 | ||
|
|
ac5d20d159 | ||
|
|
21bcaabd5a | ||
|
|
17b4d5b668 | ||
|
|
3cd15862d5 | ||
|
|
b3d1ad7201 | ||
|
|
d87df11795 | ||
|
|
82c2a62b2a | ||
|
|
0a9112506e | ||
|
|
fbc29f2f17 | ||
|
|
3915bbbf3c | ||
|
|
0b471c4e89 | ||
|
|
09077d37ef | ||
|
|
afe06b4fa6 | ||
|
|
d89b6e488a | ||
|
|
f6361ee43b | ||
|
|
726dbfb6df | ||
|
|
267f689f10 | ||
|
|
893ad3ae23 | ||
|
|
f5590b1be8 | ||
|
|
17a01f57e8 | ||
|
|
7318d86f52 | ||
|
|
1c8403e87a | ||
|
|
dd747c068a | ||
|
|
1f0f230fe2 | ||
|
|
da655cbff5 | ||
|
|
02f6c6220e | ||
|
|
0755cd198e | ||
|
|
c4a8227b66 | ||
|
|
86f0302233 | ||
|
|
a5ece15797 | ||
|
|
9f8877202e | ||
|
|
d190097ed9 | ||
|
|
9841617c66 | ||
|
|
165a6170cd | ||
|
|
5148419df9 | ||
|
|
fc0ecb0968 | ||
|
|
802b5a3060 | ||
|
|
e47af262b3 | ||
|
|
688a4850a4 | ||
|
|
e87fef751e | ||
|
|
8f714440f8 | ||
|
|
70cd09170e | ||
|
|
e98b610fd0 | ||
|
|
b554983558 | ||
|
|
4c63334299 | ||
|
|
4aef7cdac5 | ||
|
|
76adeb0d53 | ||
|
|
d95dcbe7db | ||
|
|
f9d538f049 | ||
|
|
40c7336c09 | ||
|
|
e0d2723615 | ||
|
|
93e68c62f5 | ||
|
|
dadee9dcc5 | ||
|
|
6724c4bd83 | ||
|
|
1942bd5de4 | ||
|
|
16764f6365 | ||
|
|
b56cfc2e1f | ||
|
|
7091bcf9c0 | ||
|
|
436cbfa095 | ||
|
|
c19a25f928 | ||
|
|
104e664bbb | ||
|
|
f199b451eb | ||
|
|
70bc48458e | ||
|
|
f28b2a6135 | ||
|
|
55b770b7c9 | ||
|
|
e6838dfb98 | ||
|
|
5e34061fdc | ||
|
|
6d9b77195a | ||
|
|
9bf7946ee6 | ||
|
|
acecffaeb2 | ||
|
|
0a98d6aaf2 | ||
|
|
4ea1a63a0a | ||
|
|
41a4c3bc95 | ||
|
|
60d0027f9d | ||
|
|
748c2ff751 | ||
|
|
70938b0ab3 | ||
|
|
f2f5b55dbf | ||
|
|
3c3acae803 | ||
|
|
896ee3460f | ||
|
|
9b9bf44e2b | ||
|
|
392aa88d95 | ||
|
|
7ce82cd934 | ||
|
|
0c8158b91c | ||
|
|
5a48c9c44c | ||
|
|
25c56146f5 | ||
|
|
c564fbb02e | ||
|
|
12481e14c2 | ||
|
|
fd2d247ff5 | ||
|
|
9e9ea029b2 | ||
|
|
a0da272dcd | ||
|
|
72fbcc3262 | ||
|
|
f4c8cc858b | ||
|
|
0ab4d7a20d | ||
|
|
5c98ff4e4f | ||
|
|
4cbda689c4 | ||
|
|
771b0ed914 | ||
|
|
79913c3136 | ||
|
|
43d3511d15 | ||
|
|
928ef14086 | ||
|
|
048dd7def0 | ||
|
|
51fcb8a44b | ||
|
|
c5888c39f5 | ||
|
|
2defcfae67 | ||
|
|
873b146d15 | ||
|
|
213de11c3b | ||
|
|
2f24d35471 | ||
|
|
42667c81bb | ||
|
|
1b881cc89f | ||
|
|
af295d816a | ||
|
|
fe8d3497c0 | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
ba9ad295b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
77e3c21cbd | ||
|
|
ab1798c7bd | ||
|
|
26ae69a242 | ||
|
|
25be055a51 | ||
|
|
21ad3767b9 | ||
|
|
a9383516c1 | ||
|
|
bffc516c68 | ||
|
|
9e7c0d9887 | ||
|
|
99b44d5780 | ||
|
|
02db5f3b1d | ||
|
|
1949a5fcd9 | ||
|
|
dcd3b022cc | ||
|
|
a7b837c7e1 | ||
|
|
88ebcf58ab | ||
|
|
b173e0884a | ||
|
|
f379a168be | ||
|
|
bde6ac7d45 | ||
|
|
231947c97a | ||
|
|
d5b57ad1fc | ||
|
|
0bf5c53bec | ||
|
|
e7b1550003 | ||
|
|
f5039cefc1 | ||
|
|
239da90c9f | ||
|
|
972791e7a8 | ||
|
|
6540893caf | ||
|
|
bfc85c4573 | ||
|
|
e9076313ab | ||
|
|
c6afd8ae36 | ||
|
|
370f20d13d | ||
|
|
f9b3116deb | ||
|
|
352d34979f | ||
|
|
7ff736ace4 | ||
|
|
5bab466fd0 | ||
|
|
329b8c3d6a | ||
|
|
c0aeb7baf9 | ||
|
|
8a14de10d7 | ||
|
|
b585b39a86 | ||
|
|
e9b2860e74 | ||
|
|
6327d74f68 | ||
|
|
bedbabdcb4 | ||
|
|
6b662b0efe | ||
|
|
a8b3ec7bb0 |
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Setup Biome
|
- name: Setup Biome
|
||||||
uses: biomejs/setup-biome@v2
|
uses: biomejs/setup-biome@v2
|
||||||
with:
|
with:
|
||||||
version: 1.9.4
|
version: 2.1.3
|
||||||
- name: Run Biome
|
- name: Run Biome
|
||||||
run: biome ci .
|
run: biome ci .
|
||||||
|
|
||||||
|
|||||||
47
biome.json
47
biome.json
@@ -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": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@@ -7,39 +7,35 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"ignore": [
|
"includes": [
|
||||||
"jazz-tools.json",
|
"**",
|
||||||
"**/ios/**",
|
"!**/jazz-tools.json",
|
||||||
"**/android/**",
|
"!**/ios/**",
|
||||||
"tests/jazz-svelte/src/**",
|
"!**/android/**",
|
||||||
"examples/*svelte*/**",
|
"!**/tests/jazz-svelte/src/**",
|
||||||
"starters/*svelte*/**",
|
"!**/examples/**/*svelte*/**",
|
||||||
"examples/server-worker-inbox/src/routeTree.gen.ts",
|
"!**/starters/**/*svelte*/**",
|
||||||
"homepage/homepage/**",
|
"!**/examples/server-worker-inbox/src/routeTree.gen.ts",
|
||||||
"**/package.json"
|
"!**/homepage/homepage/**",
|
||||||
|
"!**/package.json",
|
||||||
|
"!**/*svelte*/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space"
|
"indentStyle": "space"
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
"assist": { "actions": { "source": { "organizeImports": "off" } } },
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"correctness": {
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off",
|
||||||
"useImportExtensions": {
|
"useImportExtensions": {
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"options": {
|
"options": {
|
||||||
"suggestedExtensions": {
|
"forceJsExtensions": true
|
||||||
"ts": {
|
|
||||||
"module": "js",
|
|
||||||
"component": "jsx"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +43,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"include": ["packages/**/src/**"],
|
"includes": ["**/packages/**/src/**"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -56,7 +52,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": ["packages/cojson/src/storage/*/**", "cojson-transport-ws/**"],
|
"includes": [
|
||||||
|
"**/packages/cojson/src/storage/**/*/**",
|
||||||
|
"**/cojson-transport-ws/**"
|
||||||
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": ["**/tests/**"],
|
"includes": ["**/tests/**"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"correctness": {
|
"correctness": {
|
||||||
@@ -75,7 +74,7 @@
|
|||||||
"noNonNullAssertion": "off"
|
"noNonNullAssertion": "off"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "info"
|
"noExplicitAny": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ export function ChatScreen({ navigation }: { navigation: any }) {
|
|||||||
|
|
||||||
const renderMessageItem = ({
|
const renderMessageItem = ({
|
||||||
item,
|
item,
|
||||||
}: { item: Loaded<typeof Message, { text: true }> }) => {
|
}: {
|
||||||
|
item: Loaded<typeof Message, { text: true }>;
|
||||||
|
}) => {
|
||||||
const isMe = item._edits?.text?.by?.isMe;
|
const isMe = item._edits?.text?.by?.isMe;
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import React from "react";
|
|||||||
import { Text } from "react-native";
|
import { Text } from "react-native";
|
||||||
import { Chat } from "./schema";
|
import { Chat } from "./schema";
|
||||||
|
|
||||||
export function HandleInviteScreen({
|
export function HandleInviteScreen({ navigation }: { navigation: any }) {
|
||||||
navigation,
|
|
||||||
}: {
|
|
||||||
navigation: any;
|
|
||||||
}) {
|
|
||||||
useAcceptInviteNative({
|
useAcceptInviteNative({
|
||||||
invitedObjectSchema: Chat,
|
invitedObjectSchema: Chat,
|
||||||
onAccept: async (chatId) => {
|
onAccept: async (chatId) => {
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
# passkey-svelte
|
# passkey-svelte
|
||||||
|
|
||||||
|
## 0.0.112
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [67e0968]
|
||||||
|
- Updated dependencies [2c8120d]
|
||||||
|
- jazz-tools@0.16.6
|
||||||
|
|
||||||
|
## 0.0.111
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3cd1586]
|
||||||
|
- Updated dependencies [33ebbf0]
|
||||||
|
- jazz-tools@0.16.5
|
||||||
|
|
||||||
|
## 0.0.110
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [16764f6]
|
||||||
|
- jazz-tools@0.16.4
|
||||||
|
|
||||||
|
## 0.0.109
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [43d3511]
|
||||||
|
- jazz-tools@0.16.3
|
||||||
|
|
||||||
|
## 0.0.108
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- jazz-tools@0.16.2
|
||||||
|
|
||||||
## 0.0.107
|
## 0.0.107
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "chat-svelte",
|
"name": "chat-svelte",
|
||||||
"version": "0.0.107",
|
"version": "0.0.112",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"hash-slash": "workspace:*",
|
"hash-slash": "workspace:*",
|
||||||
"jazz-tools": "workspace:*",
|
"jazz-tools": "workspace:*",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.536.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Account, co } from "jazz-tools";
|
import { Account } from "jazz-tools";
|
||||||
import { createImage, useAccount, useCoState } from "jazz-tools/react";
|
import { createImage, useAccount, useCoState } from "jazz-tools/react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Chat, Message } from "./schema.ts";
|
import { Chat, Message } from "./schema.ts";
|
||||||
import {
|
import {
|
||||||
BubbleBody,
|
BubbleBody,
|
||||||
@@ -15,14 +15,17 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "./ui.tsx";
|
} from "./ui.tsx";
|
||||||
|
|
||||||
export function ChatScreen(props: { chatID: string }) {
|
const INITIAL_MESSAGES_TO_SHOW = 30;
|
||||||
const chat = useCoState(Chat, props.chatID, {
|
|
||||||
resolve: { $each: { text: true } },
|
|
||||||
});
|
|
||||||
const { me } = useAccount();
|
|
||||||
const [showNLastMessages, setShowNLastMessages] = useState(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 (
|
return (
|
||||||
<div className="flex-1 flex justify-center items-center">Loading...</div>
|
<div className="flex-1 flex justify-center items-center">Loading...</div>
|
||||||
);
|
);
|
||||||
@@ -41,7 +44,7 @@ export function ChatScreen(props: { chatID: string }) {
|
|||||||
chat.push(
|
chat.push(
|
||||||
Message.create(
|
Message.create(
|
||||||
{
|
{
|
||||||
text: co.plainText().create(file.name, chat._owner),
|
text: file.name,
|
||||||
image: image,
|
image: image,
|
||||||
},
|
},
|
||||||
chat._owner,
|
chat._owner,
|
||||||
@@ -59,9 +62,14 @@ export function ChatScreen(props: { chatID: string }) {
|
|||||||
<ChatBody>
|
<ChatBody>
|
||||||
{chat.length > 0 ? (
|
{chat.length > 0 ? (
|
||||||
chat
|
chat
|
||||||
|
// We call slice before reverse to avoid mutating the original array
|
||||||
.slice(-showNLastMessages)
|
.slice(-showNLastMessages)
|
||||||
.reverse() // this plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
|
// Reverse plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
|
||||||
.map((msg) => <ChatBubble me={me} msg={msg} key={msg.id} />)
|
.reverse()
|
||||||
|
.map(
|
||||||
|
(msg) =>
|
||||||
|
msg?.text && <ChatBubble me={me} msg={msg} key={msg.id} />,
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<EmptyChatMessage />
|
<EmptyChatMessage />
|
||||||
)}
|
)}
|
||||||
@@ -80,12 +88,7 @@ export function ChatScreen(props: { chatID: string }) {
|
|||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
onSubmit={(text) => {
|
onSubmit={(text) => {
|
||||||
chat.push(
|
chat.push(Message.create({ text }, chat._owner));
|
||||||
Message.create(
|
|
||||||
{ text: co.plainText().create(text, chat._owner) },
|
|
||||||
chat._owner,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</InputBar>
|
</InputBar>
|
||||||
@@ -93,10 +96,7 @@ export function ChatScreen(props: { chatID: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatBubble(props: {
|
function ChatBubble(props: { me: Account; msg: Message }) {
|
||||||
me: Account;
|
|
||||||
msg: co.loaded<typeof Message, { text: true }>;
|
|
||||||
}) {
|
|
||||||
if (!props.me.canRead(props.msg) || !props.msg.text?.toString()) {
|
if (!props.me.canRead(props.msg) || !props.msg.text?.toString()) {
|
||||||
return (
|
return (
|
||||||
<BubbleContainer fromMe={false}>
|
<BubbleContainer fromMe={false}>
|
||||||
@@ -126,3 +126,35 @@ function ChatBubble(props: {
|
|||||||
</BubbleContainer>
|
</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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ export function InputBar(props: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function ImageInput({
|
export function ImageInput({
|
||||||
onImageChange,
|
onImageChange,
|
||||||
}: { onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void }) {
|
}: {
|
||||||
|
onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onUploadClick = () => {
|
const onUploadClick = () => {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
|
|
||||||
export function SignInScreen({
|
export function SignInScreen({
|
||||||
setPage,
|
setPage,
|
||||||
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
|
}: {
|
||||||
|
setPage: (page: "sign-in" | "sign-up") => void;
|
||||||
|
}) {
|
||||||
const { signIn, setActive, isLoaded } = useSignIn();
|
const { signIn, setActive, isLoaded } = useSignIn();
|
||||||
|
|
||||||
const [emailAddress, setEmailAddress] = useState("");
|
const [emailAddress, setEmailAddress] = useState("");
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
|
|
||||||
export function SignUpScreen({
|
export function SignUpScreen({
|
||||||
setPage,
|
setPage,
|
||||||
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
|
}: {
|
||||||
|
setPage: (page: "sign-in" | "sign-up") => void;
|
||||||
|
}) {
|
||||||
const { isLoaded, signUp, setActive } = useSignUp();
|
const { isLoaded, signUp, setActive } = useSignUp();
|
||||||
|
|
||||||
const [emailAddress, setEmailAddress] = React.useState("");
|
const [emailAddress, setEmailAddress] = React.useState("");
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useIframeHashRouter } from "hash-slash";
|
import { useIframeHashRouter } from "hash-slash";
|
||||||
import { Loaded } from "jazz-tools";
|
|
||||||
import { useAccount, useCoState } from "jazz-tools/react";
|
import { useAccount, useCoState } from "jazz-tools/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Errors } from "./Errors.tsx";
|
import { Errors } from "./Errors.tsx";
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
BubbleTeaOrder,
|
BubbleTeaOrder,
|
||||||
DraftBubbleTeaOrder,
|
DraftBubbleTeaOrder,
|
||||||
JazzAccount,
|
JazzAccount,
|
||||||
ListOfBubbleTeaAddOns,
|
|
||||||
validateDraftOrder,
|
validateDraftOrder,
|
||||||
} from "./schema.ts";
|
} from "./schema.ts";
|
||||||
|
|
||||||
@@ -22,7 +20,7 @@ export function CreateOrder() {
|
|||||||
|
|
||||||
if (!me?.root) return;
|
if (!me?.root) return;
|
||||||
|
|
||||||
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
|
const onSave = (draft: DraftBubbleTeaOrder) => {
|
||||||
const validation = validateDraftOrder(draft);
|
const validation = validateDraftOrder(draft);
|
||||||
setErrors(validation.errors);
|
setErrors(validation.errors);
|
||||||
if (validation.errors.length > 0) {
|
if (validation.errors.length > 0) {
|
||||||
@@ -30,11 +28,11 @@ export function CreateOrder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// turn the draft into a real order
|
// 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
|
// reset the draft
|
||||||
me.root.draft = DraftBubbleTeaOrder.create({
|
me.root.draft = DraftBubbleTeaOrder.create({
|
||||||
addOns: ListOfBubbleTeaAddOns.create([]),
|
addOns: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
router.navigate("/");
|
router.navigate("/");
|
||||||
@@ -60,7 +58,7 @@ function CreateOrderForm({
|
|||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onSave: (draft: Loaded<typeof DraftBubbleTeaOrder>) => void;
|
onSave: (draft: DraftBubbleTeaOrder) => void;
|
||||||
}) {
|
}) {
|
||||||
const draft = useCoState(DraftBubbleTeaOrder, id, {
|
const draft = useCoState(DraftBubbleTeaOrder, id, {
|
||||||
resolve: { addOns: true, instructions: true },
|
resolve: { addOns: true, instructions: true },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CoPlainText, Loaded } from "jazz-tools";
|
import { CoPlainText } from "jazz-tools";
|
||||||
import {
|
import {
|
||||||
BubbleTeaAddOnTypes,
|
BubbleTeaAddOnTypes,
|
||||||
BubbleTeaBaseTeaTypes,
|
BubbleTeaBaseTeaTypes,
|
||||||
@@ -10,7 +10,7 @@ export function OrderForm({
|
|||||||
order,
|
order,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
order: Loaded<typeof BubbleTeaOrder> | Loaded<typeof DraftBubbleTeaOrder>;
|
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
// Handles updates to the instructions field of the order.
|
// Handles updates to the instructions field of the order.
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { Loaded } from "jazz-tools";
|
|
||||||
import { BubbleTeaOrder } from "./schema.ts";
|
import { BubbleTeaOrder } from "./schema.ts";
|
||||||
|
|
||||||
export function OrderThumbnail({
|
export function OrderThumbnail({ order }: { order: BubbleTeaOrder }) {
|
||||||
order,
|
|
||||||
}: {
|
|
||||||
order: Loaded<typeof BubbleTeaOrder>;
|
|
||||||
}) {
|
|
||||||
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
|
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
|
||||||
const date = deliveryDate.toLocaleDateString();
|
const date = deliveryDate.toLocaleDateString();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Loaded, co, z } from "jazz-tools";
|
import { co, z } from "jazz-tools";
|
||||||
|
|
||||||
export const BubbleTeaAddOnTypes = [
|
export const BubbleTeaAddOnTypes = [
|
||||||
"Pearl",
|
"Pearl",
|
||||||
@@ -18,8 +18,9 @@ export const BubbleTeaBaseTeaTypes = [
|
|||||||
export const ListOfBubbleTeaAddOns = co.list(
|
export const ListOfBubbleTeaAddOns = co.list(
|
||||||
z.literal([...BubbleTeaAddOnTypes]),
|
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;
|
return list && Object.entries(list._raw.insertions).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,16 +31,12 @@ export const BubbleTeaOrder = co.map({
|
|||||||
withMilk: z.boolean(),
|
withMilk: z.boolean(),
|
||||||
instructions: co.optional(co.plainText()),
|
instructions: co.optional(co.plainText()),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
addOns: co.optional(ListOfBubbleTeaAddOns),
|
|
||||||
deliveryDate: z.optional(z.date()),
|
|
||||||
withMilk: z.optional(z.boolean()),
|
|
||||||
instructions: co.optional(co.plainText()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
|
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!order.baseTea) {
|
if (!order.baseTea) {
|
||||||
@@ -52,7 +49,7 @@ export function validateDraftOrder(order: Loaded<typeof DraftBubbleTeaOrder>) {
|
|||||||
return { errors };
|
return { errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasChanges(order?: Loaded<typeof DraftBubbleTeaOrder> | null) {
|
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
|
||||||
return (
|
return (
|
||||||
!!order &&
|
!!order &&
|
||||||
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
|
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
|
||||||
@@ -73,15 +70,9 @@ export const JazzAccount = co
|
|||||||
})
|
})
|
||||||
.withMigration((account) => {
|
.withMigration((account) => {
|
||||||
if (!account.root) {
|
if (!account.root) {
|
||||||
const orders = co.list(BubbleTeaOrder).create([], account);
|
account.root = AccountRoot.create(
|
||||||
const draft = DraftBubbleTeaOrder.create(
|
{ draft: { addOns: [], instructions: "" }, orders: [] },
|
||||||
{
|
|
||||||
addOns: ListOfBubbleTeaAddOns.create([], account),
|
|
||||||
instructions: co.plainText().create("", account),
|
|
||||||
},
|
|
||||||
account,
|
account,
|
||||||
);
|
);
|
||||||
|
|
||||||
account.root = AccountRoot.create({ draft, orders }, account);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ function Avatar({
|
|||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
active,
|
active,
|
||||||
}: { name: string; color: string; active: boolean }) {
|
}: {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={name}
|
title={name}
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ button {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
cursor: pointer;
|
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);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
@@ -93,8 +95,9 @@ button:active {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
|
box-shadow:
|
||||||
rgba(0, 0, 0, 0.06);
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
width: 28rem;
|
width: 28rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"jazz-tools": "workspace:*",
|
"jazz-tools": "workspace:*",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.536.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-router": "^6.16.0",
|
"react-router": "^6.16.0",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"postcss": "^8.4.27",
|
"postcss": "^8.4.27",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^1.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* You can use it to set up the account root and any other initial CoValues you need.
|
||||||
*/
|
*/
|
||||||
if (account.root === undefined) {
|
if (account.root === undefined) {
|
||||||
const tracks = co.list(MusicTrack).create([]);
|
|
||||||
const rootPlaylist = Playlist.create({
|
const rootPlaylist = Playlist.create({
|
||||||
tracks,
|
tracks: [],
|
||||||
title: "",
|
title: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
account.root = MusicaAccountRoot.create({
|
account.root = MusicaAccountRoot.create({
|
||||||
rootPlaylist,
|
rootPlaylist,
|
||||||
playlists: co.list(Playlist).create([]),
|
playlists: [],
|
||||||
activeTrack: undefined,
|
activeTrack: undefined,
|
||||||
activePlaylist: rootPlaylist,
|
activePlaylist: rootPlaylist,
|
||||||
exampleDataLoaded: false,
|
exampleDataLoaded: false,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SidebarProvider } from "@/components/ui/sidebar";
|
|||||||
import { JazzReactProvider } from "jazz-tools/react";
|
import { JazzReactProvider } from "jazz-tools/react";
|
||||||
import { onAnonymousAccountDiscarded } from "./4_actions";
|
import { onAnonymousAccountDiscarded } from "./4_actions";
|
||||||
import { KeyboardListener } from "./components/PlayerControls";
|
import { KeyboardListener } from "./components/PlayerControls";
|
||||||
import { useUploadExampleData } from "./lib/useUploadExampleData";
|
import { usePrepareAppState } from "./lib/usePrepareAppState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walkthrough: The top-level provider `<JazzReactProvider/>`
|
* Walkthrough: The top-level provider `<JazzReactProvider/>`
|
||||||
@@ -31,7 +31,7 @@ import { useUploadExampleData } from "./lib/useUploadExampleData";
|
|||||||
function Main() {
|
function Main() {
|
||||||
const mediaPlayer = useMediaPlayer();
|
const mediaPlayer = useMediaPlayer();
|
||||||
|
|
||||||
useUploadExampleData();
|
const isReady = usePrepareAppState(mediaPlayer);
|
||||||
|
|
||||||
const router = createHashRouter([
|
const router = createHashRouter([
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,8 @@ function Main() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!isReady) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { uploadMusicTracks } from "./4_actions";
|
|||||||
import { MediaPlayer } from "./5_useMediaPlayer";
|
import { MediaPlayer } from "./5_useMediaPlayer";
|
||||||
import { FileUploadButton } from "./components/FileUploadButton";
|
import { FileUploadButton } from "./components/FileUploadButton";
|
||||||
import { MusicTrackRow } from "./components/MusicTrackRow";
|
import { MusicTrackRow } from "./components/MusicTrackRow";
|
||||||
|
import { PlayerControls } from "./components/PlayerControls";
|
||||||
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
|
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
|
||||||
import { SidePanel } from "./components/SidePanel";
|
import { SidePanel } from "./components/SidePanel";
|
||||||
import { Button } from "./components/ui/button";
|
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 playlistId = params.playlistId ?? me?.root._refs.rootPlaylist.id;
|
||||||
|
|
||||||
const playlist = useCoState(Playlist, playlistId, {
|
const playlist = useCoState(Playlist, playlistId, {
|
||||||
resolve: { tracks: true },
|
resolve: {
|
||||||
|
tracks: {
|
||||||
|
$each: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRootPlaylist = !params.playlistId;
|
const isRootPlaylist = !params.playlistId;
|
||||||
@@ -64,16 +69,16 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<SidePanel mediaPlayer={mediaPlayer} />
|
<SidePanel />
|
||||||
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden">
|
<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 />
|
<SidebarTrigger className="md:hidden" />
|
||||||
<div className="flex items-center justify-between mb-6">
|
<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 ? (
|
{isRootPlaylist ? (
|
||||||
<h1 className="text-2xl font-bold text-blue-800">All tracks</h1>
|
<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">
|
<div className="flex items-center space-x-4">
|
||||||
{isRootPlaylist && (
|
{isRootPlaylist && (
|
||||||
@@ -90,14 +95,14 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col max-w-full">
|
<ul className="flex flex-col max-w-full sm:gap-1">
|
||||||
{playlist?.tracks?.map(
|
{playlist?.tracks?.map(
|
||||||
(track) =>
|
(track, index) =>
|
||||||
track && (
|
track && (
|
||||||
<MusicTrackRow
|
<MusicTrackRow
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
key={track.id}
|
key={track.id}
|
||||||
isLoading={mediaPlayer.loading === track.id}
|
index={index}
|
||||||
isPlaying={
|
isPlaying={
|
||||||
mediaPlayer.activeTrackId === track.id &&
|
mediaPlayer.activeTrackId === track.id &&
|
||||||
isActivePlaylist &&
|
isActivePlaylist &&
|
||||||
@@ -106,12 +111,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
mediaPlayer.setActiveTrack(track, playlist);
|
mediaPlayer.setActiveTrack(track, playlist);
|
||||||
}}
|
}}
|
||||||
showAddToPlaylist={isRootPlaylist}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
|
<PlayerControls mediaPlayer={mediaPlayer} />
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
|
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
|
||||||
import { FileStream, Group, co } from "jazz-tools";
|
import { FileStream, Group } from "jazz-tools";
|
||||||
import {
|
import { MusicTrack, MusicaAccount, Playlist } from "./1_schema";
|
||||||
MusicTrack,
|
|
||||||
MusicTrackWaveform,
|
|
||||||
MusicaAccount,
|
|
||||||
Playlist,
|
|
||||||
} from "./1_schema";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walkthrough: Actions
|
* Walkthrough: Actions
|
||||||
@@ -51,7 +46,7 @@ export async function uploadMusicTracks(
|
|||||||
{
|
{
|
||||||
file: fileStream,
|
file: fileStream,
|
||||||
duration: data.duration,
|
duration: data.duration,
|
||||||
waveform: MusicTrackWaveform.create({ data: data.waveform }, group),
|
waveform: { data: data.waveform },
|
||||||
title: file.name,
|
title: file.name,
|
||||||
isExampleTrack,
|
isExampleTrack,
|
||||||
},
|
},
|
||||||
@@ -73,18 +68,10 @@ export async function createNewPlaylist() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Since playlists are meant to be shared we associate them
|
const playlist = Playlist.create({
|
||||||
// to a group which will contain the keys required to get
|
title: "New Playlist",
|
||||||
// access to the "owned" values
|
tracks: [],
|
||||||
const playlistGroup = Group.create();
|
});
|
||||||
|
|
||||||
const playlist = Playlist.create(
|
|
||||||
{
|
|
||||||
title: "New Playlist",
|
|
||||||
tracks: co.list(MusicTrack).create([], playlistGroup),
|
|
||||||
},
|
|
||||||
playlistGroup,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Again, we associate the new playlist to the
|
// Again, we associate the new playlist to the
|
||||||
// user by pushing it into the playlists CoList
|
// 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") {
|
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||||
const trackGroup = track._owner;
|
const trackGroup = track._owner;
|
||||||
await trackGroup.removeMember(playlist._owner);
|
trackGroup.removeMember(playlist._owner);
|
||||||
|
|
||||||
const index =
|
const index =
|
||||||
playlist.tracks?.findIndex(
|
playlist.tracks?.findIndex(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FileStream } from "jazz-tools";
|
|||||||
import { useAccount } from "jazz-tools/react";
|
import { useAccount } from "jazz-tools/react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { updateActivePlaylist, updateActiveTrack } from "./4_actions";
|
import { updateActivePlaylist, updateActiveTrack } from "./4_actions";
|
||||||
|
import { useAudioManager } from "./lib/audio/AudioManager";
|
||||||
import { getNextTrack, getPrevTrack } from "./lib/getters";
|
import { getNextTrack, getPrevTrack } from "./lib/getters";
|
||||||
|
|
||||||
export function useMediaPlayer() {
|
export function useMediaPlayer() {
|
||||||
@@ -12,6 +13,7 @@ export function useMediaPlayer() {
|
|||||||
resolve: { root: true },
|
resolve: { root: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const audioManager = useAudioManager();
|
||||||
const playState = usePlayState();
|
const playState = usePlayState();
|
||||||
const playMedia = usePlayMedia();
|
const playMedia = usePlayMedia();
|
||||||
|
|
||||||
@@ -24,8 +26,10 @@ export function useMediaPlayer() {
|
|||||||
|
|
||||||
async function loadTrack(track: MusicTrack) {
|
async function loadTrack(track: MusicTrack) {
|
||||||
lastLoadedTrackId.current = track.id;
|
lastLoadedTrackId.current = track.id;
|
||||||
|
audioManager.unloadCurrentAudio();
|
||||||
|
|
||||||
setLoading(track.id);
|
setLoading(track.id);
|
||||||
|
updateActiveTrack(track);
|
||||||
|
|
||||||
const file = await FileStream.loadAsBlob(track._refs.file!.id); // TODO: see if we can avoid !
|
const file = await FileStream.loadAsBlob(track._refs.file!.id); // TODO: see if we can avoid !
|
||||||
|
|
||||||
@@ -40,8 +44,6 @@ export function useMediaPlayer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateActiveTrack(track);
|
|
||||||
|
|
||||||
await playMedia(file);
|
await playMedia(file);
|
||||||
|
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
@@ -85,6 +87,7 @@ export function useMediaPlayer() {
|
|||||||
playNextTrack,
|
playNextTrack,
|
||||||
playPrevTrack,
|
playPrevTrack,
|
||||||
loading,
|
loading,
|
||||||
|
loadTrack,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
examples/music-player/src/components/ConfirmDialog.tsx
Normal file
59
examples/music-player/src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,31 +9,41 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Loaded } from "jazz-tools";
|
import { Loaded } from "jazz-tools";
|
||||||
import { useAccount, useCoState } from "jazz-tools/react";
|
import { useAccount, useCoState } from "jazz-tools/react";
|
||||||
import { MoreHorizontal } from "lucide-react";
|
import { MoreHorizontal, Pause, Play } from "lucide-react";
|
||||||
import { Fragment } from "react/jsx-runtime";
|
import { Fragment, useCallback, useState } from "react";
|
||||||
import { MusicTrackTitleInput } from "./MusicTrackTitleInput";
|
import { EditTrackDialog } from "./RenameTrackDialog";
|
||||||
|
import { Waveform } from "./Waveform";
|
||||||
import { Button } from "./ui/button";
|
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({
|
export function MusicTrackRow({
|
||||||
trackId,
|
trackId,
|
||||||
isLoading,
|
|
||||||
isPlaying,
|
isPlaying,
|
||||||
onClick,
|
onClick,
|
||||||
showAddToPlaylist,
|
index,
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
isLoading: boolean;
|
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
onClick: (track: Loaded<typeof MusicTrack>) => void;
|
onClick: (track: Loaded<typeof MusicTrack>) => void;
|
||||||
showAddToPlaylist: boolean;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
const track = useCoState(MusicTrack, trackId);
|
const track = useCoState(MusicTrack, trackId);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const { me } = useAccount(MusicaAccount, {
|
const { me } = useAccount(MusicaAccount, {
|
||||||
resolve: { root: { playlists: { $each: true } } },
|
resolve: { root: { playlists: { $each: { tracks: true } } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlists = me?.root.playlists ?? [];
|
const playlists = me?.root.playlists ?? [];
|
||||||
|
const isActiveTrack = trackId === me?.root._refs.activeTrack?.id;
|
||||||
|
|
||||||
function handleTrackClick() {
|
function handleTrackClick() {
|
||||||
if (!track) return;
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
|
className={cn(
|
||||||
onClick={handleTrackClick}
|
"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
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center bg-transparent w-8 h-8 ",
|
"flex items-center justify-center bg-transparent w-8 h-8 transition-opacity cursor-pointer",
|
||||||
!isPlaying && "group-hover:bg-slate-300 rounded-full",
|
// 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}
|
onClick={handleTrackClick}
|
||||||
aria-label={`${isPlaying ? "Pause" : "Play"} ${track?.title}`}
|
aria-label={`${isPlaying ? "Pause" : "Play"} ${track?.title}`}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isPlaying ? (
|
||||||
<div className="animate-spin">߷</div>
|
<Pause height={16} width={16} fill="currentColor" />
|
||||||
) : isPlaying ? (
|
|
||||||
"⏸️"
|
|
||||||
) : (
|
) : (
|
||||||
"▶️"
|
<Play height={16} width={16} fill="currentColor" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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()}>
|
<div onClick={(evt) => evt.stopPropagation()}>
|
||||||
{showAddToPlaylist && (
|
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
className="h-8 w-8 p-0"
|
||||||
className="h-8 w-8 p-0"
|
aria-label={`Open ${track?.title} menu`}
|
||||||
aria-label={`Open ${track?.title} menu`}
|
>
|
||||||
>
|
<span className="sr-only">Open menu</span>
|
||||||
<span className="sr-only">Open menu</span>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
{playlists.map((playlist, playlistIndex) => (
|
||||||
key={`delete`}
|
<Fragment key={playlistIndex}>
|
||||||
onSelect={async () => {
|
{isPartOfThePlaylist(trackId, playlist) ? (
|
||||||
if (!track) return;
|
|
||||||
deleteTrack();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{playlists.map((playlist, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={`add-${index}`}
|
key={`remove-${playlistIndex}`}
|
||||||
onSelect={() => handleAddToPlaylist(playlist)}
|
|
||||||
>
|
|
||||||
Add to {playlist.title}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`remove-${index}`}
|
|
||||||
onSelect={() => handleRemoveFromPlaylist(playlist)}
|
onSelect={() => handleRemoveFromPlaylist(playlist)}
|
||||||
>
|
>
|
||||||
Remove from {playlist.title}
|
Remove from {playlist.title}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Fragment>
|
) : (
|
||||||
))}
|
<DropdownMenuItem
|
||||||
</DropdownMenuContent>
|
key={`add-${playlistIndex}`}
|
||||||
</DropdownMenu>
|
onSelect={() => handleAddToPlaylist(playlist)}
|
||||||
)}
|
>
|
||||||
|
Add to {playlist.title}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
{track && isEditDialogOpen && (
|
||||||
|
<EditTrackDialog
|
||||||
|
track={track}
|
||||||
|
isOpen={isEditDialogOpen}
|
||||||
|
onOpenChange={setIsEditDialogOpen}
|
||||||
|
onDelete={deleteTrack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { usePlayState } from "@/lib/audio/usePlayState";
|
|||||||
import { useKeyboardListener } from "@/lib/useKeyboardListener";
|
import { useKeyboardListener } from "@/lib/useKeyboardListener";
|
||||||
import { useAccount, useCoState } from "jazz-tools/react";
|
import { useAccount, useCoState } from "jazz-tools/react";
|
||||||
import { Pause, Play, SkipBack, SkipForward } from "lucide-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 }) {
|
export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||||
const playState = usePlayState();
|
const playState = usePlayState();
|
||||||
@@ -15,51 +16,61 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
resolve: { root: { activePlaylist: true } },
|
resolve: { root: { activePlaylist: true } },
|
||||||
}).me?.root.activePlaylist;
|
}).me?.root.activePlaylist;
|
||||||
|
|
||||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
|
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId);
|
||||||
resolve: { waveform: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!activeTrack) return null;
|
if (!activeTrack) return null;
|
||||||
|
|
||||||
const activeTrackTitle = activeTrack.title;
|
const activeTrackTitle = activeTrack.title;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<div className="flex justify-center items-center space-x-2">
|
{/* Player Controls - Always on top */}
|
||||||
<div className="flex items-center space-x-4">
|
<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">
|
||||||
<button
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={mediaPlayer.playPrevTrack}
|
onClick={mediaPlayer.playPrevTrack}
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
aria-label="Previous track"
|
aria-label="Previous track"
|
||||||
>
|
>
|
||||||
<SkipBack size={20} />
|
<SkipBack className="h-5 w-5" fill="currentColor" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="icon"
|
||||||
onClick={playState.toggle}
|
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"}
|
aria-label={isPlaying ? "Pause active track" : "Play active track"}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{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}
|
onClick={mediaPlayer.playNextTrack}
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
aria-label="Next track"
|
aria-label="Next track"
|
||||||
>
|
>
|
||||||
<SkipForward size={20} />
|
<SkipForward className="h-5 w-5" fill="currentColor" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
|
|
||||||
<Waveform track={activeTrack} height={30} />
|
{/* Waveform - Below controls on mobile, between controls and info on desktop */}
|
||||||
</div>
|
<WaveformCanvas
|
||||||
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
|
className="order-1 sm:order-none"
|
||||||
<h4 className="font-medium text-blue-800">{activeTrackTitle}</h4>
|
track={activeTrack}
|
||||||
<p className="text-sm text-gray-600">
|
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"}
|
{activePlaylist?.title || "All tracks"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +80,9 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
|
|
||||||
export function KeyboardListener({
|
export function KeyboardListener({
|
||||||
mediaPlayer,
|
mediaPlayer,
|
||||||
}: { mediaPlayer: MediaPlayer }) {
|
}: {
|
||||||
|
mediaPlayer: MediaPlayer;
|
||||||
|
}) {
|
||||||
const playState = usePlayState();
|
const playState = usePlayState();
|
||||||
|
|
||||||
useMediaEndListener(mediaPlayer.playNextTrack);
|
useMediaEndListener(mediaPlayer.playNextTrack);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Playlist } from "@/1_schema";
|
import { Playlist } from "@/1_schema";
|
||||||
import { updatePlaylistTitle } from "@/4_actions";
|
import { updatePlaylistTitle } from "@/4_actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { useCoState } from "jazz-tools/react";
|
import { useCoState } from "jazz-tools/react";
|
||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useState } from "react";
|
||||||
|
|
||||||
export function PlaylistTitleInput({
|
export function PlaylistTitleInput({
|
||||||
playlistId,
|
playlistId,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
playlistId: string | undefined;
|
playlistId: string | undefined;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const playlist = useCoState(Playlist, playlistId);
|
const playlist = useCoState(Playlist, playlistId);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -33,7 +36,10 @@ export function PlaylistTitleInput({
|
|||||||
<input
|
<input
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleTitleChange}
|
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}
|
onFocus={handleFoucsIn}
|
||||||
onBlur={handleFocusOut}
|
onBlur={handleFocusOut}
|
||||||
aria-label={`Playlist title`}
|
aria-label={`Playlist title`}
|
||||||
|
|||||||
108
examples/music-player/src/components/RenameTrackDialog.tsx
Normal file
108
examples/music-player/src/components/RenameTrackDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { MusicTrack, MusicaAccount } from "@/1_schema";
|
import { MusicaAccount } from "@/1_schema";
|
||||||
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
|
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
|
||||||
import { MediaPlayer } from "@/5_useMediaPlayer";
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
@@ -14,22 +12,18 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { usePlayState } from "@/lib/audio/usePlayState";
|
import { useAccount } from "jazz-tools/react";
|
||||||
import { useAccount, useCoState } from "jazz-tools/react";
|
import { Home, Music, Plus, Trash2 } from "lucide-react";
|
||||||
import { Home, Music, Pause, Play, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { AuthButton } from "./AuthButton";
|
import { AuthButton } from "./AuthButton";
|
||||||
|
|
||||||
export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
export function SidePanel() {
|
||||||
const { playlistId } = useParams();
|
const { playlistId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { me } = useAccount(MusicaAccount, {
|
const { me } = useAccount(MusicaAccount, {
|
||||||
resolve: { root: { playlists: { $each: true } } },
|
resolve: { root: { playlists: { $each: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const playState = usePlayState();
|
|
||||||
const isPlaying = playState.value === "play";
|
|
||||||
|
|
||||||
function handleAllTracksClick() {
|
function handleAllTracksClick() {
|
||||||
navigate(`/`);
|
navigate(`/`);
|
||||||
}
|
}
|
||||||
@@ -50,12 +44,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
navigate(`/playlist/${playlist.id}`);
|
navigate(`/playlist/${playlist.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
|
|
||||||
resolve: { waveform: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeTrackTitle = activeTrack?.title;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@@ -137,29 +125,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</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>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useCoState } from "jazz-tools/react";
|
|||||||
export function Waveform(props: {
|
export function Waveform(props: {
|
||||||
track: Loaded<typeof MusicTrack>;
|
track: Loaded<typeof MusicTrack>;
|
||||||
height: number;
|
height: number;
|
||||||
|
className?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { track, height } = props;
|
const { track, height } = props;
|
||||||
const waveformData = useCoState(
|
const waveformData = useCoState(
|
||||||
@@ -28,29 +30,24 @@ export function Waveform(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const barCount = waveformData.length;
|
const barCount = waveformData.length;
|
||||||
const activeBar = Math.ceil(barCount * (currentTime.value / duration));
|
const activeBar = props.showProgress
|
||||||
|
? Math.ceil(barCount * (currentTime.value / duration))
|
||||||
function seek(i: number) {
|
: -1;
|
||||||
currentTime.setValue((i / barCount) * duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center items-end w-full"
|
className={cn("flex justify-center items-end w-full", props.className)}
|
||||||
style={{
|
style={{
|
||||||
height,
|
height,
|
||||||
gap: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{waveformData.map((value, i) => (
|
{waveformData.map((value, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => seek(i)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-1 transition-colors rounded-none rounded-t-lg min-h-1",
|
"w-1 transition-colors rounded-none rounded-t-lg min-h-1",
|
||||||
activeBar >= i ? "bg-gray-500" : "bg-gray-300",
|
activeBar >= i ? "bg-gray-800" : "bg-gray-400",
|
||||||
"hover:bg-black hover:border hover:border-solid hover:border-black",
|
|
||||||
"focus-visible:outline-black focus:outline-hidden",
|
"focus-visible:outline-black focus:outline-hidden",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
282
examples/music-player/src/components/WaveformCanvas.tsx
Normal file
282
examples/music-player/src/components/WaveformCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -532,7 +532,8 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
default:
|
||||||
|
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer",
|
||||||
outline:
|
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))]",
|
"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))]",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background-color: hsl(0 0% 99%);
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: hsl(0 0% 100%);
|
--background: hsl(0 0% 100%);
|
||||||
--foreground: hsl(20 14.3% 4.1%);
|
--foreground: hsl(20 14.3% 4.1%);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export class AudioManager {
|
|||||||
if (this.audioObjectURL) {
|
if (this.audioObjectURL) {
|
||||||
URL.revokeObjectURL(this.audioObjectURL);
|
URL.revokeObjectURL(this.audioObjectURL);
|
||||||
this.audioObjectURL = null;
|
this.audioObjectURL = null;
|
||||||
|
this.mediaElement.src = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import { useLayoutEffect, useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
import { useAudioManager } from "./AudioManager";
|
import { AudioManager, useAudioManager } from "./AudioManager";
|
||||||
|
|
||||||
export function usePlayerCurrentTime() {
|
export function usePlayerCurrentTime() {
|
||||||
const audioManager = useAudioManager();
|
const audioManager = useAudioManager();
|
||||||
const [value, setValue] = useState<number>(0);
|
const [value, setValue] = useState<number>(0);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setValue(audioManager.mediaElement.currentTime);
|
setValue(getPlayerCurrentTime(audioManager));
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
return subscribeToPlayerCurrentTime(audioManager, setValue);
|
||||||
setValue(audioManager.mediaElement.currentTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
|
|
||||||
};
|
|
||||||
}, [audioManager]);
|
}, [audioManager]);
|
||||||
|
|
||||||
function setCurrentTime(time: number) {
|
function setCurrentTime(time: number) {
|
||||||
@@ -31,3 +23,26 @@ export function usePlayerCurrentTime() {
|
|||||||
setValue: setCurrentTime,
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
54
examples/music-player/src/lib/usePrepareAppState.ts
Normal file
54
examples/music-player/src/lib/usePrepareAppState.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,10 +55,20 @@ export class HomePage {
|
|||||||
|
|
||||||
async editTrackTitle(trackTitle: string, newTitle: string) {
|
async editTrackTitle(trackTitle: string, newTitle: string) {
|
||||||
await this.page
|
await this.page
|
||||||
.getByRole("textbox", {
|
.getByRole("button", {
|
||||||
name: `Edit track title: ${trackTitle}`,
|
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() {
|
async createPlaylist() {
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jazz-tools": "workspace:*",
|
"jazz-tools": "workspace:*",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.536.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-router": "^6.16.0",
|
"react-router": "^6.16.0",
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { Organization } from "../schema.ts";
|
|||||||
|
|
||||||
export function InviteLink({
|
export function InviteLink({
|
||||||
organization,
|
organization,
|
||||||
}: { organization: Loaded<typeof Organization> }) {
|
}: {
|
||||||
|
organization: Loaded<typeof Organization>;
|
||||||
|
}) {
|
||||||
let [copyCount, setCopyCount] = useState(0);
|
let [copyCount, setCopyCount] = useState(0);
|
||||||
let copied = copyCount > 0;
|
let copied = copyCount > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Organization } from "../schema.ts";
|
|||||||
|
|
||||||
export function OrganizationMembers({
|
export function OrganizationMembers({
|
||||||
organization,
|
organization,
|
||||||
}: { organization: Loaded<typeof Organization> }) {
|
}: {
|
||||||
|
organization: Loaded<typeof Organization>;
|
||||||
|
}) {
|
||||||
const group = organization._owner.castAs(Group);
|
const group = organization._owner.castAs(Group);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -25,7 +27,11 @@ function MemberItem({
|
|||||||
account,
|
account,
|
||||||
role,
|
role,
|
||||||
group,
|
group,
|
||||||
}: { account: Account; role: string; group: Group }) {
|
}: {
|
||||||
|
account: Account;
|
||||||
|
role: string;
|
||||||
|
group: Group;
|
||||||
|
}) {
|
||||||
const { me } = useAccount();
|
const { me } = useAccount();
|
||||||
|
|
||||||
const canRemoveMember = group.myRole() === "admin" && account.id !== me?.id;
|
const canRemoveMember = group.myRole() === "admin" && account.id !== me?.id;
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ main {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
|
box-shadow:
|
||||||
rgba(0, 0, 0, 0.06);
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
width: 28rem;
|
width: 28rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export function ReactionsScreen(props: { id: string }) {
|
|||||||
|
|
||||||
const ReactionButtons = ({
|
const ReactionButtons = ({
|
||||||
reactions,
|
reactions,
|
||||||
}: { reactions: Loaded<typeof Reactions> }) => (
|
}: {
|
||||||
|
reactions: Loaded<typeof Reactions>;
|
||||||
|
}) => (
|
||||||
<div className="reaction-buttons">
|
<div className="reaction-buttons">
|
||||||
{ReactionTypes.map((reactionType) => (
|
{ReactionTypes.map((reactionType) => (
|
||||||
<button
|
<button
|
||||||
@@ -56,7 +58,9 @@ const ReactionButtons = ({
|
|||||||
|
|
||||||
const ReactionOverview = ({
|
const ReactionOverview = ({
|
||||||
reactions,
|
reactions,
|
||||||
}: { reactions: Loaded<typeof Reactions> }) => (
|
}: {
|
||||||
|
reactions: Loaded<typeof Reactions>;
|
||||||
|
}) => (
|
||||||
<>
|
<>
|
||||||
{Object.values(reactions.perAccount).map((reaction) => (
|
{Object.values(reactions.perAccount).map((reaction) => (
|
||||||
<div key={reaction.by?.id} className="reaction-row">
|
<div key={reaction.by?.id} className="reaction-row">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<JazzReactProvider
|
<JazzReactProvider
|
||||||
sync={{
|
sync={{
|
||||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
peer: `ws://localhost:4200/?key=${apiKey}`,
|
||||||
}}
|
}}
|
||||||
AccountSchema={JazzAccount}
|
AccountSchema={JazzAccount}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"jazz-tools": "workspace:*",
|
"jazz-tools": "workspace:*",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.536.0",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
|
|
||||||
import { JazzReactProvider } from "jazz-tools/react";
|
import { JazzReactProvider } from "jazz-tools/react";
|
||||||
|
|
||||||
export default function CovaluesLayout({
|
export default function CovaluesLayout({ children }: { children: any }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: any;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<JazzReactProvider sync={{ when: "never" }}>{children}</JazzReactProvider>
|
<JazzReactProvider sync={{ when: "never" }}>{children}</JazzReactProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { clsx } from "clsx";
|
|||||||
export function Card({
|
export function Card({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: { children: React.ReactNode; className?: string }) {
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, "border rounded-xl shadow-sm")}>
|
<div className={clsx(className, "border rounded-xl shadow-sm")}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ export function Label({
|
|||||||
label,
|
label,
|
||||||
htmlFor,
|
htmlFor,
|
||||||
className,
|
className,
|
||||||
}: { label: string; htmlFor: string; className?: string }) {
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<LabelRadix.Root className={className} htmlFor={htmlFor}>
|
<LabelRadix.Root className={className} htmlFor={htmlFor}>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ export function IconCoFeed({
|
|||||||
className,
|
className,
|
||||||
size,
|
size,
|
||||||
strokeWidth,
|
strokeWidth,
|
||||||
}: { className?: string; size?: number; strokeWidth: number }) {
|
}: {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ export function IconCoRecord({
|
|||||||
className,
|
className,
|
||||||
size,
|
size,
|
||||||
strokeWidth,
|
strokeWidth,
|
||||||
}: { className?: string; size?: number; color?: string; strokeWidth: number }) {
|
}: {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ export function JazzLogo({
|
|||||||
className,
|
className,
|
||||||
width = undefined,
|
width = undefined,
|
||||||
height = undefined,
|
height = undefined,
|
||||||
}: { className?: string; width?: number; height?: number }) {
|
}: {
|
||||||
|
className?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 386 146"
|
viewBox="0 0 386 146"
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ const TableDataContainer = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
isCopyable,
|
isCopyable,
|
||||||
}: { children: React.ReactNode; className?: string; isCopyable?: boolean }) => {
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
isCopyable?: boolean;
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("flex gap-2", className, isCopyable && "cursor-pointer")}
|
className={clsx("flex gap-2", className, isCopyable && "cursor-pointer")}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export function Footer({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="col-span-full sm:col-span-6 md:col-span-4 text-sm sm:text-base">
|
<p className="col-span-full sm:col-span-6 md:col-span-4 text-sm sm:text-base">
|
||||||
Playful software for serious problems.
|
Computers are magic.
|
||||||
|
<br />
|
||||||
|
Time to make them less complex.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-y-8 grid-cols-12">
|
<div className="grid gap-y-8 grid-cols-12">
|
||||||
|
|||||||
@@ -247,7 +247,13 @@ data-lsp {
|
|||||||
}
|
}
|
||||||
.tag-container .twoslash-annotation {
|
.tag-container .twoslash-annotation {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-family: "JetBrains Mono", Menlo, Monaco, Consolas, Courier New, monospace;
|
font-family:
|
||||||
|
"JetBrains Mono",
|
||||||
|
Menlo,
|
||||||
|
Monaco,
|
||||||
|
Consolas,
|
||||||
|
Courier New,
|
||||||
|
monospace;
|
||||||
right: -10px;
|
right: -10px;
|
||||||
/** Default annotation text to 200px */
|
/** Default annotation text to 200px */
|
||||||
width: 200px;
|
width: 200px;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import Router from "next/router";
|
|||||||
|
|
||||||
export default async function TeamMemberPage({
|
export default async function TeamMemberPage({
|
||||||
params,
|
params,
|
||||||
}: { params: Promise<{ member: string }> }) {
|
}: {
|
||||||
|
params: Promise<{ member: string }>;
|
||||||
|
}) {
|
||||||
const { member } = await params;
|
const { member } = await params;
|
||||||
|
|
||||||
const memberInfo = team.find((m) => m.slug === member);
|
const memberInfo = team.find((m) => m.slug === member);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ChatDemoSection } from "@/components/home/ChatDemoSection";
|
import { ChatDemoSection } from "@/components/home/ChatDemoSection";
|
||||||
import { CollaborationFeaturesSection } from "@/components/home/CollaborationFeaturesSection";
|
import { CollaborationFeaturesSection } from "@/components/home/CollaborationFeaturesSection";
|
||||||
import { ComingSoonSection } from "@/components/home/ComingSoonSection";
|
|
||||||
import { EarlyAdopterSection } from "@/components/home/EarlyAdopterSection";
|
import { EarlyAdopterSection } from "@/components/home/EarlyAdopterSection";
|
||||||
import { EncryptionSection } from "@/components/home/EncryptionSection";
|
import { EncryptionSection } from "@/components/home/EncryptionSection";
|
||||||
import { FeaturesSection } from "@/components/home/FeaturesSection";
|
import { FeaturesSection } from "@/components/home/FeaturesSection";
|
||||||
@@ -15,9 +14,9 @@ import { Testimonial } from "@garden-co/design-system/src/components/molecules/T
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSection />
|
<HeroSection />
|
||||||
|
|
||||||
<div className="container flex flex-col gap-12 lg:gap-20">
|
<div className="container flex flex-col gap-12 lg:gap-20">
|
||||||
|
|
||||||
<GetStartedSnippetSelect />
|
<GetStartedSnippetSelect />
|
||||||
<SupportedEnvironmentsSection />
|
<SupportedEnvironmentsSection />
|
||||||
<HowJazzWorksSection />
|
<HowJazzWorksSection />
|
||||||
@@ -54,8 +53,6 @@ export default function Home() {
|
|||||||
|
|
||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
|
|
||||||
<ComingSoonSection />
|
|
||||||
|
|
||||||
<EarlyAdopterSection />
|
<EarlyAdopterSection />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import CoPlainTextDescription from "@/app/(others)/(home)/coValueDescriptions/coPlainTextDescription.mdx";
|
|
||||||
import CursorsAndCaretsDescription from "@/app/(others)/(home)/toolkit/cursorsAndCarets.mdx";
|
|
||||||
import TwoWaySyncDescription from "@/app/(others)/(home)/toolkit/twoWaySync.mdx";
|
|
||||||
import VideoPresenceCallsDescription from "@/app/(others)/(home)/toolkit/videoPresenceCalls.mdx";
|
|
||||||
import { CodeRef } from "@garden-co/design-system/src/components/atoms/CodeRef";
|
|
||||||
import { P } from "@garden-co/design-system/src/components/atoms/Paragraph";
|
|
||||||
import { FeatureCard } from "@garden-co/design-system/src/components/molecules/FeatureCard";
|
|
||||||
import { GappedGrid } from "@garden-co/design-system/src/components/molecules/GappedGrid";
|
|
||||||
import { Prose } from "@garden-co/design-system/src/components/molecules/Prose";
|
|
||||||
import { SectionHeader } from "@garden-co/design-system/src/components/molecules/SectionHeader";
|
|
||||||
|
|
||||||
export function ComingSoonSection() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SectionHeader title="More features coming soon" />
|
|
||||||
|
|
||||||
<GappedGrid cols={4}>
|
|
||||||
<FeatureCard className="p-4" label={<h3>Cursors & carets</h3>}>
|
|
||||||
<P>Ready-made spatial presence.</P>
|
|
||||||
<Prose size="sm">
|
|
||||||
<CursorsAndCaretsDescription />
|
|
||||||
</Prose>
|
|
||||||
</FeatureCard>
|
|
||||||
|
|
||||||
<FeatureCard className="p-4" label={<h3>Two-way sync to your DB</h3>}>
|
|
||||||
<P>Add Jazz to an existing app.</P>
|
|
||||||
<Prose size="sm">
|
|
||||||
<TwoWaySyncDescription />
|
|
||||||
</Prose>
|
|
||||||
</FeatureCard>
|
|
||||||
|
|
||||||
<FeatureCard className="p-4" label={<h3>Video presence & calls</h3>}>
|
|
||||||
<P>Stream and record audio & video.</P>
|
|
||||||
<Prose size="sm">
|
|
||||||
<VideoPresenceCallsDescription />
|
|
||||||
</Prose>
|
|
||||||
</FeatureCard>
|
|
||||||
|
|
||||||
<FeatureCard
|
|
||||||
className="p-4"
|
|
||||||
label={
|
|
||||||
<h3>
|
|
||||||
<CodeRef>CoPlainText</CodeRef> & <CodeRef>CoRichText</CodeRef>
|
|
||||||
</h3>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Prose size="sm">
|
|
||||||
<CoPlainTextDescription />
|
|
||||||
</Prose>
|
|
||||||
</FeatureCard>
|
|
||||||
</GappedGrid>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,81 +16,70 @@ const features: Array<{
|
|||||||
title: string;
|
title: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
title: "Instant updates",
|
title: "Instant updates",
|
||||||
icon: "instant",
|
icon: "instant",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Real-time sync",
|
title: "Real-time sync",
|
||||||
icon: "devices",
|
icon: "devices",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Multiplayer",
|
title: "Multiplayer",
|
||||||
icon: "spatialPresence",
|
icon: "spatialPresence",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "File uploads",
|
title: "File uploads",
|
||||||
icon: "upload",
|
icon: "upload",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Social features",
|
title: "Social features",
|
||||||
icon: "social",
|
icon: "social",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Permissions",
|
title: "Permissions",
|
||||||
icon: "permissions",
|
icon: "permissions",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "E2E encryption",
|
title: "E2E encryption",
|
||||||
icon: "encryption",
|
icon: "encryption",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Authentication",
|
title: "Authentication",
|
||||||
icon: "auth",
|
icon: "auth",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
return (
|
return (
|
||||||
<div className="container grid items-center gap-x-8 gap-y-12 my-12 md:my-16 lg:my-24 lg:gap-x-10 lg:grid-cols-12">
|
<div className="container grid items-center gap-x-8 gap-y-12 mt-12 md:mt-16 lg:mt-24 mb-12 lg:gap-x-10 lg:grid-cols-12">
|
||||||
<div className="flex flex-col justify-center gap-5 lg:col-span-11 lg:gap-8">
|
<div className="flex flex-col justify-center gap-5 lg:col-span-11 lg:gap-8">
|
||||||
<Kicker>Toolkit for backendless apps</Kicker>
|
<Kicker>Reactive, distributed, secure</Kicker>
|
||||||
<H1>
|
<H1>
|
||||||
<span className="inline-block text-highlight">
|
<span className="inline-block text-highlight">
|
||||||
{marketingCopy.headline}
|
{marketingCopy.headline}
|
||||||
</span>
|
</span>
|
||||||
</H1>
|
</H1>
|
||||||
|
|
||||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200">
|
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200 prose-p:leading-normal">
|
||||||
<p>
|
<p>
|
||||||
Jazz gives you data without needing a database — plus auth,
|
Jazz is a new kind of database that's distributed across your frontend, containers, serverless functions and its own storage cloud.
|
||||||
permissions, files and multiplayer without needing a backend.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p>It syncs structured data, files and LLM streams instantly.<br/>It looks like local reactive JSON state.</p>
|
||||||
|
<p>And you get auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.</p>
|
||||||
<p>
|
<p>
|
||||||
Do everything right from the frontend and ship better apps, faster.
|
This lets you get rid of 90% of the traditional backend, and most of your frontend state juggling.
|
||||||
|
You'll ship better apps, faster.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p className="text-base">
|
||||||
Open source. Self-host or use{" "}
|
Self-host or use{" "}
|
||||||
<Link className="text-reset" href="/cloud">
|
<Link className="text-reset" href="/cloud">
|
||||||
Jazz Cloud
|
Jazz Cloud
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
for zero-config magic.
|
for a zero-deploy globally-scaled DB.
|
||||||
|
<br/>Open source (MIT)
|
||||||
</p>
|
</p>
|
||||||
</Prose>
|
</Prose>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 max-w-3xl sm:grid-cols-4 sm:gap-4">
|
|
||||||
{features.map(({ title, icon }) => (
|
|
||||||
<div
|
|
||||||
key={title}
|
|
||||||
className="flex text-xs sm:text-sm gap-2 items-center"
|
|
||||||
>
|
|
||||||
<span className="p-1.5 rounded-lg bg-primary-transparent">
|
|
||||||
<Icon size="xs" name={icon} intent="primary" />
|
|
||||||
</span>
|
|
||||||
<p>{title}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function HowJazzWorksSection() {
|
|||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Kicker>How it works</Kicker>
|
<Kicker>How it works</Kicker>
|
||||||
|
|
||||||
<H2>Build entire apps using only client-side code</H2>
|
<H2>Build entire apps with collaborative state</H2>
|
||||||
</div>
|
</div>
|
||||||
<GappedGrid>
|
<GappedGrid>
|
||||||
<Step
|
<Step
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function LocalFirstFeaturesSection() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="The best of all worlds"
|
title="Local-first state with global sync"
|
||||||
slogan={
|
slogan={
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function ProblemStatementSection() {
|
|||||||
<div className="grid gap-4 lg:gap-8">
|
<div className="grid gap-4 lg:gap-8">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
className="sm:text-center sm:mx-auto"
|
className="sm:text-center sm:mx-auto"
|
||||||
title={"Powered by the first “flat stack”"}
|
title={"A database that does what's actually needed"}
|
||||||
slogan="A perspective shift worth 10,000 hours"
|
slogan="A perspective shift worth 10,000 hours"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -41,8 +41,7 @@ export default function ProblemStatementSection() {
|
|||||||
<Prose>
|
<Prose>
|
||||||
<p>
|
<p>
|
||||||
For each new app you tackle a{" "}
|
For each new app you tackle a{" "}
|
||||||
<strong>mess of moving parts and infra worries.</strong> Or, you
|
<strong>mess of moving parts and infra worries.</strong> Your backend is responsible for shuffling data around in a myriad of ways.
|
||||||
haven't even tried because "you're not full-stack".
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Want to build a <strong>modern app</strong> with multiplayer or
|
Want to build a <strong>modern app</strong> with multiplayer or
|
||||||
@@ -68,7 +67,7 @@ export default function ProblemStatementSection() {
|
|||||||
<strong>With users & permissions built-in.</strong>
|
<strong>With users & permissions built-in.</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
With completely <strong>app-independent infra,</strong> you get to
|
With a <strong>DB and infra made for modern apps</strong> you get to
|
||||||
focus on <strong>building the app your users want.</strong> You'll
|
focus on <strong>building the app your users want.</strong> You'll
|
||||||
notice that <strong>90% of the work is now the UI.</strong>
|
notice that <strong>90% of the work is now the UI.</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BunLogo } from "@/components/icons/BunLogo";
|
import { BunLogo } from "@/components/icons/BunLogo";
|
||||||
import { CloudflareWorkerLogo } from "@/components/icons/CloudflareWorkerLogo";
|
import { CloudflareWorkerLogo } from "@/components/icons/CloudflareWorkerLogo";
|
||||||
|
import { VercelLogo } from "@/components/icons/VercelLogo";
|
||||||
import { ExpoLogo } from "@/components/icons/ExpoLogo";
|
import { ExpoLogo } from "@/components/icons/ExpoLogo";
|
||||||
import { JavascriptLogo } from "@/components/icons/JavascriptLogo";
|
import { JavascriptLogo } from "@/components/icons/JavascriptLogo";
|
||||||
import { NodejsLogo } from "@/components/icons/NodejsLogo";
|
import { NodejsLogo } from "@/components/icons/NodejsLogo";
|
||||||
@@ -44,14 +45,18 @@ const serverWorkers = [
|
|||||||
icon: NodejsLogo,
|
icon: NodejsLogo,
|
||||||
href: "/docs/react/server-workers",
|
href: "/docs/react/server-workers",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Cloudflare Workers",
|
|
||||||
icon: CloudflareWorkerLogo,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Bun",
|
name: "Bun",
|
||||||
icon: BunLogo,
|
icon: BunLogo,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Vercel",
|
||||||
|
icon: VercelLogo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CF Workers",
|
||||||
|
icon: CloudflareWorkerLogo,
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SupportedEnvironmentsSection() {
|
export function SupportedEnvironmentsSection() {
|
||||||
|
|||||||
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export function VercelLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="1.5em"
|
||||||
|
height="1.5em"
|
||||||
|
viewBox="0 0 76 65"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ function AuthStateIndicator() {
|
|||||||
const isGuest = agent._type !== "Account"
|
const isGuest = agent._type !== "Account"
|
||||||
|
|
||||||
// Anonymous authentication: has an account but not fully authenticated
|
// Anonymous authentication: has an account but not fully authenticated
|
||||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{isGuest && <span>Guest Mode</span>}
|
{isGuest && <span>Guest Mode</span>}
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ import { co, z, CoMap } from "jazz-tools";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
@@ -73,17 +73,17 @@ import { co, z } from "jazz-tools";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
// ---cut---
|
// ---cut---
|
||||||
// OrderForm.tsx
|
// OrderForm.tsx
|
||||||
export function OrderForm({
|
export function OrderForm({
|
||||||
order,
|
order,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -118,16 +118,16 @@ import * as React from "react";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export function OrderForm({
|
export function OrderForm({
|
||||||
order,
|
order,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -177,10 +177,10 @@ import { useState, useEffect } from "react";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export const AccountRoot = co.map({
|
export const AccountRoot = co.map({
|
||||||
draft: DraftBubbleTeaOrder,
|
draft: DraftBubbleTeaOrder,
|
||||||
@@ -218,7 +218,7 @@ export function OrderForm({
|
|||||||
// CreateOrder.tsx
|
// CreateOrder.tsx
|
||||||
export function CreateOrder() {
|
export function CreateOrder() {
|
||||||
const { me } = useAccount();
|
const { me } = useAccount();
|
||||||
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
|
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(DraftBubbleTeaOrder.create({}));
|
setDraft(DraftBubbleTeaOrder.create({}));
|
||||||
@@ -228,7 +228,7 @@ export function CreateOrder() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!draft || !draft.name) return;
|
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);
|
console.log("Order created:", order);
|
||||||
};
|
};
|
||||||
@@ -251,11 +251,15 @@ Update the schema to include a `validateDraftOrder` helper.
|
|||||||
import { co, z } from "jazz-tools";
|
import { co, z } from "jazz-tools";
|
||||||
// ---cut---
|
// ---cut---
|
||||||
// schema.ts
|
// schema.ts
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.optional(z.string()),
|
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[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!draft.name) {
|
if (!draft.name) {
|
||||||
@@ -279,12 +283,12 @@ import { useState, useEffect } from "react";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!draft.name) {
|
if (!draft.name) {
|
||||||
@@ -307,7 +311,7 @@ export function OrderForm({
|
|||||||
order,
|
order,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -330,7 +334,7 @@ export function OrderForm({
|
|||||||
// CreateOrder.tsx
|
// CreateOrder.tsx
|
||||||
export function CreateOrder() {
|
export function CreateOrder() {
|
||||||
const { me } = useAccount();
|
const { me } = useAccount();
|
||||||
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
|
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(DraftBubbleTeaOrder.create({}));
|
setDraft(DraftBubbleTeaOrder.create({}));
|
||||||
@@ -346,7 +350,7 @@ export function CreateOrder() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = draft as co.loaded<typeof BubbleTeaOrder>;
|
const order = draft as BubbleTeaOrder;
|
||||||
|
|
||||||
console.log("Order created:", order);
|
console.log("Order created:", order);
|
||||||
};
|
};
|
||||||
@@ -372,10 +376,10 @@ import { co, z } from "jazz-tools";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export const AccountRoot = co.map({ // [!code ++:15]
|
export const AccountRoot = co.map({ // [!code ++:15]
|
||||||
draft: DraftBubbleTeaOrder,
|
draft: DraftBubbleTeaOrder,
|
||||||
@@ -403,10 +407,10 @@ import { co, z } from "jazz-tools";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export const AccountRoot = co.map({
|
export const AccountRoot = co.map({
|
||||||
draft: DraftBubbleTeaOrder,
|
draft: DraftBubbleTeaOrder,
|
||||||
@@ -452,12 +456,12 @@ import { co, z } from "jazz-tools";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!draft.name) {
|
if (!draft.name) {
|
||||||
@@ -492,7 +496,7 @@ export function OrderForm({
|
|||||||
order,
|
order,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -533,7 +537,7 @@ export function CreateOrder() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const order = draft as co.loaded<typeof BubbleTeaOrder>;
|
const order = draft as BubbleTeaOrder;
|
||||||
console.log("Order created:", order);
|
console.log("Order created:", order);
|
||||||
|
|
||||||
// create a new empty draft
|
// create a new empty draft
|
||||||
@@ -577,11 +581,15 @@ Simply add a `hasChanges` helper to your schema.
|
|||||||
import { co, z } from "jazz-tools";
|
import { co, z } from "jazz-tools";
|
||||||
// ---cut---
|
// ---cut---
|
||||||
// schema.ts
|
// schema.ts
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.optional(z.string()),
|
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[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!draft.name) {
|
if (!draft.name) {
|
||||||
@@ -591,7 +599,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
|
|||||||
return { errors };
|
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;
|
return draft ? Object.keys(draft._edits).length : false;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -608,12 +616,12 @@ import * as React from "react";
|
|||||||
export const BubbleTeaOrder = co.map({
|
export const BubbleTeaOrder = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||||
|
|
||||||
export const DraftBubbleTeaOrder = co.map({
|
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||||
name: z.optional(z.string()),
|
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||||
});
|
|
||||||
|
|
||||||
export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>) {
|
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (!draft.name) {
|
if (!draft.name) {
|
||||||
@@ -623,7 +631,7 @@ export function validateDraftOrder(draft: co.loaded<typeof DraftBubbleTeaOrder>)
|
|||||||
return { errors };
|
return { errors };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function hasChanges(draft?: co.loaded<typeof DraftBubbleTeaOrder>) {
|
export function hasChanges(draft?: DraftBubbleTeaOrder) {
|
||||||
return draft ? Object.keys(draft._edits).length : false;
|
return draft ? Object.keys(draft._edits).length : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const Organization = co.map({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|
||||||
// shared data between users of each organization
|
// shared data between users of each organization
|
||||||
projects: co.list(Project),
|
projects: co.list(Project),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListOfOrganizations = co.list(Organization);
|
export const ListOfOrganizations = co.list(Organization);
|
||||||
@@ -115,7 +115,7 @@ import * as React from "react";
|
|||||||
import { useAcceptInvite, useAccount } from "jazz-tools/react";
|
import { useAcceptInvite, useAccount } from "jazz-tools/react";
|
||||||
import { co, z } from "jazz-tools";
|
import { co, z } from "jazz-tools";
|
||||||
|
|
||||||
const Project = z.object({
|
const Project = co.map({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,101 @@ console.log(containingGroup.getParentGroups()); // [addedGroup]
|
|||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Group hierarchy on CoValue creation
|
||||||
|
|
||||||
|
When creating CoValues that contain other CoValues using plain JSON objects, Jazz not only creates
|
||||||
|
the necessary CoValues automatically but it will also manage their group ownership.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts twoslash
|
||||||
|
import { co, z } from "jazz-tools";
|
||||||
|
|
||||||
|
// ---cut---
|
||||||
|
const Task = co.plainText();
|
||||||
|
const Column = co.list(Task);
|
||||||
|
const Board = co.map({
|
||||||
|
title: z.string(),
|
||||||
|
columns: co.list(Column),
|
||||||
|
});
|
||||||
|
|
||||||
|
const board = Board.create({
|
||||||
|
title: "My board",
|
||||||
|
columns: [
|
||||||
|
["Task 1.1", "Task 1.2"],
|
||||||
|
["Task 2.1", "Task 2.2"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
For each created column and task CoValue, Jazz also creates a new group as its owner and
|
||||||
|
adds the referencing CoValue's owner as a member of that group. This means permissions for nested CoValues
|
||||||
|
are inherited from the CoValue that references them, but can also be modified independently for each CoValue
|
||||||
|
if needed.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts twoslash
|
||||||
|
import { co, z, Group, Account } from "jazz-tools";
|
||||||
|
|
||||||
|
const alice = {} as unknown as Account;
|
||||||
|
const bob = {} as unknown as Account;
|
||||||
|
const Task = co.plainText();
|
||||||
|
const Column = co.list(Task);
|
||||||
|
const Board = co.map({
|
||||||
|
title: z.string(),
|
||||||
|
columns: co.list(Column),
|
||||||
|
});
|
||||||
|
// ---cut---
|
||||||
|
const writeAccess = Group.create();
|
||||||
|
writeAccess.addMember(bob, "writer");
|
||||||
|
|
||||||
|
// Give Bob write access to the board, columns and tasks
|
||||||
|
const board = Board.create({
|
||||||
|
title: "My board",
|
||||||
|
columns: [
|
||||||
|
["Task 1.1", "Task 1.2"],
|
||||||
|
["Task 2.1", "Task 2.2"],
|
||||||
|
],
|
||||||
|
}, writeAccess);
|
||||||
|
|
||||||
|
// Give Alice read access to one specific task
|
||||||
|
const task = board.columns[0][0];
|
||||||
|
const taskGroup = task._owner.castAs(Group);
|
||||||
|
taskGroup.addMember(alice, "reader");
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
If you prefer to manage permissions differently, you can always create CoValues explicitly:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts twoslash
|
||||||
|
import { co, Group, z, Account } from "jazz-tools";
|
||||||
|
|
||||||
|
const bob = {} as unknown as Account;
|
||||||
|
const Task = co.plainText();
|
||||||
|
const Column = co.list(Task);
|
||||||
|
const Board = co.map({
|
||||||
|
title: z.string(),
|
||||||
|
columns: co.list(Column),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---cut---
|
||||||
|
const writeAccess = Group.create();
|
||||||
|
writeAccess.addMember(bob, "writer");
|
||||||
|
const readAccess = Group.create();
|
||||||
|
readAccess.addMember(bob, "reader");
|
||||||
|
|
||||||
|
// Give Bob read access to the board and write access to the columns and tasks
|
||||||
|
const board = Board.create({
|
||||||
|
title: "My board",
|
||||||
|
columns: co.list(Column).create([
|
||||||
|
["Task 1.1", "Task 1.2"],
|
||||||
|
["Task 2.1", "Task 2.2"],
|
||||||
|
], writeAccess),
|
||||||
|
}, readAccess);
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
## Example: Team Hierarchy
|
## Example: Team Hierarchy
|
||||||
|
|
||||||
Here's a practical example of using group inheritance for team permissions:
|
Here's a practical example of using group inheritance for team permissions:
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export const metadata = {
|
|||||||
|
|
||||||
# Learn some <span className="sr-only">Jazz</span> <JazzLogo className="h-[41px] -ml-0.5 -mt-[3px] inline" />
|
# Learn some <span className="sr-only">Jazz</span> <JazzLogo className="h-[41px] -ml-0.5 -mt-[3px] inline" />
|
||||||
|
|
||||||
**Jazz is a toolkit for building backendless apps**. You get data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Jazz lets you do everything right from the frontend and you'll ship better apps, faster.
|
**Jazz is a new kind of database** that's **distributed** across your frontend, containers, serverless functions and its own storage cloud.
|
||||||
|
|
||||||
Instead of wrestling with databases, APIs, and server infrastructure, you work with **CoValues** ("collaborative values") — your new cloud-synced building blocks that feel like local state but automatically sync across all devices and users in real-time.
|
It syncs structured data, files and LLM streams instantly, and looks like local reactive JSON state.
|
||||||
|
|
||||||
|
It also provides auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ You can use [`create-jazz-app`](/docs/tools/create-jazz-app) to create a new Jaz
|
|||||||
|
|
||||||
<CodeGroup>
|
<CodeGroup>
|
||||||
```sh
|
```sh
|
||||||
npx create-jazz-app@latest --api-key you@example.com
|
npx create-jazz-app@latest --api-key you@example.com
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
@@ -30,21 +32,10 @@ Or you can follow this [React step-by-step guide](/docs/react/guide) where we wa
|
|||||||
|
|
||||||
</ContentByFramework> */}
|
</ContentByFramework> */}
|
||||||
|
|
||||||
## Why Jazz is different
|
|
||||||
|
|
||||||
Most apps rebuild the same thing: shared state that syncs between users and devices. Jazz starts from that shared state, giving you:
|
|
||||||
|
|
||||||
- **No backend required** — Focus on building features, not infrastructure
|
|
||||||
- **Real-time sync** — Changes appear everywhere immediately
|
|
||||||
- **Multiplayer by default** — Collaboration just works
|
|
||||||
- **Local-first** — Your app works offline and feels instant
|
|
||||||
|
|
||||||
Think Figma, Notion, or Linear — but you don't need years to build a custom stack.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. **Define your data** with CoValues schemas
|
1. **Define your data** with CoValues schemas
|
||||||
2. **Connect to sync infrastructure** (Jazz Cloud or self-hosted)
|
2. **Connect to storage infrastructure** (Jazz Cloud or self-hosted)
|
||||||
3. **Create and edit CoValues** like normal objects
|
3. **Create and edit CoValues** like normal objects
|
||||||
4. **Get automatic sync and persistence** across all devices and users
|
4. **Get automatic sync and persistence** across all devices and users
|
||||||
|
|
||||||
|
|||||||
@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
|
|||||||
<CodeGroup>
|
<CodeGroup>
|
||||||
```ts
|
```ts
|
||||||
// src/lib/schema.ts
|
// src/lib/schema.ts
|
||||||
import { Account, Profile, coField } from "jazz-tools";
|
import { co, z } from "jazz-tools"
|
||||||
|
|
||||||
export class MyProfile extends Profile {
|
export const MyProfile = co.profile({
|
||||||
name = coField.string;
|
name: z.string(),
|
||||||
counter = coField.number; // This will be publically visible
|
counter: z.number()
|
||||||
}
|
});
|
||||||
|
|
||||||
export class MyAccount extends Account {
|
export const root = co.map({});
|
||||||
profile = coField.ref(MyProfile);
|
|
||||||
|
|
||||||
// ...
|
export const UserAccount = co.account({
|
||||||
}
|
root,
|
||||||
|
profile: MyProfile
|
||||||
|
});
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
|
|||||||
<CodeGroup>
|
<CodeGroup>
|
||||||
```svelte
|
```svelte
|
||||||
<!-- src/routes/+layout.svelte -->
|
<!-- src/routes/+layout.svelte -->
|
||||||
|
|
||||||
<script lang="ts">
|
<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
|
// Example configuration for authentication and peer connection
|
||||||
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
|
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
|
||||||
let AccountSchema = MyAccount;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<JazzSvelteProvider {sync} {AccountSchema}>
|
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
|
||||||
<App />
|
{@render children?.()}
|
||||||
</JazzSvelteProvider>
|
</JazzSvelteProvider>
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
|
|||||||
```svelte
|
```svelte
|
||||||
<!-- src/routes/+page.svelte -->
|
<!-- src/routes/+page.svelte -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useCoState, useAccount } from 'jazz-tools/svelte';
|
import { CoState, AccountCoState } from "jazz-tools/svelte";
|
||||||
import { MyProfile } from './schema';
|
import { MyProfile, UserAccount } from "$lib/schema";
|
||||||
|
|
||||||
const { me } = useAccount();
|
const me = new AccountCoState(UserAccount);
|
||||||
|
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
|
||||||
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
|
|
||||||
|
|
||||||
function increment() {
|
function increment() {
|
||||||
if (!profile.current) return;
|
if (!profile.current) return;
|
||||||
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click={increment}>
|
<button onclick={increment}>
|
||||||
Count: {profile.current?.counter}
|
Count: {profile.current?.counter}
|
||||||
</button>
|
</button>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import { CodeGroup, ComingSoon } from "@/components/forMdx";
|
import { CodeGroup, ComingSoon } from "@/components/forMdx";
|
||||||
|
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||||
|
|
||||||
# Defining schemas: CoValues
|
# Defining schemas: CoValues
|
||||||
|
|
||||||
@@ -80,6 +81,40 @@ const project = TodoProject.create(
|
|||||||
|
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
|
When creating CoValues that contain other CoValues, you can pass in a plain JSON object.
|
||||||
|
Jazz will automatically create the CoValues for you.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts twoslash
|
||||||
|
// @filename: schema.ts
|
||||||
|
import { co, z, CoMap, CoList } from "jazz-tools";
|
||||||
|
|
||||||
|
export const ListOfTasks = co.list(z.string());
|
||||||
|
|
||||||
|
export const TodoProject = co.map({
|
||||||
|
title: z.string(),
|
||||||
|
tasks: ListOfTasks,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @filename: app.ts
|
||||||
|
// ---cut---
|
||||||
|
// app.ts
|
||||||
|
import { Group } from "jazz-tools";
|
||||||
|
import { TodoProject, ListOfTasks } from "./schema";
|
||||||
|
|
||||||
|
const group = Group.create().makePublic();
|
||||||
|
const project = TodoProject.create({
|
||||||
|
title: "New Project",
|
||||||
|
tasks: [], // Permissions are inherited, so the tasks list will also be public
|
||||||
|
}, group);
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
<Alert variant="info" className="flex gap-2 items-center my-4">
|
||||||
|
To learn more about how permissions work when creating nested CoValues with plain JSON objects,
|
||||||
|
refer to [Group hierarchy on CoValue creation](/docs/groups/inheritance#group-hierarchy-on-covalue-creation).
|
||||||
|
</Alert>
|
||||||
|
|
||||||
## Types of CoValues
|
## Types of CoValues
|
||||||
|
|
||||||
### `CoMap` (declaration)
|
### `CoMap` (declaration)
|
||||||
@@ -320,6 +355,10 @@ const Company = co.map({
|
|||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
#### Optional References
|
#### Optional References
|
||||||
|
You can make schema fields optional using either `z.optional()` or `co.optional()`, depending on the type of value:
|
||||||
|
|
||||||
|
- Use `z.optional()` for primitive Zod values like `z.string()`, `z.number()`, or `z.boolean()`
|
||||||
|
- Use `co.optional()` for CoValues like `co.map()`, `co.list()`, or `co.record()`
|
||||||
|
|
||||||
You can make references optional with `co.optional()`:
|
You can make references optional with `co.optional()`:
|
||||||
|
|
||||||
@@ -331,7 +370,8 @@ const Pet = co.map({
|
|||||||
});
|
});
|
||||||
// ---cut---
|
// ---cut---
|
||||||
const Person = co.map({
|
const Person = co.map({
|
||||||
pet: co.optional(Pet),
|
age: z.optional(z.number()), // primitive
|
||||||
|
pet: co.optional(Pet), // CoValue
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
|||||||
|
|
||||||
# Inbox API with Server Workers
|
# Inbox API with Server Workers
|
||||||
|
|
||||||
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
||||||
|
|
||||||
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
|
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
|
||||||
|
|
||||||
@@ -154,8 +154,8 @@ function EventComponent({ event }: { event: Event }) {
|
|||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
||||||
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
||||||
The value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
|
The value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
|
||||||
|
|
||||||
|
|
||||||
@@ -163,4 +163,4 @@ The value returned will be the id of the CoValue returned in the `inbox.subscrib
|
|||||||
|
|
||||||
Multi-region deployments are not supported when using the Inbox API.
|
Multi-region deployments are not supported when using the Inbox API.
|
||||||
|
|
||||||
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests.mdx) instead.
|
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests) instead.
|
||||||
|
|||||||
@@ -228,6 +228,54 @@ export type Project = co.loaded<typeof Project>;
|
|||||||
```
|
```
|
||||||
</CodeGroup>
|
</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
|
### Working with Record CoMaps
|
||||||
|
|
||||||
For record-type CoMaps, you can access values using bracket notation:
|
For record-type CoMaps, you can access values using bracket notation:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const marketingCopy = {
|
export const marketingCopy = {
|
||||||
headline: "Whip up an app",
|
headline: "Smooth database.",
|
||||||
description:
|
description:
|
||||||
"Jazz gives you data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Do everything right from the frontend and ship better apps, faster.",
|
"Jazz is a database that's distributed across your frontend, containers and functions. It syncs structured data, files and LLM streams instantly and looks like local reactive JSON state.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { readFile, readdir } from "fs/promises";
|
|||||||
import { DOC_SECTIONS } from "./utils/config.mjs";
|
import { DOC_SECTIONS } from "./utils/config.mjs";
|
||||||
import { writeDocsFile } from "./utils/index.mjs";
|
import { writeDocsFile } from "./utils/index.mjs";
|
||||||
|
|
||||||
|
const exclude = [/\/upgrade\//];
|
||||||
|
|
||||||
async function readMdxContent(url) {
|
async function readMdxContent(url) {
|
||||||
try {
|
try {
|
||||||
// Special case for the introduction
|
// Special case for the introduction
|
||||||
@@ -31,12 +33,17 @@ async function readMdxContent(url) {
|
|||||||
|
|
||||||
// If it's a directory, try to read all framework variants
|
// If it's a directory, try to read all framework variants
|
||||||
const fullPath = path.join(baseDir, relativePath);
|
const fullPath = path.join(baseDir, relativePath);
|
||||||
|
|
||||||
|
if (exclude.some((pattern) => pattern.test(fullPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// Read all MDX files in the directory
|
// Read all MDX files in the directory
|
||||||
const files = await fs.readdir(fullPath);
|
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;
|
if (mdxFiles.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"build:packages": "turbo build --filter='./packages/*'",
|
"build:packages": "turbo build --filter='./packages/*'",
|
||||||
"lint": "turbo lint && cd homepage/homepage && pnpm run lint",
|
"lint": "turbo lint && cd homepage/homepage && pnpm run lint",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ci": "vitest run --watch=false --coverage.enabled=true",
|
"test:ci": "vitest run --watch=false",
|
||||||
"test:coverage": "vitest --ui --coverage.enabled=true",
|
"test:coverage": "vitest --ui --coverage.enabled=true",
|
||||||
"format-and-lint": "biome check .",
|
"format-and-lint": "biome check .",
|
||||||
"format-and-lint:fix": "biome check . --write",
|
"format-and-lint:fix": "biome check . --write",
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
# cojson-storage-indexeddb
|
# cojson-storage-indexeddb
|
||||||
|
|
||||||
|
## 0.16.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 103d1b4: Fix Unknown transaction error on IndexedDB showing up sometimes when using a readwrite transaction to read values
|
||||||
|
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
|
||||||
|
- 4b99ff1: Add a multi-storage scheduler to avoid conflicting store operations when having multiple storage instances open on the same database
|
||||||
|
- Updated dependencies [67e0968]
|
||||||
|
- Updated dependencies [ce9ca54]
|
||||||
|
- Updated dependencies [4b99ff1]
|
||||||
|
- Updated dependencies [ac5d20d]
|
||||||
|
- Updated dependencies [9bf7946]
|
||||||
|
- cojson@0.16.6
|
||||||
|
|
||||||
|
## 0.16.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3cd1586]
|
||||||
|
- Updated dependencies [267f689]
|
||||||
|
- cojson@0.16.5
|
||||||
|
|
||||||
|
## 0.16.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [f9d538f]
|
||||||
|
- Updated dependencies [802b5a3]
|
||||||
|
- cojson@0.16.4
|
||||||
|
|
||||||
|
## 0.16.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cojson@0.16.3
|
||||||
|
|
||||||
|
## 0.16.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cojson@0.16.2
|
||||||
|
|
||||||
## 0.16.1
|
## 0.16.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cojson-storage-indexeddb",
|
"name": "cojson-storage-indexeddb",
|
||||||
"version": "0.16.1",
|
"version": "0.16.6",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import type {
|
|||||||
StoredSessionRow,
|
StoredSessionRow,
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
} from "cojson";
|
} from "cojson";
|
||||||
import { CoJsonIDBTransaction } from "./CoJsonIDBTransaction.js";
|
import {
|
||||||
|
CoJsonIDBTransaction,
|
||||||
|
putIndexedDbStore,
|
||||||
|
queryIndexedDbStore,
|
||||||
|
} from "./CoJsonIDBTransaction.js";
|
||||||
|
import { StoreName } from "./CoJsonIDBTransaction.js";
|
||||||
|
|
||||||
export class IDBClient implements DBClientInterfaceAsync {
|
export class IDBClient implements DBClientInterfaceAsync {
|
||||||
private db;
|
private db;
|
||||||
@@ -39,17 +44,18 @@ export class IDBClient implements DBClientInterfaceAsync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
|
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
|
||||||
return this.makeRequest<StoredCoValueRow | undefined>((tx) =>
|
return queryIndexedDbStore(this.db, "coValues", (store) =>
|
||||||
tx.getObjectStore("coValues").index("coValuesById").get(coValueId),
|
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[]> {
|
async getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]> {
|
||||||
return this.makeRequest<StoredSessionRow[]>((tx) =>
|
return queryIndexedDbStore(this.db, "sessions", (store) =>
|
||||||
tx
|
store.index("sessionsByCoValue").getAll(coValueRowId),
|
||||||
.getObjectStore("sessions")
|
|
||||||
.index("sessionsByCoValue")
|
|
||||||
.getAll(coValueRowId),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +63,8 @@ export class IDBClient implements DBClientInterfaceAsync {
|
|||||||
coValueRowId: number,
|
coValueRowId: number,
|
||||||
sessionID: SessionID,
|
sessionID: SessionID,
|
||||||
): Promise<StoredSessionRow | undefined> {
|
): Promise<StoredSessionRow | undefined> {
|
||||||
return this.makeRequest<StoredSessionRow>((tx) =>
|
return queryIndexedDbStore(this.db, "sessions", (store) =>
|
||||||
tx
|
store.index("uniqueSessions").get([coValueRowId, sessionID]),
|
||||||
.getObjectStore("sessions")
|
|
||||||
.index("uniqueSessions")
|
|
||||||
.get([coValueRowId, sessionID]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +73,10 @@ export class IDBClient implements DBClientInterfaceAsync {
|
|||||||
fromIdx: number,
|
fromIdx: number,
|
||||||
toIdx: number,
|
toIdx: number,
|
||||||
): Promise<TransactionRow[]> {
|
): Promise<TransactionRow[]> {
|
||||||
return this.makeRequest<TransactionRow[]>((tx) =>
|
return queryIndexedDbStore(this.db, "transactions", (store) =>
|
||||||
tx
|
store.getAll(
|
||||||
.getObjectStore("transactions")
|
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
|
||||||
.getAll(
|
),
|
||||||
IDBKeyRange.bound([sessionRowId, fromIdx], [sessionRowId, toIdx]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,32 +84,28 @@ export class IDBClient implements DBClientInterfaceAsync {
|
|||||||
sessionRowId: number,
|
sessionRowId: number,
|
||||||
firstNewTxIdx: number,
|
firstNewTxIdx: number,
|
||||||
): Promise<SignatureAfterRow[]> {
|
): Promise<SignatureAfterRow[]> {
|
||||||
return this.makeRequest<SignatureAfterRow[]>((tx) =>
|
return queryIndexedDbStore(this.db, "signatureAfter", (store) =>
|
||||||
tx
|
store.getAll(
|
||||||
.getObjectStore("signatureAfter")
|
IDBKeyRange.bound(
|
||||||
.getAll(
|
[sessionRowId, firstNewTxIdx],
|
||||||
IDBKeyRange.bound(
|
[sessionRowId, Number.POSITIVE_INFINITY],
|
||||||
[sessionRowId, firstNewTxIdx],
|
|
||||||
[sessionRowId, Number.POSITIVE_INFINITY],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addCoValue(
|
async upsertCoValue(
|
||||||
msg: CojsonInternalTypes.NewContentMessage,
|
id: RawCoID,
|
||||||
): Promise<number> {
|
header?: CojsonInternalTypes.CoValueHeader,
|
||||||
if (!msg.header) {
|
): Promise<number | undefined> {
|
||||||
throw new Error(`Header is required, coId: ${msg.id}`);
|
if (!header) {
|
||||||
|
return this.getCoValueRowID(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await this.makeRequest<IDBValidKey>((tx) =>
|
return putIndexedDbStore<CoValueRow, number>(this.db, "coValues", {
|
||||||
tx.getObjectStore("coValues").put({
|
id,
|
||||||
id: msg.id,
|
header,
|
||||||
// biome-ignore lint/style/noNonNullAssertion: TODO(JAZZ-561): Review
|
}).catch(() => this.getCoValueRowID(id));
|
||||||
header: msg.header!,
|
|
||||||
} satisfies CoValueRow),
|
|
||||||
)) as number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addSessionUpdate({
|
async addSessionUpdate({
|
||||||
|
|||||||
@@ -179,8 +179,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
|
|||||||
),
|
),
|
||||||
).toMatchInlineSnapshot(`
|
).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 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",
|
"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",
|
"client -> LOAD Map sessions: empty",
|
||||||
"storage -> CONTENT Group header: true new: After: 0 New: 3",
|
"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: 0 New: 193",
|
||||||
"storage -> CONTENT Map header: true new: After: 97 New: 97",
|
"storage -> CONTENT Map header: true new: After: 193 New: 7",
|
||||||
"storage -> CONTENT Map header: true new: After: 194 New: 6",
|
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -561,9 +561,10 @@ test("should sync and load accounts from storage", async () => {
|
|||||||
),
|
),
|
||||||
).toMatchInlineSnapshot(`
|
).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 ProfileGroup header: true new: After: 0 New: 5",
|
||||||
"client -> CONTENT Profile header: true new: After: 0 New: 1",
|
"client -> CONTENT Profile header: true new: After: 0 New: 1",
|
||||||
|
"client -> CONTENT Account header: false new: After: 3 New: 1",
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,11 @@ export function trackMessages() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
StorageApiAsync.prototype.store = async function (data, correctionCallback) {
|
StorageApiAsync.prototype.store = async function (data, correctionCallback) {
|
||||||
for (const msg of data) {
|
messages.push({
|
||||||
messages.push({
|
from: "client",
|
||||||
from: "client",
|
msg: data,
|
||||||
msg,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return originalStore.call(this, data, (msg) => {
|
return originalStore.call(this, data, (msg) => {
|
||||||
messages.push({
|
messages.push({
|
||||||
from: "storage",
|
from: "storage",
|
||||||
@@ -51,7 +50,18 @@ export function trackMessages() {
|
|||||||
...msg,
|
...msg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
correctionCallback(msg);
|
const correctionMessages = correctionCallback(msg);
|
||||||
|
|
||||||
|
if (correctionMessages) {
|
||||||
|
for (const msg of correctionMessages) {
|
||||||
|
messages.push({
|
||||||
|
from: "client",
|
||||||
|
msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return correctionMessages;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# cojson-storage-sqlite
|
# cojson-storage-sqlite
|
||||||
|
|
||||||
|
## 0.16.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
|
||||||
|
- Updated dependencies [67e0968]
|
||||||
|
- Updated dependencies [ce9ca54]
|
||||||
|
- Updated dependencies [4b99ff1]
|
||||||
|
- Updated dependencies [ac5d20d]
|
||||||
|
- Updated dependencies [9bf7946]
|
||||||
|
- cojson@0.16.6
|
||||||
|
|
||||||
|
## 0.16.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3cd1586]
|
||||||
|
- Updated dependencies [267f689]
|
||||||
|
- cojson@0.16.5
|
||||||
|
|
||||||
|
## 0.16.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [f9d538f]
|
||||||
|
- Updated dependencies [802b5a3]
|
||||||
|
- cojson@0.16.4
|
||||||
|
|
||||||
|
## 0.16.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cojson@0.16.3
|
||||||
|
|
||||||
|
## 0.16.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cojson@0.16.2
|
||||||
|
|
||||||
## 0.16.1
|
## 0.16.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cojson-storage-sqlite",
|
"name": "cojson-storage-sqlite",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.16.1",
|
"version": "0.16.6",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -211,8 +211,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
|
|||||||
),
|
),
|
||||||
).toMatchInlineSnapshot(`
|
).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 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",
|
"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 Group header: true new: After: 0 New: 3",
|
||||||
"client -> CONTENT Map header: true new: After: 0 New: 1",
|
"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",
|
"client -> CONTENT Map header: false new: After: 1 New: 3",
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@@ -455,10 +458,7 @@ test("should recover missing dependencies from storage", async () => {
|
|||||||
data,
|
data,
|
||||||
correctionCallback,
|
correctionCallback,
|
||||||
) {
|
) {
|
||||||
if (
|
if ([group.core.id, account.core.id as string].includes(data.id)) {
|
||||||
data[0]?.id &&
|
|
||||||
[group.core.id, account.core.id as string].includes(data[0].id)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,9 +658,8 @@ test("large coValue upload streaming", async () => {
|
|||||||
[
|
[
|
||||||
"client -> LOAD Map sessions: empty",
|
"client -> LOAD Map sessions: empty",
|
||||||
"storage -> CONTENT Group header: true new: After: 0 New: 3",
|
"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: 0 New: 193",
|
||||||
"storage -> CONTENT Map header: true new: After: 97 New: 97",
|
"storage -> CONTENT Map header: true new: After: 193 New: 7",
|
||||||
"storage -> CONTENT Map header: true new: After: 194 New: 6",
|
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,12 +36,11 @@ export function trackMessages() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
StorageApiSync.prototype.store = function (data, correctionCallback) {
|
StorageApiSync.prototype.store = function (data, correctionCallback) {
|
||||||
for (const msg of data) {
|
messages.push({
|
||||||
messages.push({
|
from: "client",
|
||||||
from: "client",
|
msg: data,
|
||||||
msg,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
return originalStore.call(this, data, (msg) => {
|
return originalStore.call(this, data, (msg) => {
|
||||||
messages.push({
|
messages.push({
|
||||||
from: "storage",
|
from: "storage",
|
||||||
@@ -51,7 +50,19 @@ export function trackMessages() {
|
|||||||
...msg,
|
...msg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
correctionCallback(msg);
|
|
||||||
|
const correctionMessages = correctionCallback(msg);
|
||||||
|
|
||||||
|
if (correctionMessages) {
|
||||||
|
for (const msg of correctionMessages) {
|
||||||
|
messages.push({
|
||||||
|
from: "client",
|
||||||
|
msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return correctionMessages;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# cojson-transport-nodejs-ws
|
# cojson-transport-nodejs-ws
|
||||||
|
|
||||||
|
## 0.16.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- ac5d20d: Add ingress and egress metering
|
||||||
|
- Updated dependencies [67e0968]
|
||||||
|
- Updated dependencies [ce9ca54]
|
||||||
|
- Updated dependencies [4b99ff1]
|
||||||
|
- Updated dependencies [ac5d20d]
|
||||||
|
- Updated dependencies [9bf7946]
|
||||||
|
- cojson@0.16.6
|
||||||
|
|
||||||
|
## 0.16.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3cd1586]
|
||||||
|
- Updated dependencies [267f689]
|
||||||
|
- cojson@0.16.5
|
||||||
|
|
||||||
|
## 0.16.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [f9d538f]
|
||||||
|
- Updated dependencies [802b5a3]
|
||||||
|
- cojson@0.16.4
|
||||||
|
|
||||||
|
## 0.16.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cojson@0.16.3
|
||||||
|
|
||||||
|
## 0.16.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cojson@0.16.2
|
||||||
|
|
||||||
## 0.16.1
|
## 0.16.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cojson-transport-ws",
|
"name": "cojson-transport-ws",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.16.1",
|
"version": "0.16.6",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"cojson": "workspace:*"
|
"cojson": "workspace:*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,8 +18,9 @@
|
|||||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist"
|
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:",
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
|
"typescript": "catalog:",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ValueType, metrics } from "@opentelemetry/api";
|
||||||
import type { DisconnectedError, SyncMessage } from "cojson";
|
import type { DisconnectedError, SyncMessage } from "cojson";
|
||||||
import type { Peer } from "cojson";
|
import type { Peer } from "cojson";
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
waitForWebSocketOpen,
|
waitForWebSocketOpen,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
|
|
||||||
const { CO_VALUE_PRIORITY } = cojsonInternals;
|
const { CO_VALUE_PRIORITY, getContentMessageSize } = cojsonInternals;
|
||||||
|
|
||||||
export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000;
|
export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000;
|
||||||
|
|
||||||
@@ -26,11 +27,22 @@ export class BatchedOutgoingMessages
|
|||||||
private queue: PriorityBasedMessageQueue;
|
private queue: PriorityBasedMessageQueue;
|
||||||
private processing = false;
|
private processing = false;
|
||||||
private closed = 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(
|
constructor(
|
||||||
private websocket: AnyWebSocket,
|
private websocket: AnyWebSocket,
|
||||||
private batching: boolean,
|
private batching: boolean,
|
||||||
peerRole: Peer["role"],
|
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(
|
this.queue = new PriorityBasedMessageQueue(
|
||||||
CO_VALUE_PRIORITY.HIGH,
|
CO_VALUE_PRIORITY.HIGH,
|
||||||
@@ -39,6 +51,9 @@ export class BatchedOutgoingMessages
|
|||||||
peerRole: peerRole,
|
peerRole: peerRole,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize the counter by adding 0
|
||||||
|
this.counter.add(0, this.meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
push(msg: SyncMessage | DisconnectedError) {
|
push(msg: SyncMessage | DisconnectedError) {
|
||||||
@@ -93,7 +108,11 @@ export class BatchedOutgoingMessages
|
|||||||
this.processing = false;
|
this.processing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
processMessage(msg: SyncMessage) {
|
private processMessage(msg: SyncMessage) {
|
||||||
|
if (msg.action === "content") {
|
||||||
|
this.counter.add(getContentMessageSize(msg), this.meta);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.batching) {
|
if (!this.batching) {
|
||||||
this.websocket.send(JSON.stringify(msg));
|
this.websocket.send(JSON.stringify(msg));
|
||||||
return;
|
return;
|
||||||
@@ -116,7 +135,7 @@ export class BatchedOutgoingMessages
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessagesInBulk() {
|
private sendMessagesInBulk() {
|
||||||
if (this.backlog.length > 0 && isWebSocketOpen(this.websocket)) {
|
if (this.backlog.length > 0 && isWebSocketOpen(this.websocket)) {
|
||||||
this.websocket.send(this.backlog);
|
this.websocket.send(this.backlog);
|
||||||
this.backlog = "";
|
this.backlog = "";
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { ValueType, metrics } from "@opentelemetry/api";
|
||||||
import { type Peer, type SyncMessage, cojsonInternals, logger } from "cojson";
|
import { type Peer, type SyncMessage, cojsonInternals, logger } from "cojson";
|
||||||
import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
|
import { BatchedOutgoingMessages } from "./BatchedOutgoingMessages.js";
|
||||||
import { deserializeMessages } from "./serialization.js";
|
import { deserializeMessages } from "./serialization.js";
|
||||||
import type { AnyWebSocket } from "./types.js";
|
import type { AnyWebSocket } from "./types.js";
|
||||||
|
|
||||||
const { ConnectedPeerChannel } = cojsonInternals;
|
const { ConnectedPeerChannel, getContentMessageSize } = cojsonInternals;
|
||||||
|
|
||||||
export type CreateWebSocketPeerOpts = {
|
export type CreateWebSocketPeerOpts = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +16,10 @@ export type CreateWebSocketPeerOpts = {
|
|||||||
pingTimeout?: number;
|
pingTimeout?: number;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
/**
|
||||||
|
* Additional key-value attributes to add to the ingress metric.
|
||||||
|
*/
|
||||||
|
meta?: Record<string, string | number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createPingTimeoutListener(
|
function createPingTimeoutListener(
|
||||||
@@ -64,7 +69,19 @@ export function createWebSocketPeer({
|
|||||||
pingTimeout = 10_000,
|
pingTimeout = 10_000,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onClose,
|
onClose,
|
||||||
|
meta,
|
||||||
}: CreateWebSocketPeerOpts): Peer {
|
}: 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 incoming = new ConnectedPeerChannel();
|
||||||
const emitClosedEvent = createClosedEventEmitter(onClose);
|
const emitClosedEvent = createClosedEventEmitter(onClose);
|
||||||
|
|
||||||
@@ -101,6 +118,7 @@ export function createWebSocketPeer({
|
|||||||
websocket,
|
websocket,
|
||||||
batchingByDefault,
|
batchingByDefault,
|
||||||
role,
|
role,
|
||||||
|
meta,
|
||||||
);
|
);
|
||||||
let isFirstMessage = true;
|
let isFirstMessage = true;
|
||||||
|
|
||||||
@@ -135,6 +153,10 @@ export function createWebSocketPeer({
|
|||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (msg && "action" in msg) {
|
if (msg && "action" in msg) {
|
||||||
incoming.push(msg);
|
incoming.push(msg);
|
||||||
|
|
||||||
|
if (msg.action === "content") {
|
||||||
|
totalIngressBytesCounter.add(getContentMessageSize(msg), meta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CojsonInternalTypes, SyncMessage } from "cojson";
|
import type { CojsonInternalTypes, SyncMessage } from "cojson";
|
||||||
import { cojsonInternals } 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 { MAX_OUTGOING_MESSAGES_CHUNK_BYTES } from "../BatchedOutgoingMessages.js";
|
||||||
import {
|
import {
|
||||||
type CreateWebSocketPeerOpts,
|
type CreateWebSocketPeerOpts,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../createWebSocketPeer.js";
|
} from "../createWebSocketPeer.js";
|
||||||
import type { AnyWebSocket } from "../types.js";
|
import type { AnyWebSocket } from "../types.js";
|
||||||
import { BUFFER_LIMIT, BUFFER_LIMIT_POLLING_INTERVAL } from "../utils.js";
|
import { BUFFER_LIMIT, BUFFER_LIMIT_POLLING_INTERVAL } from "../utils.js";
|
||||||
|
import { createTestMetricReader, tearDownTestMetricReader } from "./utils.js";
|
||||||
|
|
||||||
const { CO_VALUE_PRIORITY } = cojsonInternals;
|
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
|
// biome-ignore lint/suspicious/noConfusingVoidType: Test helper
|
||||||
|
|||||||
@@ -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
|
// biome-ignore lint/suspicious/noConfusingVoidType: Test helper
|
||||||
export function waitFor(callback: () => boolean | void) {
|
export function waitFor(callback: () => boolean | void) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
@@ -26,3 +35,71 @@ export function waitFor(callback: () => boolean | void) {
|
|||||||
}, 100);
|
}, 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { assert } from "node:console";
|
import { type CryptoProvider, LocalNode } from "cojson";
|
||||||
import { ControlledAgent, type CryptoProvider, LocalNode } from "cojson";
|
|
||||||
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
# cojson
|
# cojson
|
||||||
|
|
||||||
|
## 0.16.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 67e0968: Fix content streaming chunking, now chunks should be splitted always respecting the MAX_RECOMMENDED_TX_SIZE
|
||||||
|
- ce9ca54: Chunk CoPlainText content to avoid generating bg messages when the user past a megabytes of text
|
||||||
|
- 4b99ff1: Add a multi-storage scheduler to avoid conflicting store operations when having multiple storage instances open on the same database
|
||||||
|
- ac5d20d: Add ingress and egress metering
|
||||||
|
- 9bf7946: Added a TTL based optional garbage collection for covalues
|
||||||
|
|
||||||
|
## 0.16.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
|
||||||
|
|
||||||
|
Also changes the Group.removeMember method to not return a Promise, because:
|
||||||
|
|
||||||
|
- All the locally available child groups are rotated immediately
|
||||||
|
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
|
||||||
|
|
||||||
|
- 267f689: Groups: fix the readkey not being revealed to everyone when doing a key rotation
|
||||||
|
|
||||||
|
## 0.16.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- f9d538f: Fix the error raised when extending a group without having child groups loaded
|
||||||
|
- 802b5a3: Refactor local updates sync to ensure that the changes are synced respecting the insertion order
|
||||||
|
|
||||||
|
## 0.16.3
|
||||||
|
|
||||||
|
## 0.16.2
|
||||||
|
|
||||||
## 0.16.1
|
## 0.16.1
|
||||||
|
|
||||||
## 0.16.0
|
## 0.16.0
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "0.16.1",
|
"version": "0.16.6",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||||
"libsql": "^0.5.13",
|
"libsql": "^0.5.13",
|
||||||
|
|||||||
48
packages/cojson/src/GarbageCollector.ts
Normal file
48
packages/cojson/src/GarbageCollector.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { AvailableCoValueCore } from "./coValueCore/coValueCore.js";
|
||||||
AvailableCoValueCore,
|
|
||||||
CoValueCore,
|
|
||||||
} from "./coValueCore/coValueCore.js";
|
|
||||||
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
|
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
|
||||||
import { RawCoList } from "./coValues/coList.js";
|
import { RawCoList } from "./coValues/coList.js";
|
||||||
import { RawCoMap } from "./coValues/coMap.js";
|
import { RawCoMap } from "./coValues/coMap.js";
|
||||||
|
|||||||
86
packages/cojson/src/coValueContentMessage.ts
Normal file
86
packages/cojson/src/coValueContentMessage.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
|
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
|
||||||
import { Result, err } from "neverthrow";
|
import { Result, err } from "neverthrow";
|
||||||
import { PeerState } from "../PeerState.js";
|
import type { PeerState } from "../PeerState.js";
|
||||||
import { RawCoValue } from "../coValue.js";
|
import type { RawCoValue } from "../coValue.js";
|
||||||
import { ControlledAccountOrAgent, RawAccountID } from "../coValues/account.js";
|
import type { ControlledAccountOrAgent } from "../coValues/account.js";
|
||||||
import { RawGroup } from "../coValues/group.js";
|
import type { RawGroup } from "../coValues/group.js";
|
||||||
import { CO_VALUE_LOADING_CONFIG, MAX_RECOMMENDED_TX_SIZE } from "../config.js";
|
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
|
||||||
import { coreToCoValue } from "../coreToCoValue.js";
|
import { coreToCoValue } from "../coreToCoValue.js";
|
||||||
import {
|
import {
|
||||||
CryptoProvider,
|
CryptoProvider,
|
||||||
@@ -16,25 +16,15 @@ import {
|
|||||||
SignerID,
|
SignerID,
|
||||||
StreamingHash,
|
StreamingHash,
|
||||||
} from "../crypto/crypto.js";
|
} from "../crypto/crypto.js";
|
||||||
import {
|
import { RawCoID, SessionID, TransactionID } from "../ids.js";
|
||||||
RawCoID,
|
|
||||||
SessionID,
|
|
||||||
TransactionID,
|
|
||||||
getParentGroupId,
|
|
||||||
isParentGroupReference,
|
|
||||||
} from "../ids.js";
|
|
||||||
import { parseJSON, stableStringify } from "../jsonStringify.js";
|
import { parseJSON, stableStringify } from "../jsonStringify.js";
|
||||||
import { JsonValue } from "../jsonValue.js";
|
import { JsonValue } from "../jsonValue.js";
|
||||||
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
|
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import {
|
import { determineValidTransactions } from "../permissions.js";
|
||||||
determineValidTransactions,
|
|
||||||
isKeyForKeyField,
|
|
||||||
} from "../permissions.js";
|
|
||||||
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
|
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
|
||||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
|
||||||
import { getDependedOnCoValuesFromRawData } from "./utils.js";
|
import { getDependedOnCoValuesFromRawData } from "./utils.js";
|
||||||
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
|
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
|
||||||
|
|
||||||
@@ -53,8 +43,6 @@ export type DecryptedTransaction = {
|
|||||||
trusting?: boolean;
|
trusting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
|
||||||
|
|
||||||
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
|
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
|
||||||
|
|
||||||
export class CoValueCore {
|
export class CoValueCore {
|
||||||
@@ -81,7 +69,9 @@ export class CoValueCore {
|
|||||||
}
|
}
|
||||||
private readonly peers = new Map<
|
private readonly peers = new Map<
|
||||||
PeerID,
|
PeerID,
|
||||||
| { type: "unknown" | "pending" | "available" | "unavailable" }
|
| {
|
||||||
|
type: "unknown" | "pending" | "available" | "unavailable";
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "errored";
|
type: "errored";
|
||||||
error: TryAddTransactionsError;
|
error: TryAddTransactionsError;
|
||||||
@@ -90,9 +80,8 @@ export class CoValueCore {
|
|||||||
|
|
||||||
// cached state and listeners
|
// cached state and listeners
|
||||||
private _cachedContent?: RawCoValue;
|
private _cachedContent?: RawCoValue;
|
||||||
private readonly listeners: Set<
|
readonly listeners: Set<(core: CoValueCore, unsub: () => void) => void> =
|
||||||
(core: CoValueCore, unsub: () => void) => void
|
new Set();
|
||||||
> = new Set();
|
|
||||||
private readonly _decryptionCache: {
|
private readonly _decryptionCache: {
|
||||||
[key: Encrypted<JsonValue[], JsonValue>]: JsonValue[] | undefined;
|
[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) {
|
markNotFoundInPeer(peerId: PeerID) {
|
||||||
const previousState = this.loadingState;
|
const previousState = this.loadingState;
|
||||||
this.peers.set(peerId, { type: "unavailable" });
|
this.peers.set(peerId, { type: "unavailable" });
|
||||||
@@ -380,7 +389,7 @@ export class CoValueCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
knownStateWithStreaming(): CoValueKnownState {
|
knownStateWithStreaming(): CoValueKnownState {
|
||||||
if (this.isAvailable()) {
|
if (this.verified) {
|
||||||
return this.verified.knownStateWithStreaming();
|
return this.verified.knownStateWithStreaming();
|
||||||
} else {
|
} else {
|
||||||
return emptyKnownState(this.id);
|
return emptyKnownState(this.id);
|
||||||
@@ -388,7 +397,7 @@ export class CoValueCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
knownState(): CoValueKnownState {
|
knownState(): CoValueKnownState {
|
||||||
if (this.isAvailable()) {
|
if (this.verified) {
|
||||||
return this.verified.knownState();
|
return this.verified.knownState();
|
||||||
} else {
|
} else {
|
||||||
return emptyKnownState(this.id);
|
return emptyKnownState(this.id);
|
||||||
@@ -605,16 +614,23 @@ export class CoValueCore {
|
|||||||
)._unsafeUnwrap({ withStackTrace: true });
|
)._unsafeUnwrap({ withStackTrace: true });
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
const session = this.verified.sessions.get(sessionID);
|
||||||
|
const txIdx = session ? session.transactions.length - 1 : 0;
|
||||||
|
|
||||||
this.node.syncManager.recordTransactionsSize([transaction], "local");
|
this.node.syncManager.recordTransactionsSize([transaction], "local");
|
||||||
void this.node.syncManager.requestCoValueSync(this);
|
this.node.syncManager.syncLocalTransaction(
|
||||||
|
this.verified,
|
||||||
|
transaction,
|
||||||
|
sessionID,
|
||||||
|
signature,
|
||||||
|
txIdx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentContent(options?: {
|
getCurrentContent(options?: { ignorePrivateTransactions: true }): RawCoValue {
|
||||||
ignorePrivateTransactions: true;
|
|
||||||
}): RawCoValue {
|
|
||||||
if (!this.verified) {
|
if (!this.verified) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"CoValueCore: getCurrentContent called on coValue without verified state",
|
"CoValueCore: getCurrentContent called on coValue without verified state",
|
||||||
@@ -759,20 +775,7 @@ export class CoValueCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.verified.header.ruleset.type === "group") {
|
if (this.verified.header.ruleset.type === "group") {
|
||||||
const content = expectGroup(this.getCurrentContent());
|
return expectGroup(this.getCurrentContent()).getCurrentReadKey();
|
||||||
|
|
||||||
const currentKeyId = content.getCurrentReadKeyId();
|
|
||||||
|
|
||||||
if (!currentKeyId) {
|
|
||||||
throw new Error("No readKey set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = this.getReadKey(currentKeyId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
secret: secret,
|
|
||||||
id: currentKeyId,
|
|
||||||
};
|
|
||||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||||
return this.node
|
return this.node
|
||||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||||
@@ -784,154 +787,36 @@ export class CoValueCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readKeyCache = new Map<KeyID, KeySecret>();
|
||||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||||
let key = readKeyCache.get(this)?.[keyID];
|
// We want to check the cache here, to skip re-computing the group content
|
||||||
if (!key) {
|
const cachedSecret = this.readKeyCache.get(keyID);
|
||||||
key = this.getUncachedReadKey(keyID);
|
|
||||||
if (key) {
|
if (cachedSecret) {
|
||||||
let cache = readKeyCache.get(this);
|
return cachedSecret;
|
||||||
if (!cache) {
|
}
|
||||||
cache = {};
|
|
||||||
readKeyCache.set(this, cache);
|
|
||||||
}
|
|
||||||
cache[keyID] = key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
|
|
||||||
if (!this.verified) {
|
if (!this.verified) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"CoValueCore: getUncachedReadKey called on coValue without verified state",
|
"CoValueCore: getUncachedReadKey called on coValue without verified state",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Getting the readKey from accounts
|
||||||
if (this.verified.header.ruleset.type === "group") {
|
if (this.verified.header.ruleset.type === "group") {
|
||||||
const content = expectGroup(
|
const content = expectGroup(
|
||||||
this.getCurrentContent({ ignorePrivateTransactions: true }), // to prevent recursion
|
// load the account without private transactions, because we are here
|
||||||
);
|
// to be able to decrypt those
|
||||||
const keyForEveryone = content.get(`${keyID}_for_everyone`);
|
this.getCurrentContent({ ignorePrivateTransactions: true }),
|
||||||
if (keyForEveryone) {
|
|
||||||
return keyForEveryone;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find key revelation for us
|
|
||||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
|
||||||
this.node.currentSessionID,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// being careful here to avoid recursion
|
return content.getReadKey(keyID);
|
||||||
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;
|
|
||||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||||
return this.node
|
return expectGroup(
|
||||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
this.node
|
||||||
.getReadKey(keyID);
|
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||||
|
.getCurrentContent(),
|
||||||
|
).getReadKey(keyID);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Only groups or values owned by groups have read secrets",
|
"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 {
|
getGroup(): RawGroup {
|
||||||
if (!this.verified) {
|
if (!this.verified) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1007,9 +870,7 @@ export class CoValueCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForSync(options?: {
|
waitForSync(options?: { timeout?: number }) {
|
||||||
timeout?: number;
|
|
||||||
}) {
|
|
||||||
return this.node.syncManager.waitForSync(this.id, options?.timeout);
|
return this.node.syncManager.waitForSync(this.id, options?.timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user