Compare commits
25 Commits
jazz-examp
...
jazz-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
012bd43865 | ||
|
|
ffc1181b81 | ||
|
|
4ca5e258b5 | ||
|
|
2255c824b7 | ||
|
|
8ed59e40e9 | ||
|
|
03b34b4b66 | ||
|
|
53c93f6a0b | ||
|
|
4af7f25eab | ||
|
|
6d6e8a0e28 | ||
|
|
4a617c8323 | ||
|
|
eaed275a79 | ||
|
|
01fdcaed34 | ||
|
|
7aeb1a789b | ||
|
|
a00649fa29 | ||
|
|
764954c727 | ||
|
|
b0ec93eb3a | ||
|
|
4dd226bc95 | ||
|
|
1692340856 | ||
|
|
fbda78f908 | ||
|
|
61e9f6afad | ||
|
|
246bbb119d | ||
|
|
80054515c9 | ||
|
|
f9486a82c3 | ||
|
|
d0babab822 | ||
|
|
fe1092ccf6 |
172
README.md
172
README.md
@@ -164,7 +164,7 @@ Returns the role of the current account in the group.
|
||||
|
||||
```typescript
|
||||
addMember(
|
||||
accountID: AccountIDOrAgentID,
|
||||
accountID: AccountID,
|
||||
role: "reader" | "writer" | "admin"
|
||||
)
|
||||
```
|
||||
@@ -189,15 +189,22 @@ Strips the specified member of all roles (preventing future writes) and rotates
|
||||
|
||||
#### `Group.createMap(meta?)`
|
||||
```typescript
|
||||
createMap<
|
||||
M extends { [key: string]: JsonValue },
|
||||
Meta extends JsonObject | null = null
|
||||
>(meta?: Meta): CoMap<M, Meta>
|
||||
createMap<M extends CoMap<{ [key: string]: JsonValue }, JsonObject | null>>(
|
||||
meta?: M["meta"]
|
||||
): M
|
||||
```
|
||||
|
||||
Creates a new `CoMap` within this group, with the specified inner content type `M` and optional static metadata.
|
||||
Creates a new `CoMap` within this group, with the specified specialized `CoMap` type `M` and optional static metadata.
|
||||
|
||||
#### `Group.createList(meta?)`
|
||||
```typescript
|
||||
createList<L extends CoList<JsonValue, JsonObject | null>>(
|
||||
meta?: L["meta"]
|
||||
): L
|
||||
```
|
||||
|
||||
Creates a new `CoList` within this group, with the specified specialized `CoList` type `L` and optional static metadata.
|
||||
|
||||
#### `Group.createList(meta?)` (coming soon)
|
||||
#### `Group.createStream(meta?)` (coming soon)
|
||||
#### `Group.createStatic(meta)` (coming soon)
|
||||
|
||||
@@ -219,6 +226,14 @@ id: CoID<CoMap<M, Meta>>
|
||||
|
||||
Returns the CoMap's (precisely typed) `CoID`
|
||||
|
||||
#### `CoMap.meta`
|
||||
|
||||
```typescript
|
||||
meta: Meta
|
||||
```
|
||||
|
||||
Returns the CoMap's (precisely typed) static metadata
|
||||
|
||||
#### `CoMap.keys()`
|
||||
|
||||
```typescript
|
||||
@@ -233,10 +248,10 @@ get<K extends keyof M>(key: K): M[K] | undefined
|
||||
|
||||
Returns the current value for the given key.
|
||||
|
||||
#### `CoMap.getLastEditor(key)`
|
||||
#### `CoMap.whoEdited(key)`
|
||||
|
||||
```typescript
|
||||
getLastEditor<K extends keyof M>(key: K): AccountID | undefined
|
||||
whoEdited<K extends keyof M>(key: K): AccountID | undefined
|
||||
```
|
||||
|
||||
Returns the accountID of the last account to modify the value for the given key.
|
||||
@@ -307,10 +322,145 @@ If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction
|
||||
If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `CoList` (not yet implemented)
|
||||
### `CoValue` ContentType: `CoList`
|
||||
|
||||
```typescript
|
||||
class CoList<
|
||||
T extends JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
>
|
||||
```
|
||||
|
||||
#### `CoList.id`
|
||||
|
||||
```typescript
|
||||
id: CoID<CoList<T, Meta>>
|
||||
```
|
||||
|
||||
Returns the CoList's (precisely typed) `CoID`
|
||||
|
||||
#### `CoList.meta`
|
||||
|
||||
```typescript
|
||||
meta: Meta
|
||||
```
|
||||
|
||||
Returns the CoList's (precisely typed) static metadata
|
||||
|
||||
### `CoList.asArray()`
|
||||
|
||||
```typescript
|
||||
asArray(): T[]
|
||||
```
|
||||
|
||||
Returns the current items in the CoList as an array.
|
||||
|
||||
### `CoList.toJSON()`
|
||||
|
||||
```typescript
|
||||
toJSON(): T[]
|
||||
```
|
||||
|
||||
Returns the current items in the CoList as an array. (alias of asArray)
|
||||
|
||||
#### `CoList.whoInserted(idx)`
|
||||
|
||||
```typescript
|
||||
whoInserted(idx: number): AccountID | undefined
|
||||
```
|
||||
|
||||
Returns the accountID of the account that inserted value at the given index.
|
||||
|
||||
#### `CoList.subscribe(listener)`
|
||||
|
||||
```typescript
|
||||
subscribe(
|
||||
listener: (coMap: CoList<T, Meta>) => void
|
||||
): () => void
|
||||
```
|
||||
Lets you subscribe to future updates to this CoList (whether made locally or by other users). Takes a listener function that will be called with the current state for each update. Returns an unsubscribe function.
|
||||
|
||||
Used internally by `useTelepathicData()` for reactive updates on changes to a `CoList`.
|
||||
|
||||
#### `CoList.edit(editable => {...})`
|
||||
|
||||
```typescript
|
||||
edit(changer: (editable: WriteableCoList<T, Meta>) => void): CoList<T, Meta>
|
||||
```
|
||||
|
||||
Lets you apply edits to a `CoList`, inside the changer callback, which receives a `WriteableCoList`. A `WritableCoList` has all the same methods as a `CoList`, but all edits made to it with `append`, `push`, `prepend` or `delete` are reflected in it immediately - so it behaves mutably, whereas a `CoList` is always immutable (you need to use `subscribe` to receive new versions of it).
|
||||
|
||||
```typescript
|
||||
export class WriteableCoList<
|
||||
T extends JsonValue,
|
||||
Meta extends JsonObject | null = null,
|
||||
> extends CoList<T, Meta>
|
||||
```
|
||||
|
||||
#### `WritableCoList.append(after, value)`
|
||||
|
||||
```typescript
|
||||
append(
|
||||
after: number,
|
||||
value: T,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void
|
||||
```
|
||||
|
||||
Appends a new item after index `after`.
|
||||
|
||||
If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
|
||||
If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
|
||||
#### `WritableCoList.prepend(after, value)`
|
||||
|
||||
```typescript
|
||||
prepend(
|
||||
before: number,
|
||||
value: T,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void
|
||||
```
|
||||
|
||||
Prepends a new item before index `before`.
|
||||
|
||||
If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
|
||||
If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
|
||||
#### `WritableCoList.push(value)`
|
||||
|
||||
```typescript
|
||||
push(
|
||||
value: T,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void
|
||||
```
|
||||
|
||||
Pushes a new item to the end of the list.
|
||||
|
||||
If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
|
||||
If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
|
||||
#### `WritableCoList.delete(at)`
|
||||
|
||||
```typescript
|
||||
delete(
|
||||
at: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void
|
||||
```
|
||||
|
||||
Deletes the item at index `at` from the list.
|
||||
|
||||
If `privacy` is `"private"` **(default)**, the fact of this deletion is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
|
||||
|
||||
If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `CoStram` (not yet implemented)
|
||||
### `CoValue` ContentType: `CoStream` (not yet implemented)
|
||||
|
||||
---
|
||||
### `CoValue` ContentType: `Static` (not yet implemented)
|
||||
|
||||
@@ -2,343 +2,63 @@
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
More comprehensive guide coming soon, but these are the most important bits, with explanations:
|
||||
## Installing & running the example locally
|
||||
|
||||
From `./src/main.tsx`
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
// ...
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> Jazz Todo List
|
||||
Example
|
||||
</div>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Jazz Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/todo
|
||||
```
|
||||
|
||||
This shows how to use the top-level component `<WithJazz/>`, which provides the rest of the app with a `LocalNode` (used through `useJazz` later), based on `LocalAuth` that uses Passkeys to store a user's account secret - no backend needed.
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Let's move on to the main app code.
|
||||
Install dependencies:
|
||||
|
||||
---
|
||||
|
||||
From `./src/App.tsx`
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
createInviteLink
|
||||
} from "jazz-react";
|
||||
|
||||
// ...
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
|
||||
type TodoListContent = {
|
||||
title: string;
|
||||
// other keys form a set of task IDs
|
||||
[taskId: CoID<Task>]: true;
|
||||
};
|
||||
type TodoList = CoMap<TodoListContent>;
|
||||
|
||||
// ...
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
First, we define our main data model of tasks and todo lists, using CoJSON's collaborative map type, `CoMap`. We reference CoMaps of individual tasks by using them as keys inside the `TodoList` CoMap - as a makeshift solution until `CoList` is implemented.
|
||||
Start the dev server:
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
export default function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>();
|
||||
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation =
|
||||
await consumeInviteLinkFromWindowLocation(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setListId(acceptedInvitation.valueID as CoID<TodoList>);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
const listGroup = localNode.createGroup();
|
||||
const list = listGroup.createMap<TodoListContent>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
},
|
||||
[localNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoListComponent listId={listId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createList}
|
||||
label="Create New List"
|
||||
placeholder="New list title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`<App>` is the main app component, handling client-side routing based on the CoValue ID (`CoID`) of our `TodoList`, stored in the URL hash - which can also contain invite links, which we intercept and use with `consumeInviteLinkFromWindowLocation`.
|
||||
## Structure
|
||||
|
||||
`createList` is the first time we see CoJSON in action: using our `localNode` (which we got from `useJazz`), we first create a group for a new todo list (which allows us to set permissions later). Then, within that group, we create a new `CoMap<TodoListContent>` with `listGroup.createMap()`.
|
||||
- [`src/basicComponents`](./src/basicComponents) contains simple components to build the UI, unrelated to Jazz (powered by [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/) contains helper components that do contain Jazz-specific logic, but are not super relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/0_main.tsx`](./src/0_main.tsx), [`src/1_types.ts`](./src/1_types.ts), [`src/2_App.tsx`](./src/2_App.tsx), [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx), [`src/router.ts`](./src/router.ts) - the main files for this example, see the walkthrough below
|
||||
|
||||
We immediately start editing the created `list`. Within the edit callback, we can use the `set` function, to collaboratively set the key `title` to the initial title provided to `createList`.
|
||||
## Walkthrough
|
||||
|
||||
If we have a current `listId` set, we render `<TodoListComponent>` with it, which we'll see next.
|
||||
### Main parts
|
||||
|
||||
If we have no `listId` set, the user can use the displayed creation input to create (and open) their first list.
|
||||
- The top-level provider `<WithJazz/>`: [`src/0_main.tsx`](./src/0_main.tsx)
|
||||
|
||||
---
|
||||
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
```typescript
|
||||
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
|
||||
|
||||
const createTask = (text: string) => {
|
||||
if (!list) return;
|
||||
const task = list.coValue.getGroup().createMap<TaskContent>();
|
||||
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
### Helpers
|
||||
|
||||
list.edit((list) => {
|
||||
list.set(task.id, true);
|
||||
});
|
||||
};
|
||||
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && <InviteButton list={list} />}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list &&
|
||||
list
|
||||
.keys()
|
||||
.filter((key): key is CoID<Task> =>
|
||||
key.startsWith("co_")
|
||||
)
|
||||
.map((taskId) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
Here in `<TodoListComponent>`, we use `useTelepathicData()` for the first time, in this case to load the CoValue for our `TodoList` and to reactively subscribe to updates to its content - whether we create edits locally, load persisted data, or receive sync updates from other devices or participants!
|
||||
|
||||
`createTask` is similar to `createList` we saw earlier, creating a new CoMap for a new task, and then adding it as a key to our `TodoList`.
|
||||
|
||||
As you can see, we iterate over the keys of `TodoList` and for those that look like `CoID`s (they always start with `co_`), we render a `<TaskRow>`.
|
||||
|
||||
Below all tasks, we render a simple input for adding a task.
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || <Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />}
|
||||
</span>
|
||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`<TaskRow>` uses `useTelepathicState()` as well, to granularly load and subscribe to changes for that particular task (the only thing we let the user change is the "done" status).
|
||||
|
||||
We also use a `<NameBadge>` helper component to render the name of the author of the task, which we get by using the collaboration feature `getLastEditor(key)` on our `Task` CoMap, which returns the accountID of the last account that changed a given key in the CoMap.
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return (
|
||||
profile?.get("name") && <span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={{
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
}}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`<NameBadge>` uses `useProfile(accountID)`, which is a shorthand for loading an account's profile (which is always a `CoMap<{name: string}>`, but might have app-specific additional properties).
|
||||
|
||||
In our case, we just display the profile name (which, by the way, is set by the `LocalAuth` provider when we first create an account).
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function InviteButton({ list }: { list: TodoList }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list.coValue.getGroup().myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
description: "Copied invite link to clipboard!",
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Last, we have a look at the `<InviteButton>` component, which we use inside `<TodoListComponent>`. It only becomes visible when the current user is an admin in the `TodoList`'s group. You can see how we can create an invite link using `createInviteLink(coValue, role)` that allows anyone who has it to join the group as a specified role (here, as a writer).
|
||||
|
||||
---
|
||||
- (not yet commented) `location.hash`-based routing and accepting invite links with `useSimpleHashRouterThatAcceptsInvites()` in [`src/router.ts`](./src/router.ts)
|
||||
|
||||
This is the whole Todo List app!
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/0_main.tsx](./src/0_main.tsx).
|
||||
@@ -10,7 +10,7 @@
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/0_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.24",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,14 +16,14 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.1.6",
|
||||
"jazz-react-auth-local": "^0.1.6",
|
||||
"lucide-react": "^0.265.0",
|
||||
"jazz-react": "^0.1.10",
|
||||
"jazz-react-auth-local": "^0.1.10",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
38
examples/todo/src/0_main.tsx
Normal file
38
examples/todo/src/0_main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import { ThemeProvider, TitleAndLogo } from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import App from "./2_App.tsx";
|
||||
|
||||
/** Walkthrough: The top-level provider `<WithJazz/>`
|
||||
*
|
||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
||||
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
|
||||
* - no backend needed. */
|
||||
|
||||
const appName = "Jazz Todo List Example";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
})}
|
||||
>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
28
examples/todo/src/1_types.ts
Normal file
28
examples/todo/src/1_types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CoMap, CoList, CoID } from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
* Here, we define our main data model of tasks, lists of tasks and projects
|
||||
* using CoJSON's collaborative map and list types, CoMap & CoList.
|
||||
*
|
||||
* CoMap values and CoLists items can be:
|
||||
* - arbitrary immutable JSON
|
||||
* - references to other CoValues by their CoID
|
||||
* - CoIDs are strings that look like `co_zXPuWmH1D1cKdMpDW6CMzWb3LpY`
|
||||
* - In TypeScript, CoIDs take a generic parameter for the type of the
|
||||
* referenced CoValue, e.g. `CoID<Task>` - to make the references precise
|
||||
**/
|
||||
|
||||
/** An individual task which collaborators can tick or rename */
|
||||
export type Task = CoMap<{ done: boolean; text: string; }>;
|
||||
|
||||
/** A collaborative, ordered list of task references */
|
||||
export type ListOfTasks = CoList<CoID<Task>>;
|
||||
|
||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
||||
export type TodoProject = CoMap<{
|
||||
title: string;
|
||||
tasks: CoID<ListOfTasks>;
|
||||
}>;
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
78
examples/todo/src/2_App.tsx
Normal file
78
examples/todo/src/2_App.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { TodoProject, ListOfTasks } from "./1_types";
|
||||
|
||||
import { SubmittableInput, Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { TodoTable } from "./3_TodoTable";
|
||||
|
||||
/** Walkthrough: Creating todo projects & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
export default function App() {
|
||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
||||
// It is associated with a current user account, which will determine
|
||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
// This sets up routing and accepting invites, skip for now
|
||||
const [currentProjectId, navigateToProjectId] =
|
||||
useSimpleHashRouterThatAcceptsInvites<TodoProject>(localNode);
|
||||
|
||||
const createProject = useCallback(
|
||||
(title: string) => {
|
||||
if (!title) return;
|
||||
|
||||
// To create a new todo project, we first create a `Group`,
|
||||
// which is a scope for defining access rights (reader/writer/admin)
|
||||
// of its members, which will apply to all CoValues owned by that group.
|
||||
const projectGroup = localNode.createGroup();
|
||||
|
||||
// Then we create an empty todo project and list of tasks within that group.
|
||||
const project = projectGroup.createMap<TodoProject>();
|
||||
const tasks = projectGroup.createList<ListOfTasks>();
|
||||
|
||||
// We edit the todo project to initialise it.
|
||||
// Inside the `.edit` callback we can mutate a CoValue
|
||||
project.edit((project) => {
|
||||
project.set("title", title);
|
||||
project.set("tasks", tasks.id);
|
||||
});
|
||||
|
||||
navigateToProjectId(project.id);
|
||||
},
|
||||
[localNode, navigateToProjectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{currentProjectId ? (
|
||||
<TodoTable projectId={currentProjectId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createProject}
|
||||
label="Create New Project"
|
||||
placeholder="New project title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateToProjectId(undefined);
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_TodoTable.tsx */
|
||||
162
examples/todo/src/3_TodoTable.tsx
Normal file
162
examples/todo/src/3_TodoTable.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID } from "cojson";
|
||||
import { useTelepathicState } from "jazz-react";
|
||||
|
||||
import { TodoProject, Task } from "./1_types";
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
SubmittableInput,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./basicComponents";
|
||||
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import { NameBadge } from "./components/NameBadge";
|
||||
|
||||
/** Walkthrough: Reactively rendering a todo project as a table,
|
||||
* adding and editing tasks
|
||||
*
|
||||
* Here in `<TodoTable/>`, we use `useTelepathicData()` for the first time,
|
||||
* in this case to load the CoValue for our `TodoProject` as well as
|
||||
* the `ListOfTasks` referenced in it.
|
||||
*/
|
||||
|
||||
export function TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
|
||||
// `useTelepathicData()` reactively subscribes to updates to a CoValue's
|
||||
// content - whether we create edits locally, load persisted data, or receive
|
||||
// sync updates from other devices or participants!
|
||||
const project = useTelepathicState(projectId);
|
||||
const projectTasks = useTelepathicState(project?.get("tasks"));
|
||||
|
||||
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
|
||||
// for a new task (in the same group as the list of tasks/the project), and then
|
||||
// adding it as an item to the project's list of tasks.
|
||||
const createTask = useCallback(
|
||||
(text: string) => {
|
||||
if (!projectTasks || !text) return;
|
||||
const task = projectTasks.group.createMap<Task>();
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
projectTasks.edit((projectTasks) => {
|
||||
projectTasks.push(task.id);
|
||||
});
|
||||
},
|
||||
[projectTasks]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{
|
||||
// This is how we can access properties from the project,
|
||||
// accounting for the fact that it might not be loaded yet
|
||||
project?.get("title") ? (
|
||||
<>
|
||||
{project.get("title")}{" "}
|
||||
<span className="text-sm">({project.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<InviteButton list={project} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{
|
||||
// Here, we iterate over the items of our `ListOfTasks`
|
||||
// and render a `<TaskRow>` for each.
|
||||
|
||||
projectTasks?.map((taskId: CoID<Task>) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))
|
||||
}
|
||||
<NewTaskInputRow
|
||||
createTask={createTask}
|
||||
disabled={!project}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
// `<TaskRow/>` uses `useTelepathicState()` as well, to granularly load and
|
||||
// subscribe to changes for that particular task.
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
// (the only thing we let the user change is the "done" status)
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
{/* We also use a `<NameBadge/>` helper component to render the name
|
||||
of the author of the task. We get the author by using the collaboration
|
||||
feature `whoEdited(key)` on our `Task` CoMap, which returns the accountID
|
||||
of the last account that changed a given key in the CoMap. */}
|
||||
<NameBadge accountID={task?.whoEdited("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskInputRow({
|
||||
createTask,
|
||||
disabled,
|
||||
}: {
|
||||
createTask: (text: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
createInviteLink,
|
||||
} from "jazz-react";
|
||||
|
||||
import { SubmittableInput } from "./components/SubmittableInput";
|
||||
import { useToast } from "./components/ui/use-toast";
|
||||
import { Skeleton } from "./components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import uniqolor from "uniqolor";
|
||||
import QRCode from "qrcode";
|
||||
import { CoList } from "cojson/dist/contentTypes/coList";
|
||||
|
||||
type Task = CoMap<{ done: boolean; text: string }>;
|
||||
|
||||
type ListOfTasks = CoList<CoID<Task>>;
|
||||
|
||||
type TodoList = CoMap<{
|
||||
title: string;
|
||||
tasks: CoID<ListOfTasks>;
|
||||
}>;
|
||||
|
||||
export default function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>();
|
||||
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation =
|
||||
await consumeInviteLinkFromWindowLocation(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setListId(acceptedInvitation.valueID as CoID<TodoList>);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
if (!title) return;
|
||||
const listGroup = localNode.createGroup();
|
||||
const list = listGroup.createMap<TodoList>();
|
||||
const tasks = listGroup.createList<ListOfTasks>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
list.set("tasks", tasks.id);
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
},
|
||||
[localNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoListComponent listId={listId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createList}
|
||||
label="Create New List"
|
||||
placeholder="New list title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
const tasks = useTelepathicState(list?.get("tasks"));
|
||||
|
||||
const createTask = (text: string) => {
|
||||
if (!tasks || !text) return;
|
||||
const task = tasks.coValue.getGroup().createMap<Task>();
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
tasks.edit((tasks) => {
|
||||
tasks.push(task.id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && <InviteButton list={list} />}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks &&
|
||||
tasks
|
||||
.asArray()
|
||||
.map((taskId) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return (
|
||||
profile?.get("name") ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={{
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
}}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InviteButton({ list }: { list: TodoList }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list.coValue.getGroup().myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
const qr = await QRCode.toDataURL(inviteLink, {
|
||||
errorCorrectionLevel: "L",
|
||||
});
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
title: "Copied invite link to clipboard!",
|
||||
description: (
|
||||
<img src={qr} className="w-20 h-20" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/basicComponents/ui/input";
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit,
|
||||
10
examples/todo/src/basicComponents/TitleAndLogo.tsx
Normal file
10
examples/todo/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Toaster } from ".";
|
||||
|
||||
export function TitleAndLogo({name}: {name: string}) {
|
||||
return <>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
}
|
||||
17
examples/todo/src/basicComponents/index.ts
Normal file
17
examples/todo/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Checkbox } from "./ui/checkbox";
|
||||
export { Input } from "./ui/input";
|
||||
export { Skeleton } from "./ui/skeleton";
|
||||
export { Toaster } from "./ui/toaster";
|
||||
export { useToast } from "./ui/use-toast";
|
||||
export { SubmittableInput } from "./SubmittableInput";
|
||||
export { TitleAndLogo } from "./TitleAndLogo";
|
||||
export { ThemeProvider } from "./themeProvider";
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./ui/table";
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -3,7 +3,7 @@ import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
import { useState } from "react";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const PrettyAuthComponent: LocalAuthComponent = ({
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
46
examples/todo/src/components/InviteButton.tsx
Normal file
46
examples/todo/src/components/InviteButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { TodoProject } from "../1_types";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
|
||||
export function InviteButton({ list }: { list?: TodoProject }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list?.group.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
const qr = await QRCode.toDataURL(inviteLink, {
|
||||
errorCorrectionLevel: "L",
|
||||
});
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
title: "Copied invite link to clipboard!",
|
||||
description: (
|
||||
<img src={qr} className="w-20 h-20" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
46
examples/todo/src/components/NameBadge.tsx
Normal file
46
examples/todo/src/components/NameBadge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AccountID } from "cojson";
|
||||
import { useProfile } from "jazz-react";
|
||||
|
||||
import { Skeleton } from "@/basicComponents";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: Getting user profiles in `<NameBadge/>`
|
||||
*
|
||||
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
|
||||
* useTelepathicState on an account's profile.
|
||||
*
|
||||
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
|
||||
* additional properties).
|
||||
*
|
||||
* In our case, we just display the profile name (which is set by the LocalAuth
|
||||
* provider when we first create an account).
|
||||
*/
|
||||
|
||||
export function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
return accountID && profile?.get("name") ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={randomUserColor(accountID)}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
);
|
||||
}
|
||||
|
||||
function randomUserColor(accountID: AccountID) {
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return {
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -14,63 +9,63 @@
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import { PrettyAuthComponent } from "./components/prettyAuth.tsx";
|
||||
import { ThemeProvider } from "./components/themeProvider.tsx";
|
||||
import { Toaster } from "./components/ui/toaster.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
// <React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> Jazz Todo List
|
||||
Example
|
||||
</div>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Jazz Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
syncAddress={
|
||||
new URLSearchParams(window.location.search).get("sync") ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<App />
|
||||
<Toaster />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
// </React.StrictMode>
|
||||
);
|
||||
37
examples/todo/src/router.ts
Normal file
37
examples/todo/src/router.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, ContentType } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends ContentType>(
|
||||
localNode: LocalNode
|
||||
) {
|
||||
const [currentValueId, setCurrentValueId] = useState<CoID<C>>();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation = await consumeInviteLinkFromWindowLocation<C>(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setCurrentValueId(acceptedInvitation.valueID);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentValueId(
|
||||
(window.location.hash.slice(1) as CoID<C>) || undefined
|
||||
);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const navigateToValue = useCallback((id: CoID<C> | undefined) => {
|
||||
window.location.hash = id || "";
|
||||
}, []);
|
||||
|
||||
return [currentValueId, navigateToValue] as const;
|
||||
}
|
||||
@@ -8,5 +8,10 @@
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"lerna": "^7.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"build-all": "lerna run build",
|
||||
"updated": "lerna updated --include-merged-tags",
|
||||
"publish-all": "lerna publish --include-merged-tags"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
@@ -16,8 +16,8 @@
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"cojson-storage-sqlite": "^0.1.3",
|
||||
"cojson": "^0.1.8",
|
||||
"cojson-storage-sqlite": "^0.1.6",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.1.5",
|
||||
"cojson": "^0.1.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -34,7 +34,7 @@ test("A node with an account can create groups and and objects within them", asy
|
||||
|
||||
expect(map.get("foo")).toEqual("bar");
|
||||
|
||||
expect(map.getLastEditor("foo")).toEqual(accountID);
|
||||
expect(map.whoEdited("foo")).toEqual(accountID);
|
||||
});
|
||||
|
||||
test("Can create account with one node, and then load it on another", async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ export class Account extends Group {
|
||||
}
|
||||
|
||||
export interface GeneralizedControlledAccount {
|
||||
id: AccountIDOrAgentID;
|
||||
id: AccountID | AgentID;
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
currentAgentID: () => AgentID;
|
||||
@@ -138,11 +138,7 @@ export type AccountMeta = { type: "account" };
|
||||
export type AccountMap = CoMap<AccountContent, AccountMeta>;
|
||||
export type AccountID = CoID<AccountMap>;
|
||||
|
||||
export type AccountIDOrAgentID = AgentID | AccountID;
|
||||
export type AccountOrAgentID = AgentID | Account;
|
||||
export type AccountOrAgentSecret = AgentSecret | Account;
|
||||
|
||||
export function isAccountID(id: AccountIDOrAgentID): id is AccountID {
|
||||
export function isAccountID(id: AccountID | AgentID): id is AccountID {
|
||||
return id.startsWith("co_");
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ test("Can create coValue with new agent credentials and add transaction to it",
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
node.currentSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
node.currentSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
@@ -65,13 +65,13 @@ test("transactions with wrong signature are rejected", () => {
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
node.currentSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
node.currentSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
||||
@@ -101,7 +101,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
node.currentSessionID,
|
||||
[
|
||||
{
|
||||
privacy: "trusting",
|
||||
@@ -117,7 +117,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
node.currentSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
@@ -170,7 +170,7 @@ test("New transactions in a group correctly update owned values, including subsc
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
|
||||
@@ -30,11 +30,10 @@ import {
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { CoList } from "./contentTypes/coList.js";
|
||||
import {
|
||||
AccountID,
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
} from "./account.js";
|
||||
|
||||
@@ -53,11 +52,11 @@ export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountIDOrAgentID {
|
||||
return sessionID.split("_session")[0] as AccountIDOrAgentID;
|
||||
): AccountID | AgentID {
|
||||
return sessionID.split("_session")[0] as AccountID | AgentID;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
|
||||
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
|
||||
@@ -131,11 +130,11 @@ export class CoValue {
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
currentSessionID: SessionID
|
||||
): CoValue {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
ownSessionID
|
||||
currentSessionID
|
||||
);
|
||||
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
@@ -159,7 +158,7 @@ export class CoValue {
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.ownSessionID;
|
||||
const sessionID = this.node.currentSessionID;
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
@@ -294,7 +293,7 @@ export class CoValue {
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.ownSessionID;
|
||||
const sessionID = this.node.currentSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID } from "../contentType.js";
|
||||
import { CoValue, accountOrAgentIDfromSessionID } from "../coValue.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { AccountID } from "../index.js";
|
||||
import { AccountID, Group } from "../index.js";
|
||||
import { isAccountID } from "../account.js";
|
||||
|
||||
type OpID = TransactionID & { changeIdx: number };
|
||||
@@ -79,6 +79,10 @@ export class CoList<
|
||||
return this.coValue.header.meta as Meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coValue.getGroup();
|
||||
}
|
||||
|
||||
protected fillOpsFromCoValue() {
|
||||
this.insertions = {};
|
||||
this.deletionsByInsertion = {};
|
||||
@@ -230,7 +234,7 @@ export class CoList<
|
||||
}
|
||||
}
|
||||
|
||||
getLastEditor(idx: number): AccountID | undefined {
|
||||
whoInserted(idx: number): AccountID | undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -251,6 +255,28 @@ export class CoList<
|
||||
return this.entries().map((entry) => entry.value);
|
||||
}
|
||||
|
||||
map<U>(mapper: (value: T, idx: number) => U): U[] {
|
||||
return this.entries().map((entry, idx) => mapper(entry.value, idx));
|
||||
}
|
||||
|
||||
filter<U extends T>(predicate: (value: T, idx: number) => value is U): U[]
|
||||
filter(predicate: (value: T, idx: number) => boolean): T[] {
|
||||
return this.entries()
|
||||
.filter((entry, idx) => predicate(entry.value, idx))
|
||||
.map((entry) => entry.value);
|
||||
}
|
||||
|
||||
reduce<U>(
|
||||
reducer: (accumulator: U, value: T, idx: number) => U,
|
||||
initialValue: U
|
||||
): U {
|
||||
return this.entries().reduce(
|
||||
(accumulator, entry, idx) =>
|
||||
reducer(accumulator, entry.value, idx),
|
||||
initialValue
|
||||
);
|
||||
}
|
||||
|
||||
edit(
|
||||
changer: (editable: WriteableCoList<T, Meta>) => void
|
||||
): CoList<T, Meta> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TransactionID } from '../ids.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js';
|
||||
import { AccountID, isAccountID } from '../account.js';
|
||||
import { Group } from '../group.js';
|
||||
|
||||
type MapOp<K extends string, V extends JsonValue> = {
|
||||
txID: TransactionID;
|
||||
@@ -50,6 +51,10 @@ export class CoMap<
|
||||
return this.coValue.header.meta as Meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coValue.getGroup();
|
||||
}
|
||||
|
||||
protected fillOpsFromCoValue() {
|
||||
this.ops = {};
|
||||
|
||||
@@ -111,7 +116,7 @@ export class CoMap<
|
||||
}
|
||||
}
|
||||
|
||||
getLastEditor<K extends MapK<M>>(key: K): AccountID | undefined {
|
||||
whoEdited<K extends MapK<M>>(key: K): AccountID | undefined {
|
||||
const tx = this.getLastTxID(key);
|
||||
if (!tx) {
|
||||
return undefined;
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { SessionID, isAgentID } from "./ids.js";
|
||||
import { AgentID, SessionID, isAgentID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
AccountID,
|
||||
GeneralizedControlledAccount,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
@@ -28,9 +28,9 @@ import { CoList } from "./contentTypes/coList.js";
|
||||
|
||||
export type GroupContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
[key: AccountID | AgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
@@ -63,15 +63,25 @@ export class Group {
|
||||
return this.groupMap.id;
|
||||
}
|
||||
|
||||
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
|
||||
roleOf(accountID: AccountID): Role | undefined {
|
||||
return this.roleOfInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
|
||||
return this.groupMap.get(accountID);
|
||||
}
|
||||
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOf(this.node.account.id);
|
||||
return this.roleOfInternal(this.node.account.id);
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
addMember(accountID: AccountID, role: Role) {
|
||||
this.addMemberInternal(accountID, role);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addMemberInternal(accountID: AccountID | AgentID, role: Role) {
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
const currentReadKey = this.groupMap.coValue.getCurrentReadKey();
|
||||
|
||||
@@ -112,7 +122,7 @@ export class Group {
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMember(inviteID, `${role}Invite` as Role);
|
||||
this.addMemberInternal(inviteID, `${role}Invite` as Role);
|
||||
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
@@ -127,7 +137,7 @@ export class Group {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
}) as (AccountID | AgentID)[];
|
||||
|
||||
const maybeCurrentReadKey = this.groupMap.coValue.getCurrentReadKey();
|
||||
|
||||
@@ -179,7 +189,12 @@ export class Group {
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
removeMember(accountID: AccountID) {
|
||||
this.removeMemberInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(accountID: AccountID | AgentID) {
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
@@ -219,6 +234,7 @@ export class Group {
|
||||
.getCurrentContent() as L;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AccountIDOrAgentID } from './account.js';
|
||||
import { AccountID } from './account.js';
|
||||
import { base58 } from "@scure/base";
|
||||
import { shortHashLength } from './crypto.js';
|
||||
|
||||
@@ -23,4 +23,4 @@ export function isAgentID(id: string): id is AgentID {
|
||||
return typeof id === "string" && id.startsWith("sealer_") && id.includes("/signer_");
|
||||
}
|
||||
|
||||
export type SessionID = `${AccountIDOrAgentID}_session_z${string}`;
|
||||
export type SessionID = `${AccountID | AgentID}_session_z${string}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CoValue, newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { CoList } from "./contentTypes/coList.js";
|
||||
import {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
@@ -52,6 +53,7 @@ export {
|
||||
LocalNode,
|
||||
CoValue,
|
||||
CoMap,
|
||||
CoList,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
Group
|
||||
@@ -86,5 +88,4 @@ export namespace CojsonInternalTypes {
|
||||
export type Transaction = import("./coValue.js").Transaction;
|
||||
export type Signature = import("./crypto.js").Signature;
|
||||
export type RawCoID = import("./ids.js").RawCoID;
|
||||
export type AccountIDOrAgentID = import("./account.js").AccountIDOrAgentID;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { CoID, ContentType } from "./contentType.js";
|
||||
import {
|
||||
Account,
|
||||
AccountMeta,
|
||||
AccountIDOrAgentID,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
GeneralizedControlledAccount,
|
||||
ControlledAccount,
|
||||
@@ -36,17 +35,19 @@ import {
|
||||
import { CoMap } from "./index.js";
|
||||
|
||||
export class LocalNode {
|
||||
/** @internal */
|
||||
coValues: { [key: RawCoID]: CoValueState } = {};
|
||||
/** @internal */
|
||||
account: GeneralizedControlledAccount;
|
||||
ownSessionID: SessionID;
|
||||
currentSessionID: SessionID;
|
||||
sync = new SyncManager(this);
|
||||
|
||||
constructor(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
currentSessionID: SessionID
|
||||
) {
|
||||
this.account = account;
|
||||
this.ownSessionID = ownSessionID;
|
||||
this.currentSessionID = currentSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(
|
||||
@@ -75,7 +76,7 @@ export class LocalNode {
|
||||
node: nodeWithAccount,
|
||||
accountID: account.id,
|
||||
accountSecret: account.agentSecret,
|
||||
sessionID: nodeWithAccount.ownSessionID,
|
||||
sessionID: nodeWithAccount.currentSessionID,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,6 +110,7 @@ export class LocalNode {
|
||||
return node;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
createCoValue(header: CoValueHeader): CoValue {
|
||||
const coValue = new CoValue(header, this);
|
||||
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
|
||||
@@ -118,6 +120,7 @@ export class LocalNode {
|
||||
return coValue;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
loadCoValue(id: RawCoID): Promise<CoValue> {
|
||||
let entry = this.coValues[id];
|
||||
if (!entry) {
|
||||
@@ -210,7 +213,7 @@ export class LocalNode {
|
||||
newRandomSessionID(inviteAgentID)
|
||||
);
|
||||
|
||||
groupAsInvite.addMember(
|
||||
groupAsInvite.addMemberInternal(
|
||||
this.account.id,
|
||||
inviteRole === "adminInvite"
|
||||
? "admin"
|
||||
@@ -227,6 +230,7 @@ export class LocalNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
|
||||
const entry = this.coValues[id];
|
||||
if (!entry) {
|
||||
@@ -244,6 +248,7 @@ export class LocalNode {
|
||||
return entry.coValue;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectGroupContent(account.getCurrentContent()).get(
|
||||
@@ -262,6 +267,7 @@ export class LocalNode {
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
createAccount(
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
@@ -326,7 +332,8 @@ export class LocalNode {
|
||||
return controlledAccount;
|
||||
}
|
||||
|
||||
resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID {
|
||||
/** @internal */
|
||||
resolveAccountAgent(id: AccountID | AgentID, expectation?: string): AgentID {
|
||||
if (isAgentID(id)) {
|
||||
return id;
|
||||
}
|
||||
@@ -388,11 +395,12 @@ export class LocalNode {
|
||||
return new Group(groupContent, this);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
currentSessionID: SessionID
|
||||
): LocalNode {
|
||||
const newNode = new LocalNode(account, ownSessionID);
|
||||
const newNode = new LocalNode(account, currentSessionID);
|
||||
|
||||
const coValuesToCopy = Object.entries(this.coValues);
|
||||
|
||||
@@ -429,6 +437,7 @@ export class LocalNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type CoValueState =
|
||||
| {
|
||||
state: "loading";
|
||||
@@ -437,6 +446,7 @@ type CoValueState =
|
||||
}
|
||||
| { state: "loaded"; coValue: CoValue };
|
||||
|
||||
/** @internal */
|
||||
export function newLoadingState(): CoValueState {
|
||||
let resolve: (coValue: CoValue) => void;
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ test("Admins can't demote other admins in a group (high level)", () => {
|
||||
newRandomSessionID(otherAdmin.id)
|
||||
);
|
||||
|
||||
expect(() => groupAsOtherAdmin.addMember(admin.id, "writer")).toThrow(
|
||||
expect(() => groupAsOtherAdmin.addMemberInternal(admin.id, "writer")).toThrow(
|
||||
"Failed to set role"
|
||||
);
|
||||
|
||||
@@ -1378,7 +1378,7 @@ test("Admins can create an adminInvite, which can add an admin (high-level)", as
|
||||
groupAsInvitedAdmin.groupMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
|
||||
groupAsInvitedAdmin.addMember(thirdAdminID, "admin");
|
||||
groupAsInvitedAdmin.addMemberInternal(thirdAdminID, "admin");
|
||||
|
||||
expect(groupAsInvitedAdmin.groupMap.get(thirdAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValue.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
AccountID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "group"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "group"; initialAdmin: AccountID | AgentID }
|
||||
| { type: "ownedByGroup"; group: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
@@ -63,7 +63,7 @@ export function determineValidTransactions(
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
|
||||
const memberState: { [agent: AccountID | AgentID]: Role } = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
|
||||
[];
|
||||
@@ -77,7 +77,7 @@ export function determineValidTransactions(
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
const change = tx.changes[0] as
|
||||
| MapOpPayload<AccountIDOrAgentID, Role>
|
||||
| MapOpPayload<AccountID | AgentID, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (tx.changes.length !== 1) {
|
||||
@@ -248,7 +248,7 @@ export function isKeyForKeyField(
|
||||
|
||||
export function isKeyForAccountField(
|
||||
field: string
|
||||
): field is `${KeyID}_for_${AccountIDOrAgentID}` {
|
||||
): field is `${KeyID}_for_${AccountID | AgentID}` {
|
||||
return (
|
||||
field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))
|
||||
|
||||
@@ -77,12 +77,12 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
uniqueness: map.coValue.header.uniqueness,
|
||||
},
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -94,7 +94,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -130,7 +130,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
id: map.coValue.id,
|
||||
header: true,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 1,
|
||||
[node.currentSessionID]: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,12 +155,12 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
id: map.coValue.id,
|
||||
header: undefined,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 1,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -172,7 +172,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -207,7 +207,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
id: map.coValue.id,
|
||||
header: false,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 0,
|
||||
[node.currentSessionID]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -244,12 +244,12 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -261,7 +261,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -276,12 +276,12 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
action: "content",
|
||||
id: map.coValue.id,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 1,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -293,7 +293,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -332,7 +332,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
id: map.coValue.id,
|
||||
header: false,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 0,
|
||||
[node.currentSessionID]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -355,12 +355,12 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -372,7 +372,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -403,7 +403,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
id: map.coValue.id,
|
||||
header: false,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 0,
|
||||
[node.currentSessionID]: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
id: map.coValue.id,
|
||||
header: true,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 1,
|
||||
[node.currentSessionID]: 1,
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
|
||||
@@ -458,12 +458,12 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
id: map.coValue.id,
|
||||
header: undefined,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 1,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -475,7 +475,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -561,12 +561,12 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
id: map.coValue.id,
|
||||
header: map.coValue.header,
|
||||
new: {
|
||||
[node.ownSessionID]: {
|
||||
[node.currentSessionID]: {
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting" as const,
|
||||
madeAt: map.coValue.sessions[node.ownSessionID]!
|
||||
madeAt: map.coValue.sessions[node.currentSessionID]!
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
@@ -578,7 +578,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
|
||||
map.coValue.sessions[node.currentSessionID]!.lastSignature!,
|
||||
},
|
||||
},
|
||||
} satisfies SyncMessage);
|
||||
@@ -697,7 +697,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
|
||||
id: map.coValue.id,
|
||||
header: true,
|
||||
sessions: {
|
||||
[node.ownSessionID]: 1,
|
||||
[node.currentSessionID]: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -258,7 +258,6 @@ export class SyncManager {
|
||||
const readIncoming = async () => {
|
||||
try {
|
||||
for await (const msg of peerState.incoming) {
|
||||
console.log("Got msg from", peerState.id, msg);
|
||||
try {
|
||||
await this.handleSyncMessage(msg, peerState);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"stripInternal": true
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./src/**/*.test.*"],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.1.5",
|
||||
"jazz-browser": "^0.1.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -147,7 +147,7 @@ async function signUp(
|
||||
accountSecret,
|
||||
} satisfies SessionStorageData);
|
||||
|
||||
node.ownSessionID = await getSessionFor(accountID);
|
||||
node.currentSessionID = await getSessionFor(accountID);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"jazz-storage-indexeddb": "^0.1.5",
|
||||
"cojson": "^0.1.8",
|
||||
"jazz-storage-indexeddb": "^0.1.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { InviteSecret } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
CojsonInternalTypes,
|
||||
AccountID,
|
||||
AgentID,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
@@ -39,7 +40,7 @@ export async function createBrowserNode({
|
||||
sessionDone = sessionHandle.done;
|
||||
return sessionHandle.session;
|
||||
},
|
||||
[await IDBStorage.asPeer({ trace: true }), firstWsPeer]
|
||||
[await IDBStorage.asPeer(), firstWsPeer]
|
||||
);
|
||||
|
||||
async function websocketReconnectLoop() {
|
||||
@@ -81,7 +82,7 @@ export interface AuthProvider {
|
||||
}
|
||||
|
||||
export type SessionProvider = (
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
accountID: AccountID | AgentID
|
||||
) => Promise<SessionID>;
|
||||
|
||||
export type SessionHandle = {
|
||||
@@ -90,7 +91,7 @@ export type SessionHandle = {
|
||||
};
|
||||
|
||||
function getSessionHandleFor(
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
accountID: AccountID | AgentID
|
||||
): SessionHandle {
|
||||
let done!: () => void;
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
@@ -286,9 +287,7 @@ export function createInviteLink(
|
||||
let currentCoValue = coValue;
|
||||
|
||||
while (currentCoValue.header.ruleset.type === "ownedByGroup") {
|
||||
currentCoValue = node.expectCoValueLoaded(
|
||||
currentCoValue.header.ruleset.group
|
||||
);
|
||||
currentCoValue = currentCoValue.getGroup().groupMap.coValue;
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "group") {
|
||||
@@ -305,16 +304,16 @@ export function createInviteLink(
|
||||
return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink(inviteURL: string):
|
||||
export function parseInviteLink<C extends ContentType>(inviteURL: string):
|
||||
| {
|
||||
valueID: CoID<ContentType>;
|
||||
valueID: CoID<C>;
|
||||
inviteSecret: InviteSecret;
|
||||
}
|
||||
| undefined {
|
||||
const url = new URL(inviteURL);
|
||||
const valueID = url.hash
|
||||
.split("&")[0]
|
||||
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
|
||||
?.replace(/^#invitedTo=/, "") as CoID<C>;
|
||||
const inviteSecret = url.hash.split("&")[1] as InviteSecret;
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
@@ -322,15 +321,15 @@ export function parseInviteLink(inviteURL: string):
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation(node: LocalNode): Promise<
|
||||
export function consumeInviteLinkFromWindowLocation<C extends ContentType>(node: LocalNode): Promise<
|
||||
| {
|
||||
valueID: string;
|
||||
valueID: CoID<C>;
|
||||
inviteSecret: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const result = parseInviteLink(window.location.href);
|
||||
const result = parseInviteLink<C>(window.location.href);
|
||||
|
||||
if (result) {
|
||||
node.acceptInvite(result.valueID, result.inviteSecret)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.10",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.1.5",
|
||||
"jazz-react": "^0.1.6",
|
||||
"jazz-browser-auth-local": "^0.1.8",
|
||||
"jazz-react": "^0.1.10",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
"react": "17 - 18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.10",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"jazz-browser": "^0.1.5",
|
||||
"cojson": "^0.1.8",
|
||||
"jazz-browser": "^0.1.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
"react": "17 - 18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
|
||||
@@ -49,7 +49,10 @@ export function WithJazz({
|
||||
(async () => {
|
||||
const nodeHandle = await createBrowserNode({
|
||||
auth: auth,
|
||||
syncAddress,
|
||||
syncAddress:
|
||||
syncAddress ||
|
||||
new URLSearchParams(window.location.search).get("sync") ||
|
||||
undefined,
|
||||
});
|
||||
|
||||
setNode(nodeHandle.node);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.5",
|
||||
"cojson": "^0.1.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -5499,10 +5499,10 @@ lru-cache@^7.14.1, lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1:
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a"
|
||||
integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==
|
||||
|
||||
lucide-react@^0.265.0:
|
||||
version "0.265.0"
|
||||
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.265.0.tgz#251558b65aa24d069171b4e2b4846af37f5cc105"
|
||||
integrity sha512-znyvziBEUQ7CKR31GiU4viomQbJrpDLG5ac+FajwiZIavC3YbPFLkzQx3dCXT4JWJx/pB34EwmtiZ0ElGZX0PA==
|
||||
lucide-react@^0.274.0:
|
||||
version "0.274.0"
|
||||
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.274.0.tgz#d3b54dcb972b12f1292061448d61d422ef2e269d"
|
||||
integrity sha512-qiWcojRXEwDiSimMX1+arnxha+ROJzZjJaVvCC0rsG6a9pUPjZePXSq7em4ZKMp0NDm1hyzPNkM7UaWC3LU2AA==
|
||||
|
||||
magic-string@^0.30.1:
|
||||
version "0.30.2"
|
||||
@@ -7566,10 +7566,10 @@ tailwind-merge@^1.14.0:
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-1.14.0.tgz#e677f55d864edc6794562c63f5001f45093cdb8b"
|
||||
integrity sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==
|
||||
|
||||
tailwindcss-animate@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.6.tgz#c7195037481552cc47962ea50113830360fd0c28"
|
||||
integrity sha512-4WigSGMvbl3gCCact62ZvOngA+PRqhAn7si3TQ3/ZuPuQZcIEtVap+ENSXbzWhpojKB8CpvnIsrwBu8/RnHtuw==
|
||||
tailwindcss-animate@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
|
||||
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
|
||||
|
||||
tailwindcss@^3.3.3:
|
||||
version "3.3.3"
|
||||
|
||||
Reference in New Issue
Block a user