Compare commits
4 Commits
cojson@0.2
...
jazz-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9007dd1b5 | ||
|
|
ee1e5b06e4 | ||
|
|
fae290c4cf | ||
|
|
381d68019f |
68
.github/workflows/build-and-deploy.yaml
vendored
68
.github/workflows/build-and-deploy.yaml
vendored
@@ -7,11 +7,8 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
example: ["todo", "pets"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -23,47 +20,34 @@ jobs:
|
||||
node-version: 16
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/todo
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: docker-layer-caching-${{ github.workflow }}-{hash}
|
||||
restore-keys: |
|
||||
docker-layer-caching-${{ github.workflow }}-
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: gardencmp
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Nuke Workspace
|
||||
run: |
|
||||
rm package.json yarn.lock;
|
||||
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/${{ matrix.example }}
|
||||
|
||||
- name: Docker Build & Push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./examples/${{ matrix.example }}
|
||||
push: true
|
||||
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
run: |
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
|
||||
docker build . --file Dockerfile --tag $DOCKER_TAG;
|
||||
docker push $DOCKER_TAG;
|
||||
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
|
||||
working-directory: ./examples/todo
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
matrix:
|
||||
example: ["todo", "pets"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- uses: gacts/install-nomad@v1
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v1
|
||||
@@ -82,9 +66,13 @@ jobs:
|
||||
|
||||
export DOCKER_USER=gardencmp;
|
||||
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
|
||||
export DOCKER_TAG=${{ env.DOCKER_TAG }};
|
||||
|
||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
|
||||
cat job-instance.nomad;
|
||||
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
|
||||
working-directory: ./examples/${{ matrix.example }}
|
||||
for region in ${{ vars.DEPLOY_REGIONS }}
|
||||
do
|
||||
export REGION=$region;
|
||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN} ${REGION}' < job-template.nomad > job-instance.nomad;
|
||||
cat job-instance.nomad;
|
||||
NOMAD_ADDR='${{ secrets.NOMAD_ADDR }}' nomad job run job-instance.nomad;
|
||||
done
|
||||
working-directory: ./examples/todo
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
node_modules
|
||||
yarn-error.log
|
||||
lerna-debug.log
|
||||
docsTmp
|
||||
lerna-debug.log
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
92
README.md
92
README.md
@@ -1,91 +1,9 @@
|
||||
# Jazz - instant sync
|
||||
|
||||
Homepage: [jazz.tools](https://jazz.tools) — [Discord](https://discord.gg/utDMjHYg42)
|
||||
Jazz is an open-source toolkit for telepathic data.
|
||||
|
||||
Jazz is an open-source toolkit for *secure telepathic data.*
|
||||
Ship faster and simplify frontend, backend & devops by building with Telepathic Data.
|
||||
Get real-time multiplayer and cross-device sync for free.
|
||||
|
||||
- Ship faster & simplify your frontend and backend
|
||||
- Get cross-device sync, real-time collaboration & offline support for free
|
||||
|
||||
[Jazz Global Mesh](https://jazz.tools/mesh) is serverless sync & storage for Jazz apps. (currently free!)
|
||||
|
||||
|
||||
|
||||
## What is Secure Telepathic Data?
|
||||
|
||||
**Telepathic** means:
|
||||
|
||||
- **Read and write data as if it was local,** from anywhere in your app.
|
||||
- **Always have that data synced, instantly.** Across devices of the same user — or to other users (coming soon: to your backend, workers, etc.)
|
||||
|
||||
**Secure** means:
|
||||
|
||||
- **Fine-grained, role-based permissions are *baked into* your data.**
|
||||
- **Permissions are enforced everywhere, locally.** (using cryptography instead of through an API)
|
||||
- Roles can be changed dynamically, supporting changing teams, invite links and more.
|
||||
|
||||
## How to build an app with Jazz?
|
||||
|
||||
### Building a new app, completely with Jazz
|
||||
|
||||
It's still a bit early, but these are the rough steps:
|
||||
|
||||
1. Define your data model with [CoJSON Values](#cojson).
|
||||
2. Implement permission logic using [CoJSON Groups](#group).
|
||||
3. Hook up a user interface with [jazz-react](#jazz-react).
|
||||
|
||||
The best example is currently the [Todo List app](#example-app-todo-list).
|
||||
|
||||
### Gradually adding Jazz to an existing app
|
||||
|
||||
Coming soon: Jazz will support gradual adoption by integrating with your existing UI, auth and database.
|
||||
|
||||
## Example App: Todo List
|
||||
|
||||
The best example of Jazz is currently the Todo List app.
|
||||
|
||||
- Live version: https://example-todo.jazz.tools
|
||||
- Source code: [`./examples/todo`](./examples/todo). See the README there for a walk-through and running instructions.
|
||||
|
||||
# Documentation
|
||||
|
||||
Note: Since it's early days, this is the only source of documentation so far.
|
||||
|
||||
If you want to build something with Jazz, [join the Jazz Discord](https://discord.gg/utDMjHYg42) for encouragement and help!
|
||||
|
||||
## Overview: Main Packages
|
||||
|
||||
**`cojson`** → [DOCS](./DOCS.md#cojson)
|
||||
|
||||
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of secure telepathic data.
|
||||
|
||||
**`jazz-react`** → [DOCS](./DOCS.md#jazz-react)
|
||||
|
||||
Provides you with everything you need to build react apps around CoJSON, including reactive hooks for telepathic data, local IndexedDB persistence, support for different auth providers and helpers for simple invite links for CoJSON groups.
|
||||
|
||||
### Supporting packages
|
||||
<small>
|
||||
|
||||
**`cojson-simple-sync`**
|
||||
|
||||
A generic CoJSON sync server you can run locally if you don't want to use Jazz Global Mesh (the default sync backend, at `wss://sync.jazz.tools`)
|
||||
|
||||
**`jazz-browser`** → [DOCS](./DOCS.md#jazz-browser)
|
||||
|
||||
framework-agnostic primitives that allow you to use CoJSON in the browser. Used to implement `jazz-react`, will be used to implement bindings for other frameworks in the future.
|
||||
|
||||
**`jazz-react-auth-local`** (and `jazz-browser-auth-local`): A simple auth provider that stores cryptographic keys on user devices using WebAuthentication/Passkeys. Lets you build Jazz apps completely without a backend, with end-to-end encryption by default.
|
||||
|
||||
**`jazz-storage-indexeddb`**
|
||||
|
||||
Provides local, offline-capable persistence. Included and enabled in `jazz-react` by default.
|
||||
|
||||
**`jazz-react-media-images`** → [DOCS](./DOCS.md#jazz-react-media-images)
|
||||
|
||||
TODO: document
|
||||
|
||||
**`jazz-browser-media-images`** → [DOCS](./DOCS.md#jazz-browser-media-images)
|
||||
|
||||
TODO: document
|
||||
|
||||
</small>
|
||||
## What is Telepathic Data?
|
||||
...
|
||||
@@ -1,18 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
examples/pets/.gitignore
vendored
24
examples/pets/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
@@ -1,51 +0,0 @@
|
||||
# Jazz Rate-My-Pet List Example
|
||||
|
||||
Live version: https://example-pets.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/pets jazz-example-pets
|
||||
cd jazz-example-pets
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
TODO
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
TODO
|
||||
|
||||
### Helpers
|
||||
|
||||
TODO
|
||||
|
||||
## 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).
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Rate My Pet Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/0_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,56 +0,0 @@
|
||||
job "example-pets$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
count = 8
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "mesh"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "example-pets$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.2.5",
|
||||
"jazz-react-auth-local": "^0.2.5",
|
||||
"jazz-react-media-images": "^0.2.5",
|
||||
"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.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,38 +0,0 @@
|
||||
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 Rate My Pet Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
@@ -1,29 +0,0 @@
|
||||
import { CoMap, CoStream, Media } from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
* Here, we define our main data model of TODO
|
||||
*
|
||||
* TODO
|
||||
**/
|
||||
|
||||
export type PetPost = CoMap<{
|
||||
name: string;
|
||||
image: Media.ImageDefinition;
|
||||
reactions: PetReactions;
|
||||
}>;
|
||||
|
||||
export const REACTION_TYPES = [
|
||||
"aww",
|
||||
"love",
|
||||
"haha",
|
||||
"wow",
|
||||
"tiny",
|
||||
"chonkers",
|
||||
] as const;
|
||||
|
||||
export type ReactionType = (typeof REACTION_TYPES)[number];
|
||||
|
||||
export type PetReactions = CoStream<ReactionType>;
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { PetPost } from "./1_types";
|
||||
|
||||
import { Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { RatePetPostUI } from "./4_RatePetPostUI";
|
||||
import { CreatePetPostForm } from "./3_CreatePetPostForm";
|
||||
|
||||
/** Walkthrough: Creating pet posts & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our PetPost, 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 [currentPetPostID, navigateToPetPostID] =
|
||||
useSimpleHashRouterThatAcceptsInvites<PetPost>(localNode);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{currentPetPostID ? (
|
||||
<RatePetPostUI petPostID={currentPetPostID} />
|
||||
) : (
|
||||
<CreatePetPostForm onCreate={navigateToPetPostID} />
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigateToPetPostID(undefined);
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_CreatePetPostForm.tsx */
|
||||
@@ -1,106 +0,0 @@
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
|
||||
import { CoID, CoMap, Media } from "cojson";
|
||||
import { useJazz, useTelepathicQuery } from "jazz-react";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
|
||||
import { PetPost, PetReactions } from "./1_types";
|
||||
|
||||
import { Input, Button } from "./basicComponents";
|
||||
import { useLoadImage } from "jazz-react-media-images";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
type PartialPetPost = CoMap<{
|
||||
name: string;
|
||||
image?: Media.ImageDefinition;
|
||||
reactions: PetReactions;
|
||||
}>
|
||||
|
||||
export function CreatePetPostForm({
|
||||
onCreate,
|
||||
}: {
|
||||
onCreate: (id: CoID<PetPost>) => void;
|
||||
}) {
|
||||
const { localNode } = useJazz();
|
||||
|
||||
const [newPostId, setNewPostId] = useState<CoID<PartialPetPost> | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const newPetPost = useTelepathicQuery(newPostId);
|
||||
|
||||
const onChangeName = useCallback(
|
||||
(name: string) => {
|
||||
if (newPetPost) {
|
||||
newPetPost.edit((petPost) => {
|
||||
petPost.set("name", name);
|
||||
});
|
||||
} else {
|
||||
const petPostGroup = localNode.createGroup();
|
||||
const petPost = petPostGroup.createMap<PartialPetPost>({
|
||||
name,
|
||||
reactions: petPostGroup.createStream<PetReactions>(),
|
||||
});
|
||||
|
||||
setNewPostId(petPost.id);
|
||||
}
|
||||
},
|
||||
[localNode, newPetPost]
|
||||
);
|
||||
|
||||
const onImageSelected = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!newPetPost || !event.target.files) return;
|
||||
|
||||
const image = await createImage(
|
||||
event.target.files[0],
|
||||
newPetPost.group
|
||||
);
|
||||
|
||||
newPetPost.edit((petPost) => {
|
||||
petPost.set("image", image);
|
||||
});
|
||||
},
|
||||
[newPetPost]
|
||||
);
|
||||
|
||||
const petImage = useLoadImage(newPetPost?.image?.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<p>Share your pet with friends!</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pet Name"
|
||||
className="text-3xl py-6"
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
value={newPetPost?.name || ""}
|
||||
/>
|
||||
|
||||
{petImage ? (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="file"
|
||||
disabled={!newPetPost?.name}
|
||||
onChange={onImageSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newPetPost?.name && newPetPost?.image && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreate(newPetPost.id as CoID<PetPost>);
|
||||
}}
|
||||
>
|
||||
Submit Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useTelepathicQuery } from "jazz-react";
|
||||
|
||||
import { PetPost, ReactionType, REACTION_TYPES, PetReactions } from "./1_types";
|
||||
|
||||
import { ShareButton } from "./components/ShareButton";
|
||||
import { Button, Skeleton } from "./basicComponents";
|
||||
import { useLoadImage } from "jazz-react-media-images";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
const reactionEmojiMap: { [reaction in ReactionType]: string } = {
|
||||
aww: "😍",
|
||||
love: "❤️",
|
||||
haha: "😂",
|
||||
wow: "😮",
|
||||
tiny: "🐥",
|
||||
chonkers: "🐘",
|
||||
};
|
||||
|
||||
export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
|
||||
const petPost = useTelepathicQuery(petPostID);
|
||||
const petImage = useLoadImage(petPost?.image);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-3xl font-bold">{petPost?.name}</h1>
|
||||
<ShareButton petPost={petPost} />
|
||||
</div>
|
||||
|
||||
{petImage && (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between max-w-xs flex-wrap">
|
||||
{REACTION_TYPES.map((reactionType) => (
|
||||
<Button
|
||||
key={reactionType}
|
||||
variant={
|
||||
petPost?.reactions?.me?.last === reactionType
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
petPost?.reactions?.edit((reactions) => {
|
||||
reactions.push(reactionType);
|
||||
});
|
||||
}}
|
||||
title={`React with ${reactionType}`}
|
||||
className="text-2xl px-2"
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{petPost?.group.myRole() === "admin" && petPost.reactions && (
|
||||
<ReactionOverview petReactions={petPost.reactions} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReactionOverview({
|
||||
petReactions,
|
||||
}: {
|
||||
petReactions: Queried<PetReactions>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Reactions</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{REACTION_TYPES.map((reactionType) => {
|
||||
const reactionsOfThisType = Object.values(
|
||||
petReactions.perAccount
|
||||
).filter(({ last }) => last === reactionType);
|
||||
|
||||
if (reactionsOfThisType.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center"
|
||||
key={reactionType}
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}{" "}
|
||||
{reactionsOfThisType.map((reaction) =>
|
||||
reaction.by?.profile?.name ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={uniqueColoring(reaction.by.id)}
|
||||
>
|
||||
{reaction.by.profile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueColoring(seed: string) {
|
||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
return {
|
||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Input } from "./ui/input";
|
||||
export { Toaster } from "./ui/toaster";
|
||||
export { useToast } from "./ui/use-toast";
|
||||
export { Skeleton } from "./ui/skeleton";
|
||||
export { TitleAndLogo } from "./TitleAndLogo";
|
||||
export { ThemeProvider } from "./themeProvider";
|
||||
@@ -1,72 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: string;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState(
|
||||
() => localStorage.getItem(storageKey) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
).matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: string) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Sign Up as new account"
|
||||
/>
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={logIn}>
|
||||
Log In with existing account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { PetPost } from "../1_types";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { Queried } from "cojson";
|
||||
|
||||
export function ShareButton({ petPost }: { petPost?: Queried<PetPost> }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
petPost?.group.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!petPost}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (petPost && !inviteLink) {
|
||||
inviteLink = createInviteLink(petPost, "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" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, CoValue } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValue>(
|
||||
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;
|
||||
}
|
||||
1
examples/pets/src/vite-env.d.ts
vendored
1
examples/pets/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,76 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
COPY ./dist /usr/share/caddy/
|
||||
|
||||
RUN caddy
|
||||
@@ -1,65 +1,27 @@
|
||||
# Jazz Todo List Example
|
||||
# React + TypeScript + Vite
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Installing & running the example locally
|
||||
Currently, two official plugins are available:
|
||||
|
||||
Start by checking out just the example app to a folder:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
||||
cd jazz-example-todo
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- [`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
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
- 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)
|
||||
|
||||
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
|
||||
|
||||
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
|
||||
|
||||
### Helpers
|
||||
|
||||
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
|
||||
|
||||
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
- (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!
|
||||
|
||||
## 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).
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Todo List Example</title>
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/0_main.tsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
job "example-todo$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
region = "$REGION"
|
||||
datacenters = ["$REGION"]
|
||||
|
||||
group "static" {
|
||||
count = 8
|
||||
// count = 3
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
@@ -14,17 +14,13 @@ job "example-todo$BRANCH_SUFFIX" {
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "mesh"
|
||||
value = "edge"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
// spread {
|
||||
// attribute = "${node.datacenter}"
|
||||
// weight = 100
|
||||
// }
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
@@ -41,7 +37,9 @@ job "example-todo$BRANCH_SUFFIX" {
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "example-todo$BRANCH_SUFFIX"
|
||||
meta {
|
||||
public_name = "${BRANCH_SUBDOMAIN}example-todo"
|
||||
}
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,19 +12,15 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.2.5",
|
||||
"jazz-react-auth-local": "^0.2.5",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"jazz-react": "^0.0.10",
|
||||
"jazz-react-auth-local": "^0.0.7",
|
||||
"lucide-react": "^0.265.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
"tailwindcss-animate": "^1.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
1
examples/todo/public/vite.svg
Normal file
1
examples/todo/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,38 +0,0 @@
|
||||
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";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CoMap, CoList } 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; }>;
|
||||
|
||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
||||
export type TodoProject = CoMap<{
|
||||
title: string;
|
||||
/** A collaborative, ordered list of tasks */
|
||||
tasks: CoList<Task>;
|
||||
}>;
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { Task, TodoProject } from "./1_types";
|
||||
|
||||
import { SubmittableInput, Button } from "./basicComponents";
|
||||
|
||||
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
|
||||
import { TodoTable } from "./3_TodoTable";
|
||||
import { CoList } from "cojson";
|
||||
|
||||
/** 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
|
||||
const project = projectGroup.createMap<TodoProject>({
|
||||
title,
|
||||
tasks: projectGroup.createList<CoList<Task>>()
|
||||
});
|
||||
|
||||
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 */
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useTelepathicQuery } 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 uniqolor from "uniqolor";
|
||||
|
||||
/** 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 = useTelepathicQuery(projectId);
|
||||
|
||||
// `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 (!project?.tasks || !text) return;
|
||||
const task = project?.group.createMap<Task>({
|
||||
text,
|
||||
done: false,
|
||||
});
|
||||
|
||||
project?.tasks.edit((tasks) => {
|
||||
tasks.push(task);
|
||||
});
|
||||
},
|
||||
[project?.tasks, project?.group]
|
||||
);
|
||||
|
||||
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 query,
|
||||
// accounting for the fact that it might not be loaded yet
|
||||
project?.title ? (
|
||||
<>
|
||||
{project.title}{" "}
|
||||
<span className="text-sm">({project.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
<InviteButton value={project} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{project?.tasks?.map(
|
||||
(task) => task && <TaskRow key={task.id} task={task} />
|
||||
)}
|
||||
<NewTaskInputRow
|
||||
createTask={createTask}
|
||||
disabled={!project}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.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">
|
||||
{task?.text ? (
|
||||
<span className={task?.done ? "line-through" : ""}>
|
||||
{task.text}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
{task?.edits.text?.by?.profile?.name ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={uniqueColoring(task.edits.text.by.id)}
|
||||
>
|
||||
{task.edits.text.by.profile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueColoring(seed: string) {
|
||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
return {
|
||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
||||
};
|
||||
}
|
||||
158
examples/todo/src/App.tsx
Normal file
158
examples/todo/src/App.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CoMap, CoID } from "cojson";
|
||||
import { useJazz, useTelepathicState } from "jazz-react";
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
|
||||
type TodoListContent = { title: string; [taskId: CoID<Task>]: true };
|
||||
type TodoList = CoMap<TodoListContent>;
|
||||
|
||||
function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
|
||||
const { localNode } = useJazz();
|
||||
|
||||
const createList = () => {
|
||||
const listTeam = localNode.createTeam();
|
||||
const list = listTeam.createMap<TodoListContent>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", "My Todo List");
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
}
|
||||
window.addEventListener("hashchange", listener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 md:pt-[30vh] pb-10">
|
||||
{listId && <TodoList listId={listId} />}
|
||||
<Button onClick={createList}>Create New List</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
|
||||
const createTodo = (text: string) => {
|
||||
if (!list) return;
|
||||
let task = list.coValue.getTeam().createMap<TaskContent>();
|
||||
|
||||
task = task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
console.log("Created task", task.id, task.toJSON());
|
||||
|
||||
const listAfter = list.edit((list) => {
|
||||
list.set(task.id, true);
|
||||
});
|
||||
|
||||
console.log("Updated list", listAfter.toJSON());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<h1>
|
||||
{list?.get("title")} ({list?.id})
|
||||
</h1>
|
||||
<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) => (
|
||||
<TodoRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<form
|
||||
className="flex flex-row items-center gap-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const textEl =
|
||||
e.currentTarget.elements.namedItem(
|
||||
"text"
|
||||
) as HTMLInputElement;
|
||||
createTodo(textEl.value);
|
||||
textEl.value = "";
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="-ml-3 -my-2"
|
||||
name="text"
|
||||
placeholder="Add todo"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button asChild type="submit">
|
||||
<Input type="submit" value="Add" />
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoRow({ 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 className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
30
examples/todo/src/LocalAuth.tsx
Normal file
30
examples/todo/src/LocalAuth.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Input } from "./components/ui/input.tsx";
|
||||
import { Button } from "./components/ui/button.tsx";
|
||||
import { AuthComponent } from "jazz-react";
|
||||
import { useLocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
export const LocalAuth: AuthComponent = ({ onCredential }) => {
|
||||
const { displayName, setDisplayName, signIn, signUp } = useLocalAuth(onCredential);
|
||||
|
||||
return (<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="webauthn" />
|
||||
<Button asChild>
|
||||
<Input type="submit" value="Sign Up as new account" />
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={signIn}>Log In with existing account</Button>
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Input } from "@/basicComponents/ui/input";
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="flex flex-row items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const textEl = e.currentTarget.elements.namedItem(
|
||||
"text"
|
||||
) as HTMLInputElement;
|
||||
onSubmit(textEl.value);
|
||||
textEl.value = "";
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="-ml-3 -my-2 flex-grow flex-3 text-base"
|
||||
name="text"
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
|
||||
<Input type="submit" value={label} disabled={disabled} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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 />
|
||||
</>
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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";
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: string;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState(
|
||||
() => localStorage.getItem(storageKey) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
).matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: string) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Sign Up as new account"
|
||||
/>
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={logIn}>
|
||||
Log In with existing account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { CoValue, Queried } from "cojson";
|
||||
|
||||
export function InviteButton<T extends CoValue>({ value }: { value: T | Queried<T> | undefined }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
value?.group?.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!value.group || !value.id}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (value.group && value.id && !inviteLink) {
|
||||
inviteLink = createInviteLink(value, "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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 "@/basicComponents/lib/utils"
|
||||
import { cn } from "@/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 "@/basicComponents/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -1,7 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
:root, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -9,63 +14,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;
|
||||
|
||||
14
examples/todo/src/main.tsx
Normal file
14
examples/todo/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "./LocalAuth.tsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<WithJazz auth={LocalAuth} syncAddress={new URLSearchParams(window.location.search).get("sync") || undefined}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoID, LocalNode, CoValue } from "cojson";
|
||||
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
|
||||
|
||||
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValue>(
|
||||
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;
|
||||
}
|
||||
@@ -9,8 +9,5 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
|
||||
1810
examples/todo/yarn.lock
Normal file
1810
examples/todo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
489
generateDocs.ts
489
generateDocs.ts
@@ -1,489 +0,0 @@
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { Application, JSONOutput } from "typedoc";
|
||||
|
||||
const manuallyIgnore = new Set(["CojsonInternalTypes"]);
|
||||
|
||||
async function main() {
|
||||
// Application.bootstrap also exists, which will not load plugins
|
||||
// Also accepts an array of option readers if you want to disable
|
||||
// TypeDoc's tsconfig.json/package.json/typedoc.json option readers
|
||||
const packageDocs = Object.entries({
|
||||
cojson: "index.ts",
|
||||
"jazz-react": "index.tsx",
|
||||
"jazz-browser": "index.ts",
|
||||
"jazz-browser-media-images": "index.ts",
|
||||
"jazz-react-media-images": "index.tsx",
|
||||
}).map(async ([packageName, entryPoint]) => {
|
||||
const app = await Application.bootstrapWithPlugins({
|
||||
entryPoints: [`packages/${packageName}/src/${entryPoint}`],
|
||||
tsconfig: `packages/${packageName}/tsconfig.json`,
|
||||
sort: ["required-first"],
|
||||
groupOrder: [
|
||||
"Functions",
|
||||
"Classes",
|
||||
"TypeAliases",
|
||||
"Namespaces"
|
||||
]
|
||||
});
|
||||
|
||||
const project = await app.convert();
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Failed to convert project" + packageName);
|
||||
}
|
||||
// Alternatively generate JSON output
|
||||
await app.generateJson(project, `docsTmp/${packageName}.json`);
|
||||
|
||||
const docs = JSON.parse(
|
||||
await readFile(`docsTmp/${packageName}.json`, "utf8")
|
||||
) as JSONOutput.ProjectReflection;
|
||||
|
||||
return (
|
||||
`# ${packageName}\n\n` +
|
||||
docs
|
||||
.groups!.map((group) => {
|
||||
return group.children
|
||||
?.map((childId) => {
|
||||
const child = docs.children!.find(
|
||||
(child) => child.id === childId
|
||||
)!;
|
||||
|
||||
if (manuallyIgnore.has(child.name)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
`## \`${renderChildName(child)}\` (${group.title
|
||||
.toLowerCase()
|
||||
.replace("ces", "ce")
|
||||
.replace(/es$/, "")
|
||||
.replace(
|
||||
"ns",
|
||||
"n"
|
||||
)} in \`${packageName}\`)\n\n` +
|
||||
renderChildType(child) +
|
||||
renderComment(child.comment) +
|
||||
(child.kind === 128 || child.kind === 256
|
||||
? child.groups
|
||||
?.map((group) =>
|
||||
renderChildGroup(child, group)
|
||||
)
|
||||
.join("\n\n")
|
||||
: child.kind === 4
|
||||
? child.groups
|
||||
?.map((group) =>
|
||||
renderChildGroup(child, group)
|
||||
)
|
||||
.join("\n\n")
|
||||
: "TODO: doc generator not implemented yet " +
|
||||
child.kind)
|
||||
);
|
||||
})
|
||||
.join("\n\n----\n\n");
|
||||
})
|
||||
.join("\n\n----\n\n")
|
||||
);
|
||||
|
||||
function renderComment(comment?: JSONOutput.Comment): string {
|
||||
if (comment) {
|
||||
return (
|
||||
comment.summary
|
||||
.map((token) =>
|
||||
token.kind === "text" || token.kind === "code"
|
||||
? token.text
|
||||
: ""
|
||||
)
|
||||
.join("") +
|
||||
"\n\n" +
|
||||
(comment.blockTags || [])
|
||||
.map((blockTag) =>
|
||||
blockTag.tag === "@example"
|
||||
? "##### Example:\n\n" +
|
||||
blockTag.content
|
||||
.map((token) =>
|
||||
token.kind === "text" ||
|
||||
token.kind === "code"
|
||||
? token.text
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: ""
|
||||
)
|
||||
.join("\n\n") +
|
||||
"\n\n"
|
||||
);
|
||||
} else {
|
||||
return "TODO: document\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildName(child: JSONOutput.DeclarationReflection) {
|
||||
if (child.signatures) {
|
||||
if (
|
||||
child.signatures[0].type?.type === "reference" &&
|
||||
child.signatures[0].type.qualifiedName ===
|
||||
"React.JSX.Element"
|
||||
) {
|
||||
return `<${child.name}/>`;
|
||||
} else {
|
||||
return (
|
||||
child.name +
|
||||
`(${(child.signatures[0].parameters || [])
|
||||
.map(renderParamSimple)
|
||||
.join(", ")})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return child.name;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildType(
|
||||
child: JSONOutput.DeclarationReflection
|
||||
): string {
|
||||
const isClass = child.kind === 128;
|
||||
const isTypeDef = child.kind === 2097152;
|
||||
const isInterface = child.kind === 256;
|
||||
const isNamespace = child.kind === 4;
|
||||
const isFunction = !!child.signatures;
|
||||
return (
|
||||
"```typescript\n" +
|
||||
`export ${
|
||||
isClass
|
||||
? "class"
|
||||
: isTypeDef
|
||||
? "type"
|
||||
: isFunction
|
||||
? "function"
|
||||
: isInterface
|
||||
? "interface"
|
||||
: isNamespace
|
||||
? "namespace"
|
||||
: ""
|
||||
} ${child.name}` +
|
||||
(child.typeParameters
|
||||
? "<" +
|
||||
child.typeParameters.map(renderTypeParam).join(", ") +
|
||||
">"
|
||||
: "") +
|
||||
(child.extendedTypes
|
||||
? " extends " +
|
||||
child.extendedTypes.map(renderType).join(", ")
|
||||
: "") +
|
||||
(child.implementedTypes
|
||||
? " implements " +
|
||||
child.implementedTypes.map(renderType).join(", ")
|
||||
: "") +
|
||||
(isClass || isInterface || isNamespace
|
||||
? " {...}"
|
||||
: isTypeDef
|
||||
? ` = ${renderType(child.type)}`
|
||||
: child.signatures
|
||||
? `(${(child.signatures[0].parameters || [])
|
||||
.map(renderParam)
|
||||
.join(", ")}): ${renderType(
|
||||
child.signatures[0].type
|
||||
)}`
|
||||
: "") +
|
||||
"\n```\n"
|
||||
);
|
||||
}
|
||||
|
||||
function renderChildGroup(
|
||||
child: JSONOutput.DeclarationReflection,
|
||||
group: JSONOutput.ReflectionGroup
|
||||
): string {
|
||||
return (
|
||||
`### ${group.title}\n\n` +
|
||||
group.children
|
||||
?.map((memberId) => {
|
||||
const member = child.children!.find(
|
||||
(member) => member.id === memberId
|
||||
)!;
|
||||
|
||||
if (member.kind === 2048 || member.kind === 512) {
|
||||
if (
|
||||
member.signatures?.every((sig) =>
|
||||
sig.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentConstructorOrMethod(
|
||||
member,
|
||||
child
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
member.kind === 1024 ||
|
||||
member.kind === 262144
|
||||
) {
|
||||
if (
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentProperty(member, child);
|
||||
}
|
||||
} else if (member.kind === 2097152) {
|
||||
if (
|
||||
member.comment?.modifierTags?.includes(
|
||||
"@internal"
|
||||
)
|
||||
) {
|
||||
return "";
|
||||
} else {
|
||||
return documentProperty({...member, flags: {isStatic: true}}, child);
|
||||
}
|
||||
} else {
|
||||
return "Unknown member kind " + member.kind;
|
||||
}
|
||||
})
|
||||
.join("\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
function renderType(t?: JSONOutput.SomeType): string {
|
||||
if (!t) return "";
|
||||
if (t.type === "reference") {
|
||||
return (
|
||||
t.name +
|
||||
(t.typeArguments
|
||||
? "<" + t.typeArguments.map(renderType).join(", ") + ">"
|
||||
: "")
|
||||
);
|
||||
} else if (t.type === "intrinsic") {
|
||||
return t.name;
|
||||
} else if (t.type === "literal") {
|
||||
return JSON.stringify(t.value);
|
||||
} else if (t.type === "union") {
|
||||
return [...new Set(t.types.map(renderType))].join(" | ");
|
||||
} else if (t.type === "intersection") {
|
||||
return [...new Set(t.types.map(renderType))].join(" & ");
|
||||
} else if (t.type === "indexedAccess") {
|
||||
return (
|
||||
renderType(t.objectType) +
|
||||
"[" +
|
||||
renderType(t.indexType) +
|
||||
"]"
|
||||
);
|
||||
} else if (t.type === "reflection") {
|
||||
if (t.declaration.indexSignature) {
|
||||
return (
|
||||
`{ ${t.declaration.children?t.declaration.children
|
||||
.map(
|
||||
(child) =>
|
||||
`${child.name}${
|
||||
child.flags.isOptional ? "?" : ""
|
||||
}: ${renderType(child.type)}`
|
||||
)
|
||||
.join(", ") + ", " : ""}[` +
|
||||
t.declaration.indexSignature?.parameters?.[0].name +
|
||||
": " +
|
||||
renderType(
|
||||
t.declaration.indexSignature?.parameters?.[0].type
|
||||
) +
|
||||
"]: " +
|
||||
renderType(t.declaration.indexSignature?.type) +
|
||||
" }"
|
||||
);
|
||||
} else if (t.declaration.children) {
|
||||
return `{${t.declaration.children
|
||||
.map(
|
||||
(child) =>
|
||||
`${child.name}${
|
||||
child.flags.isOptional ? "?" : ""
|
||||
}: ${renderType(child.type)}`
|
||||
)
|
||||
.join(", ")}}`;
|
||||
} else if (t.declaration.signatures) {
|
||||
if (t.declaration.signatures.length > 1) {
|
||||
return "COMPLEX_TYPE_MULTIPLE_INLINE_SIGNATURES";
|
||||
} else {
|
||||
return `(${(
|
||||
t.declaration.signatures[0].parameters || []
|
||||
).map(renderParam)}) => ${renderType(
|
||||
t.declaration.signatures[0].type
|
||||
)}`;
|
||||
}
|
||||
} else {
|
||||
return "COMPLEX_TYPE_REFLECTION";
|
||||
}
|
||||
} else if (t.type === "array") {
|
||||
return renderType(t.elementType) + "[]";
|
||||
} else if (t.type === "tuple") {
|
||||
return `[${t.elements?.map(renderType).join(", ")}]`;
|
||||
} else if (t.type === "templateLiteral") {
|
||||
const matchingNamedType = docs.children?.find(
|
||||
(child) =>
|
||||
child.variant === "declaration" &&
|
||||
child.type?.type === "templateLiteral" &&
|
||||
child.type.head === t.head &&
|
||||
child.type.tail.every(
|
||||
(piece, i) => piece[1] === t.tail[i][1]
|
||||
)
|
||||
);
|
||||
|
||||
if (matchingNamedType) {
|
||||
return matchingNamedType.name;
|
||||
} else {
|
||||
if (
|
||||
t.head === "sealerSecret_z" &&
|
||||
t.tail[0][1] === "/signerSecret_z"
|
||||
) {
|
||||
return "AgentSecret";
|
||||
} else if (
|
||||
t.head === "sealer_z" &&
|
||||
t.tail[0][1] === "/signer_z"
|
||||
) {
|
||||
if (t.tail[1] && t.tail[1][1] === "_session_z") {
|
||||
return "SessionID";
|
||||
} else {
|
||||
return "AgentID";
|
||||
}
|
||||
} else {
|
||||
return "`" + t.head + t.tail.map(bit => "${" + renderType(bit[0]) + "}" + bit[1]).join("") + "`";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return "COMPLEX_TYPE_" + t.type;
|
||||
}
|
||||
}
|
||||
|
||||
// function renderTemplateLiteral(tempLit: JSONOutput.TemplateLiteralType) {
|
||||
// return tempLit.head + tempLit.tail.map((piece) => piece[0] + piece[1]).join("");
|
||||
// }
|
||||
|
||||
// function resolveTemplateLiteralPieceType(t: SomeType): string {
|
||||
// if (t.type === "string") {
|
||||
// return "${string}"
|
||||
// }
|
||||
// if (t.type === "reference") {
|
||||
// const referencedType = docs.children?.find(
|
||||
// (child) => child.name === t.name
|
||||
// );
|
||||
|
||||
// }
|
||||
// }
|
||||
|
||||
function renderTypeParam(
|
||||
t?: JSONOutput.TypeParameterReflection
|
||||
): string {
|
||||
if (!t) return "";
|
||||
return t.name + (t.type ? " extends " + renderType(t.type) : "");
|
||||
}
|
||||
|
||||
function renderParam(param: JSONOutput.ParameterReflection) {
|
||||
return param.name === "__namedParameters"
|
||||
? renderType(param.type)
|
||||
: `${param.name}: ${renderType(param.type)}`;
|
||||
}
|
||||
|
||||
function renderParamSimple(param: JSONOutput.ParameterReflection) {
|
||||
return param.name === "__namedParameters" &&
|
||||
param.type?.type === "reflection"
|
||||
? `{${param.type?.declaration.children
|
||||
?.map(
|
||||
(child) =>
|
||||
child.name + (child.flags.isOptional ? "?" : "")
|
||||
)
|
||||
.join(", ")}}${param.defaultValue ? "?" : ""}`
|
||||
: param.name + (param.defaultValue ? "?" : "");
|
||||
}
|
||||
|
||||
function documentConstructorOrMethod(
|
||||
member: JSONOutput.DeclarationReflection,
|
||||
child: JSONOutput.DeclarationReflection
|
||||
) {
|
||||
const stem =
|
||||
member.name === "constructor"
|
||||
? "new " + child.name
|
||||
: (member.flags.isStatic
|
||||
? child.name
|
||||
: child.name[0].toLowerCase() + child.name.slice(1)) +
|
||||
"." +
|
||||
member.name;
|
||||
|
||||
return (
|
||||
`<details>\n<summary><code>${stem}(${(
|
||||
member.signatures?.[0]?.parameters?.map(
|
||||
renderParamSimple
|
||||
) || []
|
||||
).join(", ")})</code> ${
|
||||
member.inheritedFrom
|
||||
? "(from <code>" +
|
||||
member.inheritedFrom.name.split(".")[0] +
|
||||
"</code>) "
|
||||
: ""
|
||||
} ${
|
||||
member.signatures?.[0]?.comment ? "" : "(undocumented)"
|
||||
}</summary>\n\n` +
|
||||
member.signatures?.map((signature) => {
|
||||
return (
|
||||
"```typescript\n" +
|
||||
`${stem}${
|
||||
signature.typeParameter
|
||||
? `<${signature.typeParameter
|
||||
.map(renderTypeParam)
|
||||
.join(", ")}>`
|
||||
: ""
|
||||
}(${
|
||||
(
|
||||
signature.parameters?.map(
|
||||
(param) =>
|
||||
`\n ${param.name}${
|
||||
param.defaultValue ? "?" : ""
|
||||
}: ${renderType(param.type)}${
|
||||
param.defaultValue
|
||||
? ` = ${param.defaultValue}`
|
||||
: ""
|
||||
}`
|
||||
) || []
|
||||
).join(",") +
|
||||
(signature.parameters?.length ? "\n" : "")
|
||||
}): ${renderType(signature.type)}\n` +
|
||||
"```\n" +
|
||||
renderComment(signature.comment)
|
||||
);
|
||||
}) +
|
||||
"</details>\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
function documentProperty(
|
||||
member: JSONOutput.DeclarationReflection,
|
||||
child: JSONOutput.DeclarationReflection
|
||||
) {
|
||||
const stem = member.flags.isStatic
|
||||
? child.name
|
||||
: child.name[0].toLowerCase() + child.name.slice(1);
|
||||
return (
|
||||
`<details>\n<summary><code>${stem}.${member.name}</code> ${
|
||||
member.inheritedFrom
|
||||
? "(from <code>" +
|
||||
member.inheritedFrom.name.split(".")[0] +
|
||||
"</code>) "
|
||||
: ""
|
||||
} ${member.comment ? "" : "(undocumented)"}</summary>\n\n` +
|
||||
"```typescript\n" +
|
||||
`${member.getSignature ? "get " : ""}${stem}.${member.name}${
|
||||
member.getSignature ? "()" : ""
|
||||
}: ${renderType(member.type || member.getSignature?.type)}\n` +
|
||||
"```\n" +
|
||||
renderComment(member.comment) +
|
||||
"</details>\n\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
"./DOCS.md",
|
||||
(await Promise.all(packageDocs)).join("\n\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
projects: ['<rootDir>/packages/cojson'],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
10
package.json
10
package.json
@@ -7,14 +7,6 @@
|
||||
],
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"lerna": "^7.1.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"typedoc": "^0.25.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build-all": "lerna run build",
|
||||
"updated": "lerna updated --include-merged-tags",
|
||||
"publish-all": "yarn run gen-docs && lerna publish --include-merged-tags",
|
||||
"gen-docs": "ts-node generateDocs.ts"
|
||||
"lerna": "^7.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
|
||||
};
|
||||
174
packages/cojson-simple-sync/.gitignore
vendored
174
packages/cojson-simple-sync/.gitignore
vendored
@@ -1,174 +0,0 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
|
||||
out
|
||||
sync.db*
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "cojson-simple-sync",
|
||||
"module": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.2.6",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
"eslint": "^8.46.0",
|
||||
"jest": "^29.6.2",
|
||||
"ts-jest": "^29.1.1",
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.2.3",
|
||||
"cojson-storage-sqlite": "^0.2.6",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist && npm run add-shebang && chmod +x ./dist/index.js",
|
||||
"add-shebang": "echo \"#!/usr/bin/env node\" | cat - ./dist/index.js > /tmp/out && mv /tmp/out ./dist/index.js",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"bin": "./dist/index.js",
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { SQLiteStorage } from "cojson-storage-sqlite";
|
||||
import { websocketReadableStream, websocketWritableStream } from "./websocketStreams.js";
|
||||
|
||||
const wss = new WebSocketServer({ port: 4200 });
|
||||
|
||||
console.log("COJSON sync server listening on port " + wss.options.port);
|
||||
|
||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
||||
const agentID = cojsonInternals.getAgentID(agentSecret);
|
||||
|
||||
const localNode = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
cojsonInternals.newRandomSessionID(agentID)
|
||||
);
|
||||
|
||||
SQLiteStorage.asPeer({ filename: "./sync.db" })
|
||||
.then((storage) => localNode.sync.addPeer(storage))
|
||||
.catch((e) => console.error(e));
|
||||
|
||||
wss.on("connection", function connection(ws, req) {
|
||||
const pinging = setInterval(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "ping",
|
||||
time: Date.now(),
|
||||
dc: "cojson-simple-sync",
|
||||
})
|
||||
);
|
||||
}, 2000);
|
||||
|
||||
ws.on("close", () => {
|
||||
clearInterval(pinging);
|
||||
});
|
||||
|
||||
|
||||
|
||||
const clientAddress =
|
||||
(req.headers["x-forwarded-for"] as string | undefined)
|
||||
?.split(",")[0]
|
||||
?.trim() || req.socket.remoteAddress;
|
||||
|
||||
const clientId = clientAddress + "@" + new Date().toISOString();
|
||||
|
||||
localNode.sync.addPeer({
|
||||
id: clientId,
|
||||
role: "client",
|
||||
incoming: websocketReadableStream(ws),
|
||||
outgoing: websocketWritableStream(ws),
|
||||
});
|
||||
|
||||
ws.on("error", (e) => console.error(`Error on connection ${clientId}:`, e));
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { WritableStream, ReadableStream } from "isomorphic-streams";
|
||||
|
||||
export function websocketReadableStream<T>(ws: WebSocket) {
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
return new ReadableStream<T>({
|
||||
start(controller) {
|
||||
ws.addEventListener("message", (event) => {
|
||||
if (typeof event.data !== "string")
|
||||
return console.warn(
|
||||
"Got non-string message from client",
|
||||
event.data
|
||||
);
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "ping") {
|
||||
// console.debug(
|
||||
// "Got ping from",
|
||||
// msg.dc,
|
||||
// "latency",
|
||||
// Date.now() - msg.time,
|
||||
// "ms"
|
||||
// );
|
||||
return;
|
||||
}
|
||||
controller.enqueue(msg);
|
||||
});
|
||||
ws.addEventListener("close", () => controller.close());
|
||||
ws.addEventListener("error", () =>
|
||||
controller.error(new Error("The WebSocket errored!"))
|
||||
);
|
||||
},
|
||||
|
||||
cancel() {
|
||||
ws.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function websocketWritableStream<T>(ws: WebSocket) {
|
||||
return new WritableStream<T>({
|
||||
start(controller) {
|
||||
ws.addEventListener("close", () =>
|
||||
controller.error(
|
||||
new Error("The WebSocket closed unexpectedly!")
|
||||
)
|
||||
);
|
||||
ws.addEventListener("error", () =>
|
||||
controller.error(new Error("The WebSocket errored!"))
|
||||
);
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => ws.once("open", resolve));
|
||||
},
|
||||
|
||||
write(chunk) {
|
||||
ws.send(JSON.stringify(chunk));
|
||||
// Return immediately, since the web socket gives us no easy way to tell
|
||||
// when the write completes.
|
||||
},
|
||||
|
||||
close() {
|
||||
return closeWS(1000);
|
||||
},
|
||||
|
||||
abort(reason) {
|
||||
return closeWS(4000, reason && reason.message);
|
||||
},
|
||||
});
|
||||
|
||||
function closeWS(code: number, reasonString?: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ws.onclose = (e) => {
|
||||
if (e.wasClean) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("The connection was not closed cleanly"));
|
||||
}
|
||||
};
|
||||
ws.close(code, reasonString);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
// "@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
};
|
||||
171
packages/cojson-storage-sqlite/.gitignore
vendored
171
packages/cojson-storage-sqlite/.gitignore
vendored
@@ -1,171 +0,0 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
@@ -1,2 +0,0 @@
|
||||
coverage
|
||||
node_modules
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.2.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.2.3",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.4"
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import {
|
||||
cojsonInternals,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
ReadableStreamDefaultReader,
|
||||
WritableStreamDefaultWriter,
|
||||
} from "isomorphic-streams";
|
||||
|
||||
import Database, { Database as DatabaseT } from "better-sqlite3";
|
||||
|
||||
type CoValueRow = {
|
||||
id: CojsonInternalTypes.RawCoID;
|
||||
header: string;
|
||||
};
|
||||
|
||||
type StoredCoValueRow = CoValueRow & { rowID: number };
|
||||
|
||||
type SessionRow = {
|
||||
coValue: number;
|
||||
sessionID: SessionID;
|
||||
lastIdx: number;
|
||||
lastSignature: CojsonInternalTypes.Signature;
|
||||
bytesSinceLastSignature?: number;
|
||||
};
|
||||
|
||||
type StoredSessionRow = SessionRow & { rowID: number };
|
||||
|
||||
type TransactionRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
tx: string;
|
||||
};
|
||||
|
||||
type SignatureAfterRow = {
|
||||
ses: number;
|
||||
idx: number;
|
||||
signature: CojsonInternalTypes.Signature;
|
||||
};
|
||||
|
||||
export class SQLiteStorage {
|
||||
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
||||
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
|
||||
db: DatabaseT;
|
||||
|
||||
constructor(
|
||||
db: DatabaseT,
|
||||
fromLocalNode: ReadableStream<SyncMessage>,
|
||||
toLocalNode: WritableStream<SyncMessage>
|
||||
) {
|
||||
this.db = db;
|
||||
this.fromLocalNode = fromLocalNode.getReader();
|
||||
this.toLocalNode = toLocalNode.getWriter();
|
||||
|
||||
(async () => {
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const result = await this.fromLocalNode.read();
|
||||
done = result.done;
|
||||
|
||||
if (result.value) {
|
||||
await this.handleSyncMessage(result.value);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
static async asPeer({
|
||||
filename,
|
||||
trace,
|
||||
localNodeName = "local",
|
||||
}: {
|
||||
filename: string;
|
||||
trace?: boolean;
|
||||
localNodeName?: string;
|
||||
}): Promise<Peer> {
|
||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
|
||||
localNodeName,
|
||||
"storage",
|
||||
{ peer1role: "client", peer2role: "server", trace }
|
||||
);
|
||||
|
||||
await SQLiteStorage.open(
|
||||
filename,
|
||||
localNodeAsPeer.incoming,
|
||||
localNodeAsPeer.outgoing
|
||||
);
|
||||
|
||||
return storageAsPeer;
|
||||
}
|
||||
|
||||
static async open(
|
||||
filename: string,
|
||||
fromLocalNode: ReadableStream<SyncMessage>,
|
||||
toLocalNode: WritableStream<SyncMessage>
|
||||
) {
|
||||
const db = Database(filename);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
const oldVersion = (
|
||||
db.pragma("user_version") as [{ user_version: number }]
|
||||
)[0].user_version as number;
|
||||
|
||||
console.log("DB version", oldVersion);
|
||||
|
||||
if (oldVersion === 0) {
|
||||
console.log("Migration 0 -> 1: Basic schema");
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS transactions (
|
||||
ses INTEGER,
|
||||
idx INTEGER,
|
||||
tx TEXT NOT NULL,
|
||||
PRIMARY KEY (ses, idx)
|
||||
) WITHOUT ROWID;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
rowID INTEGER PRIMARY KEY,
|
||||
coValue INTEGER NOT NULL,
|
||||
sessionID TEXT NOT NULL,
|
||||
lastIdx INTEGER,
|
||||
lastSignature TEXT,
|
||||
UNIQUE (sessionID, coValue)
|
||||
);`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS sessionsByCoValue ON sessions (coValue);`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS coValues (
|
||||
rowID INTEGER PRIMARY KEY,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
header TEXT NOT NULL UNIQUE
|
||||
);`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS coValuesByID ON coValues (id);`
|
||||
).run();
|
||||
|
||||
db.pragma("user_version = 1");
|
||||
console.log("Migration 0 -> 1: Basic schema - done");
|
||||
}
|
||||
|
||||
if (oldVersion <= 1) {
|
||||
// fix embarrassing off-by-one error for transaction indices
|
||||
console.log(
|
||||
"Migration 1 -> 2: Fix off-by-one error for transaction indices"
|
||||
);
|
||||
|
||||
const txs = db
|
||||
.prepare(`SELECT * FROM transactions`)
|
||||
.all() as TransactionRow[];
|
||||
|
||||
for (const tx of txs) {
|
||||
db.prepare(
|
||||
`DELETE FROM transactions WHERE ses = ? AND idx = ?`
|
||||
).run(tx.ses, tx.idx);
|
||||
tx.idx -= 1;
|
||||
db.prepare(
|
||||
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
|
||||
).run(tx.ses, tx.idx, tx.tx);
|
||||
}
|
||||
|
||||
db.pragma("user_version = 2");
|
||||
console.log(
|
||||
"Migration 1 -> 2: Fix off-by-one error for transaction indices - done"
|
||||
);
|
||||
}
|
||||
|
||||
if (oldVersion <= 2) {
|
||||
console.log("Migration 2 -> 3: Add signatureAfter");
|
||||
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS signatureAfter (
|
||||
ses INTEGER,
|
||||
idx INTEGER,
|
||||
signature TEXT NOT NULL,
|
||||
PRIMARY KEY (ses, idx)
|
||||
) WITHOUT ROWID;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE sessions ADD COLUMN bytesSinceLastSignature INTEGER;`
|
||||
).run();
|
||||
|
||||
db.pragma("user_version = 3");
|
||||
console.log("Migration 2 -> 3: Add signatureAfter - done");
|
||||
}
|
||||
|
||||
return new SQLiteStorage(db, fromLocalNode, toLocalNode);
|
||||
}
|
||||
|
||||
async handleSyncMessage(msg: SyncMessage) {
|
||||
switch (msg.action) {
|
||||
case "load":
|
||||
await this.handleLoad(msg);
|
||||
break;
|
||||
case "content":
|
||||
await this.handleContent(msg);
|
||||
break;
|
||||
case "known":
|
||||
await this.handleKnown(msg);
|
||||
break;
|
||||
case "done":
|
||||
await this.handleDone(msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async sendNewContentAfter(
|
||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
||||
asDependencyOf?: CojsonInternalTypes.RawCoID
|
||||
) {
|
||||
const coValueRow = (await this.db
|
||||
.prepare(`SELECT * FROM coValues WHERE id = ?`)
|
||||
.get(theirKnown.id)) as StoredCoValueRow | undefined;
|
||||
|
||||
const allOurSessions = coValueRow
|
||||
? (this.db
|
||||
.prepare<number>(`SELECT * FROM sessions WHERE coValue = ?`)
|
||||
.all(coValueRow.rowID) as StoredSessionRow[])
|
||||
: [];
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: theirKnown.id,
|
||||
header: !!coValueRow,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const parsedHeader = (coValueRow?.header &&
|
||||
JSON.parse(coValueRow.header)) as
|
||||
| CojsonInternalTypes.CoValueHeader
|
||||
| undefined;
|
||||
|
||||
const newContentPieces: CojsonInternalTypes.NewContentMessage[] = [
|
||||
{
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : parsedHeader,
|
||||
new: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const sessionRow of allOurSessions) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
|
||||
if (
|
||||
sessionRow.lastIdx >
|
||||
(theirKnown.sessions[sessionRow.sessionID] || 0)
|
||||
) {
|
||||
const firstNewTxIdx =
|
||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||
|
||||
const signaturesAndIdxs = this.db
|
||||
.prepare<[number, number]>(
|
||||
`SELECT * FROM signatureAfter WHERE ses = ? AND idx >= ?`
|
||||
)
|
||||
.all(sessionRow.rowID, firstNewTxIdx) as SignatureAfterRow[];
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "signaturesAndIdxs",
|
||||
// JSON.stringify(signaturesAndIdxs)
|
||||
// );
|
||||
|
||||
const newTxInSession = this.db
|
||||
.prepare<[number, number]>(
|
||||
`SELECT * FROM transactions WHERE ses = ? AND idx >= ?`
|
||||
)
|
||||
.all(sessionRow.rowID, firstNewTxIdx) as TransactionRow[];
|
||||
|
||||
let idx = firstNewTxIdx;
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
// "newTxInSession",
|
||||
// newTxInSession.length
|
||||
// );
|
||||
|
||||
for (const tx of newTxInSession) {
|
||||
let sessionEntry =
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
sessionRow.sessionID
|
||||
] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(JSON.parse(tx.tx));
|
||||
|
||||
if (
|
||||
signaturesAndIdxs[0] &&
|
||||
idx === signaturesAndIdxs[0].idx
|
||||
) {
|
||||
sessionEntry.lastSignature =
|
||||
signaturesAndIdxs[0].signature;
|
||||
signaturesAndIdxs.shift();
|
||||
newContentPieces.push({
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
new: {},
|
||||
});
|
||||
} else if (
|
||||
idx ===
|
||||
firstNewTxIdx + newTxInSession.length - 1
|
||||
) {
|
||||
sessionEntry.lastSignature = sessionRow.lastSignature;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dependedOnCoValues =
|
||||
parsedHeader?.ruleset.type === "group"
|
||||
? newContentPieces
|
||||
.flatMap((piece) => Object.values(piece.new)).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
// TODO: avoid parsing here?
|
||||
return cojsonInternals
|
||||
.parseJSON(tx.changes)
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
: parsedHeader?.ruleset.type === "ownedByGroup"
|
||||
? [parsedHeader?.ruleset.group]
|
||||
: [];
|
||||
|
||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||
await this.sendNewContentAfter(
|
||||
{ id: dependedOnCoValue, header: false, sessions: {} },
|
||||
asDependencyOf || theirKnown.id
|
||||
);
|
||||
}
|
||||
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
asDependencyOf,
|
||||
});
|
||||
|
||||
const nonEmptyNewContentPieces = newContentPieces.filter(
|
||||
(piece) => piece.header || Object.keys(piece.new).length > 0
|
||||
);
|
||||
|
||||
// console.log(theirKnown.id, nonEmptyNewContentPieces);
|
||||
|
||||
for (const piece of nonEmptyNewContentPieces) {
|
||||
await this.toLocalNode.write(piece);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
|
||||
return this.sendNewContentAfter(msg);
|
||||
}
|
||||
|
||||
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
||||
let storedCoValueRowID = (
|
||||
this.db
|
||||
.prepare<CojsonInternalTypes.RawCoID>(
|
||||
`SELECT rowID FROM coValues WHERE id = ?`
|
||||
)
|
||||
.get(msg.id) as StoredCoValueRow | undefined
|
||||
)?.rowID;
|
||||
|
||||
if (storedCoValueRowID === undefined) {
|
||||
const header = msg.header;
|
||||
if (!header) {
|
||||
console.error("Expected to be sent header first");
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
isCorrection: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storedCoValueRowID = this.db
|
||||
.prepare<[CojsonInternalTypes.RawCoID, string]>(
|
||||
`INSERT INTO coValues (id, header) VALUES (?, ?)`
|
||||
)
|
||||
.run(msg.id, JSON.stringify(header)).lastInsertRowid as number;
|
||||
}
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: msg.id,
|
||||
header: true,
|
||||
sessions: {},
|
||||
};
|
||||
let invalidAssumptions = false;
|
||||
|
||||
this.db.transaction(() => {
|
||||
const allOurSessions = (
|
||||
this.db
|
||||
.prepare<number>(`SELECT * FROM sessions WHERE coValue = ?`)
|
||||
.all(storedCoValueRowID!) as StoredSessionRow[]
|
||||
).reduce((acc, row) => {
|
||||
acc[row.sessionID] = row;
|
||||
return acc;
|
||||
}, {} as { [sessionID: string]: StoredSessionRow });
|
||||
|
||||
for (const sessionID of Object.keys(msg.new) as SessionID[]) {
|
||||
const sessionRow = allOurSessions[sessionID];
|
||||
if (sessionRow) {
|
||||
ourKnown.sessions[sessionRow.sessionID] =
|
||||
sessionRow.lastIdx;
|
||||
}
|
||||
|
||||
if (
|
||||
(sessionRow?.lastIdx || 0) <
|
||||
(msg.new[sessionID]?.after || 0)
|
||||
) {
|
||||
invalidAssumptions = true;
|
||||
} else {
|
||||
const newTransactions =
|
||||
msg.new[sessionID]?.newTransactions || [];
|
||||
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) -
|
||||
(msg.new[sessionID]?.after || 0);
|
||||
|
||||
const actuallyNewTransactions =
|
||||
newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
let newBytesSinceLastSignature =
|
||||
(sessionRow?.bytesSinceLastSignature || 0) +
|
||||
actuallyNewTransactions.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
const newLastIdx =
|
||||
(sessionRow?.lastIdx || 0) +
|
||||
actuallyNewTransactions.length;
|
||||
|
||||
let shouldWriteSignature = false;
|
||||
|
||||
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
|
||||
shouldWriteSignature = true;
|
||||
newBytesSinceLastSignature = 0;
|
||||
}
|
||||
|
||||
let nextIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const sessionUpdate = {
|
||||
coValue: storedCoValueRowID!,
|
||||
sessionID: sessionID,
|
||||
lastIdx: newLastIdx,
|
||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||
bytesSinceLastSignature: newBytesSinceLastSignature,
|
||||
};
|
||||
|
||||
const upsertedSession = this.db
|
||||
.prepare<[number, string, number, string, number]>(
|
||||
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature, bytesSinceLastSignature) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature, bytesSinceLastSignature=excluded.bytesSinceLastSignature
|
||||
RETURNING rowID`
|
||||
)
|
||||
.get(
|
||||
sessionUpdate.coValue,
|
||||
sessionUpdate.sessionID,
|
||||
sessionUpdate.lastIdx,
|
||||
sessionUpdate.lastSignature,
|
||||
sessionUpdate.bytesSinceLastSignature,
|
||||
) as { rowID: number };
|
||||
|
||||
const sessionRowID = upsertedSession.rowID;
|
||||
|
||||
if (shouldWriteSignature) {
|
||||
this.db
|
||||
.prepare<[number, number, string]>(
|
||||
`INSERT INTO signatureAfter (ses, idx, signature) VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
sessionRowID,
|
||||
// TODO: newLastIdx is a misnomer, it's actually more like nextIdx or length
|
||||
newLastIdx - 1,
|
||||
msg.new[sessionID]!.lastSignature
|
||||
);
|
||||
}
|
||||
|
||||
for (const newTransaction of actuallyNewTransactions) {
|
||||
this.db
|
||||
.prepare<[number, number, string]>(
|
||||
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
sessionRowID,
|
||||
nextIdx,
|
||||
JSON.stringify(newTransaction)
|
||||
);
|
||||
nextIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (invalidAssumptions) {
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
isCorrection: invalidAssumptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleKnown(msg: CojsonInternalTypes.KnownStateMessage) {
|
||||
return this.sendNewContentAfter(msg);
|
||||
}
|
||||
|
||||
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ module.exports = {
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
ignorePatterns: [".eslint.cjs", "**/tests/*"],
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
|
||||
@@ -1,3 +1,53 @@
|
||||
# CoJSON
|
||||
|
||||
[See the top-level README](../../README.md#cojson)
|
||||
CoJSON ("Collaborative JSON") will be a minimal protocol and implementation for collaborative values (CRDTs + public-key cryptography).
|
||||
|
||||
CoJSON is developed by [Garden Computing](https://gcmp.io) as the underpinnings of [Jazz](https://jazz.tools), a framework for building apps with telepathic data.
|
||||
|
||||
The protocol and implementation will cover:
|
||||
|
||||
- how to represent collaborative values internally
|
||||
- the APIs collaborative values expose
|
||||
- how to sync and query for collaborative values between peers
|
||||
- how to enforce access rights within collaborative values locally and at sync boundaries
|
||||
|
||||
THIS IS WORK IN PROGRESS
|
||||
|
||||
## Core Value Types
|
||||
|
||||
### `Immutable` Values (JSON)
|
||||
- null
|
||||
- boolean
|
||||
- number
|
||||
- string
|
||||
- stringly-encoded CoJSON identifiers & data (`CoID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`)
|
||||
|
||||
- array
|
||||
- object
|
||||
|
||||
### `Collaborative` Values
|
||||
- CoMap (`string` → `Immutable`, last-writer-wins per key)
|
||||
- Team (`AgentID` → `Role`)
|
||||
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
|
||||
- Agent (`{signerID, sealerID}[]`)
|
||||
- CoStream (independent per-session streams of `Immutable`s)
|
||||
- Static (single addressable `Immutable`)
|
||||
|
||||
## Implementation Abstractions
|
||||
- CoValue
|
||||
- Session Logs
|
||||
- Transactions
|
||||
- Private (encrypted) transactions
|
||||
- Trusting (unencrypted) transactions
|
||||
- Rulesets
|
||||
- CoValue Content Types
|
||||
- LocalNode
|
||||
- Peers
|
||||
- AgentCredentials
|
||||
- Peer
|
||||
|
||||
## Extensions & higher-level protocols
|
||||
|
||||
### More complex datastructures
|
||||
- CoText: a clean way to collaboratively mark up rich text with CoJSON
|
||||
- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "cojson",
|
||||
"module": "dist/index.js",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.2.3",
|
||||
"version": "0.0.18",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
@@ -19,8 +19,9 @@
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.1.3",
|
||||
"@noble/curves": "^1.1.0",
|
||||
"@noble/hashes": "^1.3.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"hash-wasm": "^4.9.0",
|
||||
"fast-json-stable-stringify": "https://github.com/tirithen/fast-json-stable-stringify#7a3dcf2",
|
||||
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -50,6 +51,5 @@
|
||||
"/node_modules/",
|
||||
"/dist/"
|
||||
]
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { LocalNode } from "../node.js";
|
||||
import { connectedPeers } from "../streamUtils.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
|
||||
test("Can create a node while creating a new account with profile", async () => {
|
||||
const { node, accountID, accountSecret, sessionID } =
|
||||
@@ -24,14 +19,14 @@ test("Can create a node while creating a new account with profile", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("A node with an account can create groups and and objects within them", async () => {
|
||||
test("A node with an account can create teams and and objects within them", async () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
const team = await node.createTeam();
|
||||
expect(team).not.toBeNull();
|
||||
|
||||
let map = group.createMap();
|
||||
let map = team.createMap();
|
||||
map = map.edit((edit) => {
|
||||
edit.set("foo", "bar", "private");
|
||||
expect(edit.get("foo")).toEqual("bar");
|
||||
@@ -39,17 +34,17 @@ test("A node with an account can create groups and and objects within them", asy
|
||||
|
||||
expect(map.get("foo")).toEqual("bar");
|
||||
|
||||
expect(map.whoEdited("foo")).toEqual(accountID);
|
||||
expect(map.getLastEditor("foo")).toEqual(accountID);
|
||||
});
|
||||
|
||||
test("Can create account with one node, and then load it on another", async () => {
|
||||
const { node, accountID, accountSecret } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
const team = await node.createTeam();
|
||||
expect(team).not.toBeNull();
|
||||
|
||||
let map = group.createMap();
|
||||
let map = team.createMap();
|
||||
map = map.edit((edit) => {
|
||||
edit.set("foo", "bar", "private");
|
||||
expect(edit.get("foo")).toEqual("bar");
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CoValueHeader } from "./coValueCore.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { CoValueHeader } from "./coValue.js";
|
||||
import { CoID } from "./contentType.js";
|
||||
import {
|
||||
AgentSecret,
|
||||
SealerID,
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
getAgentSignerSecret,
|
||||
} from "./crypto.js";
|
||||
import { AgentID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { Group, GroupContent } from "./group.js";
|
||||
import { CoMap, LocalNode } from "./index.js";
|
||||
import { Team, TeamContent } from "./permissions.js";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
agentSecret: AgentSecret
|
||||
@@ -23,7 +22,7 @@ export function accountHeaderForInitialAgentSecret(
|
||||
const agent = getAgentID(agentSecret);
|
||||
return {
|
||||
type: "comap",
|
||||
ruleset: { type: "group", initialAdmin: agent },
|
||||
ruleset: { type: "team", initialAdmin: agent },
|
||||
meta: {
|
||||
type: "account",
|
||||
},
|
||||
@@ -32,13 +31,13 @@ export function accountHeaderForInitialAgentSecret(
|
||||
};
|
||||
}
|
||||
|
||||
export class AccountGroup extends Group {
|
||||
export class Account extends Team {
|
||||
get id(): AccountID {
|
||||
return this.underlyingMap.id as AccountID;
|
||||
return this.teamMap.id as AccountID;
|
||||
}
|
||||
|
||||
getCurrentAgentID(): AgentID {
|
||||
const agents = this.underlyingMap
|
||||
const agents = this.teamMap
|
||||
.keys()
|
||||
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
||||
|
||||
@@ -53,7 +52,7 @@ export class AccountGroup extends Group {
|
||||
}
|
||||
|
||||
export interface GeneralizedControlledAccount {
|
||||
id: AccountID | AgentID;
|
||||
id: AccountIDOrAgentID;
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
currentAgentID: () => AgentID;
|
||||
@@ -63,19 +62,18 @@ export interface GeneralizedControlledAccount {
|
||||
currentSealerSecret: () => SealerSecret;
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class ControlledAccount
|
||||
extends AccountGroup
|
||||
extends Account
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
constructor(
|
||||
agentSecret: AgentSecret,
|
||||
groupMap: CoMap<AccountContent, AccountMeta>,
|
||||
teamMap: CoMap<AccountContent, AccountMeta>,
|
||||
node: LocalNode
|
||||
) {
|
||||
super(groupMap, node);
|
||||
super(teamMap, node);
|
||||
|
||||
this.agentSecret = agentSecret;
|
||||
}
|
||||
@@ -101,7 +99,6 @@ export class ControlledAccount
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class AnonymousControlledAccount
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
@@ -136,12 +133,15 @@ export class AnonymousControlledAccount
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountContent = { profile: Profile } & GroupContent;
|
||||
export type AccountContent = TeamContent & { profile: CoID<Profile> };
|
||||
export type AccountMeta = { type: "account" };
|
||||
export type Account = CoMap<AccountContent, AccountMeta>;
|
||||
export type AccountID = CoID<Account>;
|
||||
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
|
||||
|
||||
export function isAccountID(id: AccountID | AgentID): id is AccountID {
|
||||
export type AccountIDOrAgentID = AgentID | AccountID;
|
||||
export type AccountOrAgentID = AgentID | Account;
|
||||
export type AccountOrAgentSecret = AgentSecret | Account;
|
||||
|
||||
export function isAccountID(id: AccountIDOrAgentID): id is AccountID {
|
||||
return id.startsWith("co_");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url";
|
||||
|
||||
const txt = new TextEncoder();
|
||||
|
||||
test("Test our Base64 URL encoding and decoding", () => {
|
||||
// tests from the RFC
|
||||
|
||||
expect(base64URLtoBytes("")).toEqual(new Uint8Array([]));
|
||||
expect(bytesToBase64url(new Uint8Array([]))).toEqual("");
|
||||
|
||||
expect(bytesToBase64url(txt.encode("f"))).toEqual("Zg==");
|
||||
expect(bytesToBase64url(txt.encode("fo"))).toEqual("Zm8=");
|
||||
expect(bytesToBase64url(txt.encode("foo"))).toEqual("Zm9v");
|
||||
expect(bytesToBase64url(txt.encode("foob"))).toEqual("Zm9vYg==");
|
||||
expect(bytesToBase64url(txt.encode("fooba"))).toEqual("Zm9vYmE=");
|
||||
expect(bytesToBase64url(txt.encode("foobar"))).toEqual("Zm9vYmFy");
|
||||
// reverse
|
||||
expect(base64URLtoBytes("Zg==")).toEqual(txt.encode("f"));
|
||||
expect(base64URLtoBytes("Zm8=")).toEqual(txt.encode("fo"));
|
||||
expect(base64URLtoBytes("Zm9v")).toEqual(txt.encode("foo"));
|
||||
expect(base64URLtoBytes("Zm9vYg==")).toEqual(txt.encode("foob"));
|
||||
expect(base64URLtoBytes("Zm9vYmE=")).toEqual(txt.encode("fooba"));
|
||||
expect(base64URLtoBytes("Zm9vYmFy")).toEqual(txt.encode("foobar"));
|
||||
|
||||
expect(base64URLtoBytes("V2hhdCBkb2VzIDIgKyAyLjEgZXF1YWw_PyB-IDQ=")).toEqual(
|
||||
txt.encode("What does 2 + 2.1 equal?? ~ 4")
|
||||
);
|
||||
// reverse
|
||||
expect(
|
||||
bytesToBase64url(txt.encode("What does 2 + 2.1 equal?? ~ 4"))
|
||||
).toEqual("V2hhdCBkb2VzIDIgKyAyLjEgZXF1YWw_PyB-IDQ=");
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export function base64URLtoBytes(base64: string) {
|
||||
base64 = base64.replace(/=/g, "");
|
||||
const n = base64.length;
|
||||
const rem = n % 4;
|
||||
const k = rem && rem - 1; // how many bytes the last base64 chunk encodes
|
||||
const m = (n >> 2) * 3 + k; // total encoded bytes
|
||||
|
||||
const encoded = new Uint8Array(n + 3);
|
||||
encoder.encodeInto(base64 + "===", encoded);
|
||||
|
||||
for (let i = 0, j = 0; i < n; i += 4, j += 3) {
|
||||
const x =
|
||||
(lookup[encoded[i]!]! << 18) +
|
||||
(lookup[encoded[i + 1]!]! << 12) +
|
||||
(lookup[encoded[i + 2]!]! << 6) +
|
||||
lookup[encoded[i + 3]!]!;
|
||||
encoded[j] = x >> 16;
|
||||
encoded[j + 1] = (x >> 8) & 0xff;
|
||||
encoded[j + 2] = x & 0xff;
|
||||
}
|
||||
return new Uint8Array(encoded.buffer, 0, m);
|
||||
}
|
||||
|
||||
export function bytesToBase64url(bytes: Uint8Array) {
|
||||
// const before = performance.now();
|
||||
const m = bytes.length;
|
||||
const k = m % 3;
|
||||
const n = Math.floor(m / 3) * 4 + (k && k + 1);
|
||||
const N = Math.ceil(m / 3) * 4;
|
||||
const encoded = new Uint8Array(N);
|
||||
|
||||
for (let i = 0, j = 0; j < m; i += 4, j += 3) {
|
||||
const y =
|
||||
(bytes[j]! << 16) + (bytes[j + 1]! << 8) + (bytes[j + 2]! | 0);
|
||||
encoded[i] = encodeLookup[y >> 18]!;
|
||||
encoded[i + 1] = encodeLookup[(y >> 12) & 0x3f]!;
|
||||
encoded[i + 2] = encodeLookup[(y >> 6) & 0x3f]!;
|
||||
encoded[i + 3] = encodeLookup[y & 0x3f]!;
|
||||
}
|
||||
|
||||
let base64 = decoder.decode(new Uint8Array(encoded.buffer, 0, n));
|
||||
if (k === 1) base64 += "==";
|
||||
if (k === 2) base64 += "=";
|
||||
// const after = performance.now();
|
||||
// console.log(
|
||||
// "bytesToBase64url bandwidth in MB/s for length",
|
||||
// (1000 * bytes.length / (after - before)) / (1024 * 1024),
|
||||
// bytes.length
|
||||
// );
|
||||
return base64;
|
||||
}
|
||||
|
||||
const alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
const lookup = new Uint8Array(128);
|
||||
for (const [i, a] of Array.from(alphabet).entries()) {
|
||||
lookup[a.charCodeAt(0)] = i;
|
||||
}
|
||||
lookup["=".charCodeAt(0)] = 0;
|
||||
|
||||
const encodeLookup = new Uint8Array(64);
|
||||
for (const [i, a] of Array.from(alphabet).entries()) {
|
||||
encodeLookup[i] = a.charCodeAt(0);
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { Transaction } from "../coValueCore.js";
|
||||
import { LocalNode } from "../node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "../crypto.js";
|
||||
import { Transaction } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { MapOpPayload } from "../coValues/coMap.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { stableStringify } from "../jsonStringify.js";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
|
||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
@@ -25,21 +17,21 @@ test("Can create coValue with new agent credentials and add transaction to it",
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.currentSessionID,
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.currentSessionID,
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
@@ -62,21 +54,21 @@ test("transactions with wrong signature are rejected", () => {
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.currentSessionID,
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.currentSessionID,
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
||||
@@ -98,89 +90,34 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.currentSessionID,
|
||||
node.ownSessionID,
|
||||
[
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: stableStringify([
|
||||
changes: [
|
||||
{
|
||||
hello: "wrong",
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.currentSessionID,
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("New transactions in a group correctly update owned values, including subscriptions", async () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
const timeBeforeEdit = Date.now();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
let map = group.createMap();
|
||||
|
||||
let mapAfterEdit = map.edit((map) => {
|
||||
map.set("hello", "world");
|
||||
});
|
||||
|
||||
const listener = jest.fn().mockImplementation();
|
||||
|
||||
map.subscribe(listener);
|
||||
|
||||
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
|
||||
|
||||
const resignationThatWeJustLearnedAbout = {
|
||||
privacy: "trusting",
|
||||
madeAt: timeBeforeEdit,
|
||||
changes: stableStringify([
|
||||
{
|
||||
op: "set",
|
||||
key: account.id,
|
||||
value: "revoked"
|
||||
} satisfies MapOpPayload<typeof account.id, Role>
|
||||
])
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = group.underlyingMap.core.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
expect(map.core.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = group.underlyingMap.core.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
expect(listener.mock.calls.length).toBe(2);
|
||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
||||
|
||||
expect(map.core.getValidSortedTransactions().length).toBe(0);
|
||||
});
|
||||
@@ -1,90 +1,547 @@
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { ContentType } from "./contentType.js";
|
||||
import { Static } from "./contentTypes/static.js";
|
||||
import { CoStream } from "./contentTypes/coStream.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
BinaryCoStreamMeta,
|
||||
CoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { Group } from "./group.js";
|
||||
Encrypted,
|
||||
Hash,
|
||||
KeySecret,
|
||||
Signature,
|
||||
StreamingHash,
|
||||
unseal,
|
||||
shortHash,
|
||||
sign,
|
||||
verify,
|
||||
encryptForTransaction,
|
||||
decryptForTransaction,
|
||||
KeyID,
|
||||
decryptKeySecret,
|
||||
getAgentSignerID,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
Team,
|
||||
determineValidTransactions,
|
||||
expectTeamContent,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { CoList } from "./contentTypes/coList.js";
|
||||
import {
|
||||
AccountID,
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
} from "./account.js";
|
||||
|
||||
export type CoID<T extends CoValue> = RawCoID & {
|
||||
readonly __type: T;
|
||||
export type CoValueHeader = {
|
||||
type: ContentType["type"];
|
||||
ruleset: RulesetDef;
|
||||
meta: JsonObject | null;
|
||||
createdAt: `2${string}` | null;
|
||||
uniqueness: `z${string}` | null;
|
||||
};
|
||||
|
||||
export interface CoValue {
|
||||
/** The `CoValue`'s (precisely typed) `CoID` */
|
||||
id: CoID<this>;
|
||||
core: CoValueCore;
|
||||
/** Specifies which kind of `CoValue` this is */
|
||||
type: string;
|
||||
/** The `CoValue`'s (precisely typed) static metadata */
|
||||
meta: JsonObject | null;
|
||||
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
||||
group: Group;
|
||||
/** Returns an immutable JSON presentation of this `CoValue` */
|
||||
toJSON(): JsonValue;
|
||||
/** Lets you subscribe to future updates to this CoValue (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 `CoValue`. */
|
||||
subscribe(listener: (coValue: this) => void): () => void;
|
||||
/** Lets you apply edits to a `CoValue`, inside the changer callback, which receives a `WriteableCoValue`.
|
||||
*
|
||||
* A `WritableCoValue` has all the same methods as a `CoValue`, but all edits made to it (with its additional mutator methods)
|
||||
* are reflected in it immediately - so it behaves mutably, whereas a `CoValue` is always immutable
|
||||
* (you need to use `subscribe` to receive new versions of it). */
|
||||
edit?: ((changer: (editable: CoValue) => void) => this) | undefined;
|
||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
const hash = shortHash(header);
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
}
|
||||
|
||||
export type AnyCoMap = CoMap<
|
||||
{ [key: string]: JsonValue | CoValue | undefined },
|
||||
JsonObject | null
|
||||
>;
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountIDOrAgentID {
|
||||
return sessionID.split("_session")[0] as AccountIDOrAgentID;
|
||||
}
|
||||
|
||||
export type AnyCoList = CoList<JsonValue | CoValue, JsonObject | null>;
|
||||
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
|
||||
export type AnyCoStream = CoStream<JsonValue | CoValue, JsonObject | null>;
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
export type AnyBinaryCoStream = BinaryCoStream<BinaryCoStreamMeta>;
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
keyUsed: KeyID;
|
||||
encryptedChanges: Encrypted<
|
||||
JsonValue[],
|
||||
{ in: RawCoID; tx: TransactionID }
|
||||
>;
|
||||
};
|
||||
|
||||
export type AnyStatic = Static<JsonObject>;
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: JsonValue[];
|
||||
};
|
||||
|
||||
export type AnyCoValue =
|
||||
| AnyCoMap
|
||||
| AnyCoList
|
||||
| AnyCoStream
|
||||
| AnyBinaryCoStream
|
||||
| AnyStatic;
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export function expectMap(
|
||||
content: CoValue
|
||||
): CoMap<{ [key: string]: string }, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
export class CoValue {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
sessions: { [key: SessionID]: SessionLog };
|
||||
content?: ContentType;
|
||||
listeners: Set<(content?: ContentType) => void> = new Set();
|
||||
|
||||
constructor(header: CoValueHeader, node: LocalNode) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this.sessions = {};
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
|
||||
}
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
): CoValue {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
ownSessionID
|
||||
);
|
||||
|
||||
export function isCoValueImpl(
|
||||
value: JsonValue | AnyCoValue | undefined
|
||||
): value is AnyCoValue {
|
||||
return (
|
||||
value instanceof CoMap ||
|
||||
value instanceof CoList ||
|
||||
value instanceof CoStream ||
|
||||
value instanceof BinaryCoStream ||
|
||||
value instanceof Static
|
||||
);
|
||||
}
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
}
|
||||
|
||||
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
||||
return isCoValueImpl(value as AnyCoValue);
|
||||
}
|
||||
knownState(): CoValueKnownState {
|
||||
return {
|
||||
id: this.id,
|
||||
header: true,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(this.sessions).map(([k, v]) => [
|
||||
k,
|
||||
v.transactions.length,
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.ownSessionID;
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature
|
||||
): boolean {
|
||||
const signerID = getAgentSignerID(
|
||||
this.node.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction"
|
||||
)
|
||||
);
|
||||
|
||||
if (!signerID) {
|
||||
console.warn(
|
||||
"Unknown agent",
|
||||
accountOrAgentIDfromSessionID(sessionID)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this.sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this.content = undefined;
|
||||
|
||||
const content = this.getCurrentContent();
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: ContentType) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting"
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
let transaction: Transaction;
|
||||
|
||||
if (privacy === "private") {
|
||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||
|
||||
if (!keySecret) {
|
||||
throw new Error(
|
||||
"Can't make transaction without read key secret"
|
||||
);
|
||||
}
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.ownSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
this.node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
const success = this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): ContentType {
|
||||
if (this.content) {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this.content = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this.content = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
this.content = new CoStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this.content = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
const decrytedChanges = decryptForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (!decrytedChanges) {
|
||||
console.error(
|
||||
"Failed to decrypt transaction despite having key"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: decrytedChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
||||
allTransactions.sort(
|
||||
(a, b) =>
|
||||
a.madeAt - b.madeAt ||
|
||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||
a.txID.txIndex - b.txID.txIndex
|
||||
);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "team") {
|
||||
const content = expectTeamContent(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only teams or values owned by teams have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (this.header.ruleset.type === "team") {
|
||||
const content = expectTeamContent(this.getCurrentContent());
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (secret) return secret as KeySecret;
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const field of content.keys()) {
|
||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(field)!;
|
||||
|
||||
const secret = decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret;
|
||||
} else {
|
||||
console.error(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only teams or values owned by teams have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTeam(): Team {
|
||||
if (this.header.ruleset.type !== "ownedByTeam") {
|
||||
throw new Error("Only values owned by teams have teams");
|
||||
}
|
||||
|
||||
return new Team(
|
||||
expectTeamContent(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
|
||||
getTx(txID: TransactionID): Transaction | undefined {
|
||||
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
|
||||
}
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined
|
||||
): NewContentMessage | undefined {
|
||||
const newContent: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
new: Object.fromEntries(
|
||||
Object.entries(this.sessions)
|
||||
.map(([sessionID, log]) => {
|
||||
const newTransactions = log.transactions.slice(
|
||||
knownState?.sessions[sessionID as SessionID] || 0
|
||||
);
|
||||
|
||||
if (
|
||||
newTransactions.length === 0 ||
|
||||
!log.lastHash ||
|
||||
!log.lastSignature
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
after:
|
||||
knownState?.sessions[
|
||||
sessionID as SessionID
|
||||
] || 0,
|
||||
newTransactions,
|
||||
lastSignature: log.lastSignature,
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
!newContent.header &&
|
||||
Object.keys(newContent.new).length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "team"
|
||||
? expectTeamContent(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByTeam"
|
||||
? [this.header.ruleset.team]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { AnyCoValue } from "./coValue.js";
|
||||
import { Static } from "./coValues/static.js";
|
||||
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import {
|
||||
Encrypted,
|
||||
Hash,
|
||||
KeySecret,
|
||||
Signature,
|
||||
StreamingHash,
|
||||
unseal,
|
||||
shortHash,
|
||||
sign,
|
||||
verify,
|
||||
encryptForTransaction,
|
||||
KeyID,
|
||||
decryptKeySecret,
|
||||
getAgentSignerID,
|
||||
getAgentSealerID,
|
||||
decryptRawForTransaction,
|
||||
} from "./crypto.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { AccountID, GeneralizedControlledAccount } from "./account.js";
|
||||
import { Stringified, stableStringify } from "./jsonStringify.js";
|
||||
|
||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
||||
|
||||
export type CoValueHeader = {
|
||||
type: AnyCoValue["type"];
|
||||
ruleset: RulesetDef;
|
||||
meta: JsonObject | null;
|
||||
createdAt: `2${string}` | null;
|
||||
uniqueness: `z${string}` | null;
|
||||
};
|
||||
|
||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
const hash = shortHash(header);
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
}
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountID | AgentID {
|
||||
return sessionID.split("_session")[0] as AccountID | AgentID;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
signatureAfter: { [txIdx: number]: Signature | undefined };
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
keyUsed: KeyID;
|
||||
encryptedChanges: Encrypted<
|
||||
JsonValue[],
|
||||
{ in: RawCoID; tx: TransactionID }
|
||||
>;
|
||||
};
|
||||
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: Stringified<JsonValue[]>;
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: Stringified<JsonValue[]>;
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export class CoValueCore {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: AnyCoValue;
|
||||
listeners: Set<(content?: AnyCoValue) => void> = new Set();
|
||||
_decryptionCache: {
|
||||
[key: Encrypted<JsonValue[], JsonValue>]:
|
||||
| Stringified<JsonValue[]>
|
||||
| undefined;
|
||||
} = {};
|
||||
|
||||
constructor(
|
||||
header: CoValueHeader,
|
||||
node: LocalNode,
|
||||
internalInitSessions: { [key: SessionID]: SessionLog } = {}
|
||||
) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this._sessions = internalInitSessions;
|
||||
this.node = node;
|
||||
|
||||
if (header.ruleset.type == "ownedByGroup") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.group)
|
||||
.subscribe((_groupUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
const newContent = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
currentSessionID: SessionID
|
||||
): CoValueCore {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
currentSessionID
|
||||
);
|
||||
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
return {
|
||||
id: this.id,
|
||||
header: true,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(this.sessions).map(([k, v]) => [
|
||||
k,
|
||||
v.transactions.length,
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.currentSessionID;
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature
|
||||
): boolean {
|
||||
const signerID = getAgentSignerID(
|
||||
this.node.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction"
|
||||
)
|
||||
);
|
||||
|
||||
if (!signerID) {
|
||||
console.warn(
|
||||
"Unknown agent",
|
||||
accountOrAgentIDfromSessionID(sessionID)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// const beforeHash = performance.now();
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
// const afterHash = performance.now();
|
||||
// console.log(
|
||||
// "Hashing took",
|
||||
// afterHash - beforeHash
|
||||
// );
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", {
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// const beforeVerify = performance.now();
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature in",
|
||||
this.id,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// const afterVerify = performance.now();
|
||||
// console.log(
|
||||
// "Verify took",
|
||||
// afterVerify - beforeVerify
|
||||
// );
|
||||
|
||||
this.doAddTransactions(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
newStreamingHash
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async tryAddTransactionsAsync(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature
|
||||
): Promise<boolean> {
|
||||
const signerID = getAgentSignerID(
|
||||
this.node.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction"
|
||||
)
|
||||
);
|
||||
|
||||
if (!signerID) {
|
||||
console.warn(
|
||||
"Unknown agent",
|
||||
accountOrAgentIDfromSessionID(sessionID)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const nTxBefore = this.sessions[sessionID]?.transactions.length ?? 0;
|
||||
|
||||
// const beforeHash = performance.now();
|
||||
const { expectedNewHash, newStreamingHash } =
|
||||
await this.expectedNewHashAfterAsync(sessionID, newTransactions);
|
||||
// const afterHash = performance.now();
|
||||
// console.log(
|
||||
// "Hashing took",
|
||||
// afterHash - beforeHash
|
||||
// );
|
||||
|
||||
const nTxAfter = this.sessions[sessionID]?.transactions.length ?? 0;
|
||||
|
||||
if (nTxAfter !== nTxBefore) {
|
||||
const newTransactionLengthBefore = newTransactions.length;
|
||||
newTransactions = newTransactions.slice(nTxAfter - nTxBefore);
|
||||
console.warn("Transactions changed while async hashing", {
|
||||
nTxBefore,
|
||||
nTxAfter,
|
||||
newTransactionLengthBefore,
|
||||
remainingNewTransactions: newTransactions.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", {
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// const beforeVerify = performance.now();
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature in",
|
||||
this.id,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// const afterVerify = performance.now();
|
||||
// console.log(
|
||||
// "Verify took",
|
||||
// afterVerify - beforeVerify
|
||||
// );
|
||||
|
||||
this.doAddTransactions(
|
||||
sessionID,
|
||||
newTransactions,
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
newStreamingHash
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private doAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
newSignature: Signature,
|
||||
expectedNewHash: Hash,
|
||||
newStreamingHash: StreamingHash
|
||||
) {
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
const signatureAfter = this.sessions[sessionID]?.signatureAfter ?? {};
|
||||
|
||||
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
|
||||
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
|
||||
-1
|
||||
);
|
||||
|
||||
const sizeOfTxsSinceLastInbetweenSignature = transactions
|
||||
.slice(lastInbetweenSignatureIdx + 1)
|
||||
.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
if (sizeOfTxsSinceLastInbetweenSignature > 100 * 1024) {
|
||||
// console.log(
|
||||
// "Saving inbetween signature for tx ",
|
||||
// sessionID,
|
||||
// transactions.length - 1,
|
||||
// sizeOfTxsSinceLastInbetweenSignature
|
||||
// );
|
||||
signatureAfter[transactions.length - 1] = newSignature;
|
||||
}
|
||||
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
signatureAfter: signatureAfter,
|
||||
};
|
||||
|
||||
this._cachedContent = undefined;
|
||||
|
||||
if (this.listeners.size > 0) {
|
||||
const content = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: AnyCoValue) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
async expectedNewHashAfterAsync(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): Promise<{ expectedNewHash: Hash; newStreamingHash: StreamingHash }> {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
let before = performance.now();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
const after = performance.now();
|
||||
if (after - before > 1) {
|
||||
// console.log("Hashing blocked for", after - before);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
before = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting"
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
let transaction: Transaction;
|
||||
|
||||
if (privacy === "private") {
|
||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||
|
||||
if (!keySecret) {
|
||||
throw new Error(
|
||||
"Can't make transaction without read key secret"
|
||||
);
|
||||
}
|
||||
|
||||
const encrypted = encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
});
|
||||
|
||||
this._decryptionCache[encrypted] = stableStringify(changes);
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encrypted,
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes: stableStringify(changes),
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.currentSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
this.node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
const success = this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): AnyCoValue {
|
||||
if (this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this._cachedContent = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
if (this.header.meta && this.header.meta.type === "binary") {
|
||||
this._cachedContent = new BinaryCoStream(this);
|
||||
} else {
|
||||
this._cachedContent = new CoStream(this);
|
||||
}
|
||||
} else if (this.header.type === "static") {
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
let decrytedChanges =
|
||||
this._decryptionCache[tx.encryptedChanges];
|
||||
|
||||
if (!decrytedChanges) {
|
||||
decrytedChanges = decryptRawForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
this._decryptionCache[tx.encryptedChanges] =
|
||||
decrytedChanges;
|
||||
}
|
||||
|
||||
if (!decrytedChanges) {
|
||||
console.error(
|
||||
"Failed to decrypt transaction despite having key"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: decrytedChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
||||
allTransactions.sort(
|
||||
(a, b) =>
|
||||
a.madeAt - b.madeAt ||
|
||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||
a.txID.txIndex - b.txID.txIndex
|
||||
);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (readKeyCache.get(this)?.[keyID]) {
|
||||
return readKeyCache.get(this)?.[keyID];
|
||||
}
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(
|
||||
`${keyID}_for_${this.node.account.id}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const field of content.keys()) {
|
||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret =
|
||||
this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(field)!;
|
||||
|
||||
const secret = decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = secret;
|
||||
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
console.error(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getGroup(): Group {
|
||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
}
|
||||
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
|
||||
getTx(txID: TransactionID): Transaction | undefined {
|
||||
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
|
||||
}
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined
|
||||
): NewContentMessage[] | undefined {
|
||||
let currentPiece: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
new: {},
|
||||
};
|
||||
|
||||
const pieces = [currentPiece];
|
||||
|
||||
const sentState: CoValueKnownState["sessions"] = {
|
||||
...knownState?.sessions,
|
||||
};
|
||||
|
||||
let newTxsWereAdded = true;
|
||||
let pieceSize = 0;
|
||||
while (newTxsWereAdded) {
|
||||
newTxsWereAdded = false;
|
||||
|
||||
for (const [sessionID, log] of Object.entries(this.sessions) as [
|
||||
SessionID,
|
||||
SessionLog
|
||||
][]) {
|
||||
const nextKnownSignatureIdx = Object.keys(log.signatureAfter)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.find((idx) => idx >= (sentState[sessionID] ?? -1));
|
||||
|
||||
const txsToAdd = log.transactions.slice(
|
||||
sentState[sessionID] ?? 0,
|
||||
nextKnownSignatureIdx === undefined
|
||||
? undefined
|
||||
: nextKnownSignatureIdx + 1
|
||||
);
|
||||
|
||||
if (txsToAdd.length === 0) continue;
|
||||
|
||||
newTxsWereAdded = true;
|
||||
|
||||
const oldPieceSize = pieceSize;
|
||||
pieceSize += txsToAdd.reduce(
|
||||
(sum, tx) =>
|
||||
sum +
|
||||
(tx.privacy === "private"
|
||||
? tx.encryptedChanges.length
|
||||
: tx.changes.length),
|
||||
0
|
||||
);
|
||||
|
||||
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
|
||||
currentPiece = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: undefined,
|
||||
new: {},
|
||||
};
|
||||
pieces.push(currentPiece);
|
||||
pieceSize = pieceSize - oldPieceSize;
|
||||
}
|
||||
|
||||
let sessionEntry = currentPiece.new[sessionID];
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: sentState[sessionID] ?? 0,
|
||||
newTransactions: [],
|
||||
lastSignature: "WILL_BE_REPLACED" as Signature
|
||||
};
|
||||
currentPiece.new[sessionID] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(...txsToAdd);
|
||||
sessionEntry.lastSignature = nextKnownSignatureIdx === undefined
|
||||
? log.lastSignature!
|
||||
: log.signatureAfter[nextKnownSignatureIdx]!
|
||||
|
||||
sentState[sessionID] =
|
||||
(sentState[sessionID] || 0) + txsToAdd.length;
|
||||
}
|
||||
}
|
||||
|
||||
const piecesWithContent = pieces.filter(
|
||||
(piece) => Object.keys(piece.new).length > 0 || piece.header
|
||||
);
|
||||
|
||||
if (piecesWithContent.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return piecesWithContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "group"
|
||||
? expectGroupContent(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [this.header.ruleset.group]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user