Compare commits

...

76 Commits

Author SHA1 Message Date
Anselm
ccebd2447d Publish
- jazz-example-pets@0.0.19
 - jazz-example-todo@0.0.43
 - jazz-example-twit@0.0.6
 - cojson@0.4.6
 - cojson-simple-sync@0.4.6
 - cojson-storage-indexeddb@0.4.6
 - cojson-storage-sqlite@0.4.6
 - jazz-autosub@0.4.6
 - jazz-browser@0.4.6
 - jazz-browser-auth-local@0.4.6
 - jazz-browser-media-images@0.4.7
 - jazz-react@0.4.6
 - jazz-react-auth-local@0.4.6
2023-10-04 21:01:44 +01:00
Anselm
08dca75789 Address some circular deps in cojson typescript 2023-10-04 21:00:59 +01:00
Anselm
f1cd639a09 Add project list to todo example 2023-10-03 13:59:00 +01:00
Anselm Eickhoff
be18e4de14 Merge pull request #111 from gardencmp/autosub-api
Autosub API
2023-10-01 13:18:40 +01:00
Anselm
7e62c91d44 Publish
- jazz-example-pets@0.0.18
 - jazz-example-twit@0.0.5
 - jazz-browser-media-images@0.4.6
2023-10-01 12:27:10 +01:00
Anselm
b2d5a103b5 move image-blob-reduce types to proper deps 2023-10-01 12:26:53 +01:00
Anselm
4ee2cad39e Publish
- jazz-example-pets@0.0.17
 - jazz-example-todo@0.0.42
 - jazz-example-twit@0.0.4
 - cojson@0.4.5
 - cojson-simple-sync@0.4.5
 - cojson-storage-indexeddb@0.4.5
 - cojson-storage-sqlite@0.4.5
 - jazz-autosub@0.4.5
 - jazz-browser@0.4.5
 - jazz-browser-auth-local@0.4.5
 - jazz-browser-media-images@0.4.5
 - jazz-react@0.4.5
 - jazz-react-auth-local@0.4.5
2023-10-01 12:18:35 +01:00
Anselm
b7c8a0038b Rename syncedQueries -> autosub and clean up a lot of APIs 2023-10-01 12:16:56 +01:00
Anselm
8c27e8c379 doc fixes 2023-09-28 23:05:59 +01:00
Anselm Eickhoff
0133aa47ff Merge pull request #105 from gardencmp/example-twit
Twitter example
2023-09-28 11:51:38 +01:00
Anselm
5659c925a2 Change Twit html title 2023-09-28 11:44:44 +01:00
Anselm
27779ac792 Publish
- jazz-example-pets@0.0.16
 - jazz-example-todo@0.0.41
 - jazz-example-twit@0.0.3
 - cojson@0.4.1
 - cojson-simple-sync@0.4.1
 - cojson-storage-indexeddb@0.4.1
 - cojson-storage-sqlite@0.4.1
 - jazz-browser@0.4.1
 - jazz-browser-auth-local@0.4.1
 - jazz-browser-media-images@0.4.1
 - jazz-react@0.4.1
 - jazz-react-auth-local@0.4.1
2023-09-28 11:25:36 +01:00
Anselm
3f1bfa4629 Improve twit example 2023-09-28 11:25:09 +01:00
Anselm
15a693c3ed Simplify QueriedCoStream 2023-09-28 11:23:23 +01:00
Anselm
b1d620e145 Update docs 2023-09-28 11:23:06 +01:00
Anselm
478fbd0aa9 Bigger inputs on mobile 2023-09-27 22:21:19 +01:00
Anselm
ee906b7351 Add QR code to own profile 2023-09-27 22:13:55 +01:00
Anselm
dd15f21ccb Fix follow button 2023-09-27 21:51:20 +01:00
Anselm
d7cd5fda7c Actually deploy twit example 2023-09-27 21:43:07 +01:00
Anselm
174300b00f Deploy twit example 2023-09-27 21:39:30 +01:00
Anselm
b2c8d8c855 Publish
- jazz-example-pets@0.0.15
 - jazz-example-todo@0.0.40
 - jazz-example-twit@0.0.2
 - cojson@0.4.0
 - cojson-simple-sync@0.4.0
 - cojson-storage-indexeddb@0.4.0
 - cojson-storage-sqlite@0.4.0
 - jazz-browser@0.4.0
 - jazz-browser-auth-local@0.4.0
 - jazz-browser-media-images@0.4.0
 - jazz-react@0.4.0
 - jazz-react-auth-local@0.4.0
2023-09-27 21:37:56 +01:00
Anselm
2bad2b6bfe Update docs 2023-09-27 21:37:22 +01:00
Anselm
880d0ff855 Fix last lint issues 2023-09-27 21:37:04 +01:00
Anselm
e66cbee6cd Implement Twitter example 2023-09-27 21:27:49 +01:00
Anselm
03e470721e AAAAAAAAAA 2023-09-27 15:08:09 +01:00
Anselm
ecf73bcfa7 Basic account initialization, fixes #103 2023-09-26 18:07:14 +01:00
Anselm
2c3a500286 Add root to queried group 2023-09-26 17:47:31 +01:00
Anselm
8b83061cf4 Update docs 2023-09-26 17:42:48 +01:00
Anselm
e75c3207d6 Make Groups and Accounts behave like proper CoValues, fixes #101 2023-09-26 17:42:28 +01:00
Anselm
41d4b5ba0b Ability to add/remove the public as readers & writers #99 2023-09-26 11:19:39 +01:00
Anselm
21fa1b168b First sketch of twit example 2023-09-26 09:50:08 +01:00
Anselm
91e5e7f2ab v0.3.7 2023-09-24 20:25:23 +01:00
Anselm
e3f7e2f1bd Actually use delayOnerror 2023-09-24 20:24:58 +01:00
Anselm
084cf80c60 v0.3.6 2023-09-24 20:16:25 +01:00
Anselm
632e3bbb08 Add option for delay on error when handling peer messages 2023-09-24 20:15:10 +01:00
Anselm
17d17833b2 Publish
- jazz-example-pets@0.0.14
 - jazz-example-todo@0.0.39
 - cojson@0.3.5
 - cojson-simple-sync@0.3.7
 - cojson-storage-indexeddb@0.3.5
 - cojson-storage-sqlite@0.3.7
 - jazz-browser@0.3.5
 - jazz-browser-auth-local@0.3.5
 - jazz-browser-media-images@0.3.5
 - jazz-react@0.3.5
 - jazz-react-auth-local@0.3.5
 - jazz-react-media-images@0.3.5
2023-09-22 15:18:21 +01:00
Anselm
8e22bd9c1e Lint fix 2023-09-22 15:17:44 +01:00
Anselm
98213743f3 deploy bump 2023-09-22 15:15:09 +01:00
Anselm
bb855ed83d Publish
- jazz-example-pets@0.0.13
 - jazz-example-todo@0.0.38
 - cojson@0.3.4
 - cojson-simple-sync@0.3.6
 - cojson-storage-indexeddb@0.3.4
 - cojson-storage-sqlite@0.3.6
 - jazz-browser@0.3.4
 - jazz-browser-auth-local@0.3.4
 - jazz-browser-media-images@0.3.4
 - jazz-react@0.3.4
 - jazz-react-auth-local@0.3.4
 - jazz-react-media-images@0.3.4
2023-09-22 14:33:25 +01:00
Anselm
a8ef49e228 Small lint fixes 2023-09-22 14:32:41 +01:00
Anselm
e0ad32dbd2 Implement exponential falloff, fixes #69 2023-09-22 14:30:55 +01:00
Anselm
62bf769cad Publish
- cojson-simple-sync@0.3.5
 - cojson-storage-sqlite@0.3.5
2023-09-22 10:36:17 +01:00
Anselm
7488ff25b2 Missed one bit of JSON parsing to make more robust 2023-09-22 10:36:02 +01:00
Anselm
b69c9da983 Publish
- cojson-simple-sync@0.3.4
 - cojson-storage-sqlite@0.3.4
2023-09-22 10:25:25 +01:00
Anselm
d30fdef8aa More JSON.parse resiliency in cojson-storage-sqlite 2023-09-22 10:25:08 +01:00
Anselm
9c5a6b9833 Publish
- jazz-example-pets@0.0.12
 - jazz-example-todo@0.0.37
 - cojson@0.3.3
 - cojson-simple-sync@0.3.3
 - cojson-storage-indexeddb@0.3.3
 - cojson-storage-sqlite@0.3.3
 - jazz-browser@0.3.3
 - jazz-browser-auth-local@0.3.3
 - jazz-browser-media-images@0.3.3
 - jazz-react@0.3.3
 - jazz-react-auth-local@0.3.3
 - jazz-react-media-images@0.3.3
2023-09-22 10:09:04 +01:00
Anselm
d300d265c4 manually update cojson 2023-09-22 10:07:55 +01:00
Anselm
1d72ce587f Update version 2023-09-22 09:53:25 +01:00
Anselm
3fdb41dcb9 More resilience against invalid JSON 2023-09-22 09:51:07 +01:00
Anselm
f20de2f04a v0.3.1 2023-09-22 09:36:32 +01:00
Anselm
31b31f111b Shorter logs on failed transactions 2023-09-22 09:34:54 +01:00
Anselm Eickhoff
2ae9fb9778 Fix example comment 2023-09-21 18:00:28 +01:00
Anselm Eickhoff
cd0da0f6bf Merge pull request #94 from gardencmp/ergonomic-covalues
Implement queries
2023-09-21 17:31:31 +01:00
Anselm
cd9bfbb9fa Publish
- jazz-example-pets@0.0.11
 - jazz-example-todo@0.0.36
 - cojson@0.3.0
 - cojson-simple-sync@0.3.0
 - cojson-storage-indexeddb@0.3.0
 - cojson-storage-sqlite@0.3.0
 - jazz-browser@0.3.0
 - jazz-browser-auth-local@0.3.0
 - jazz-browser-media-images@0.3.0
 - jazz-react@0.3.0
 - jazz-react-auth-local@0.3.0
 - jazz-react-media-images@0.3.0
2023-09-21 17:29:23 +01:00
Anselm
ed0428bf97 Pre-release fixes 2023-09-21 17:12:10 +01:00
Anselm
c038a02051 Publish
- jazz-example-pets@0.0.10
 - jazz-example-todo@0.0.35
 - cojson@0.3.0-alpha.0
 - cojson-simple-sync@0.3.0-alpha.0
 - cojson-storage-indexeddb@0.3.0-alpha.0
 - cojson-storage-sqlite@0.3.0-alpha.0
 - jazz-browser@0.3.0-alpha.0
 - jazz-browser-auth-local@0.3.0-alpha.0
 - jazz-browser-media-images@0.3.0-alpha.0
 - jazz-react@0.3.0-alpha.0
 - jazz-react-auth-local@0.3.0-alpha.0
 - jazz-react-media-images@0.3.0-alpha.0
2023-09-21 17:07:06 +01:00
Anselm
31abcfeef4 Walkthrough and doc improvements 2023-09-21 17:02:34 +01:00
Anselm
5f32d9ccf5 Support external routers & more doc improvements 2023-09-21 16:35:13 +01:00
Anselm
0510600104 Lots of doc improvements, cleaner Queried's 2023-09-20 17:48:07 +01:00
Anselm
7f30fbf3c5 move stuff to "co" property in queries 2023-09-20 11:52:57 +01:00
Anselm
3d56260ca4 Lots more consistency and API improvements 2023-09-19 13:17:31 +01:00
Anselm
1137775da9 Publish
- jazz-example-pets@0.0.9
 - jazz-example-todo@0.0.34
 - cojson@0.2.3
 - cojson-simple-sync@0.2.6
 - cojson-storage-sqlite@0.2.6
 - jazz-browser@0.2.5
 - jazz-browser-auth-local@0.2.5
 - jazz-browser-media-images@0.2.5
 - jazz-react@0.2.5
 - jazz-react-auth-local@0.2.5
 - jazz-react-media-images@0.2.5
 - jazz-storage-indexeddb@0.2.5
2023-09-15 16:40:47 +01:00
Anselm
3951fdc938 Implement queries & use in examples 2023-09-15 16:36:48 +01:00
Anselm
5779e357dd Allow CoValues directly where ids would be expected 2023-09-13 17:48:04 +01:00
Anselm
2842d80f26 Improve docs for new packages 2023-09-12 16:55:58 +01:00
Anselm Eickhoff
96387d8023 Merge pull request #89 from gardencmp:stream-txs
Stream transactions
2023-09-12 16:26:09 +01:00
Anselm
6720c19233 Publish
- jazz-example-pets@0.0.8
 - jazz-example-todo@0.0.33
 - cojson-simple-sync@0.2.5
 - cojson-storage-sqlite@0.2.5
 - jazz-browser@0.2.4
 - jazz-browser-auth-local@0.2.4
 - jazz-browser-media-images@0.2.4
 - jazz-react@0.2.4
 - jazz-react-auth-local@0.2.4
 - jazz-react-media-images@0.2.4
 - jazz-storage-indexeddb@0.2.4
2023-09-12 16:17:09 +01:00
Anselm
ef732b4700 Implement saving signatures and streaming txs for SQLite 2023-09-12 16:16:40 +01:00
Anselm
ee7e3ee5a7 Publish
- jazz-example-pets@0.0.7
 - jazz-example-todo@0.0.32
 - cojson@0.2.2
 - cojson-simple-sync@0.2.4
 - cojson-storage-sqlite@0.2.4
 - jazz-browser@0.2.3
 - jazz-browser-auth-local@0.2.3
 - jazz-browser-media-images@0.2.3
 - jazz-react@0.2.3
 - jazz-react-auth-local@0.2.3
 - jazz-react-media-images@0.2.3
 - jazz-storage-indexeddb@0.2.3
2023-09-12 15:26:43 +01:00
Anselm
ceeed88fa5 Less verbose error output 2023-09-12 15:26:22 +01:00
Anselm
79353a1d97 Publish
- cojson-simple-sync@0.2.3
 - cojson-storage-sqlite@0.2.3
2023-09-12 15:22:01 +01:00
Anselm
7fdc42c62f Fix migration 2023-09-12 15:21:45 +01:00
Anselm
3a2e854a88 Publish
- cojson-simple-sync@0.2.2
 - cojson-storage-sqlite@0.2.2
2023-09-12 15:19:12 +01:00
Anselm
661a2d023a Fixes #90 for SQLite 2023-09-12 15:18:53 +01:00
Anselm
6ef5b6b2ab Publish
- jazz-example-pets@0.0.6
 - jazz-example-todo@0.0.31
 - jazz-browser@0.2.2
 - jazz-browser-auth-local@0.2.2
 - jazz-browser-media-images@0.2.2
 - jazz-react@0.2.2
 - jazz-react-auth-local@0.2.2
 - jazz-react-media-images@0.2.2
 - jazz-storage-indexeddb@0.2.2
2023-09-12 14:56:31 +01:00
Anselm
1384ebed84 Fix migration 2023-09-12 14:55:57 +01:00
155 changed files with 25910 additions and 5187 deletions

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
example: ["todo", "pets"]
example: ["todo", "pets", "twit"]
steps:
- uses: actions/checkout@v3
@@ -58,7 +58,7 @@ jobs:
needs: build
strategy:
matrix:
example: ["todo", "pets"]
example: ["todo", "pets", "twit"]
steps:
- uses: actions/checkout@v3

16021
DOCS.md

File diff suppressed because it is too large Load Diff

126
README.md
View File

@@ -1,82 +1,116 @@
# Jazz - instant sync
Homepage: [jazz.tools](https://jazz.tools) — [Discord](https://discord.gg/utDMjHYg42)
<sub>Homepage: [jazz.tools](https://jazz.tools) &mdash; Docs: [DOCS.md](./DOCS.md) &mdash; Community & support: [Discord](https://discord.gg/utDMjHYg42) &mdash; Updates: [Twitter](https://twitter.com/jazz_tools) & [Email](https://gcmp.io/news)</sub>
Jazz is an open-source toolkit for *secure telepathic data.*
**Jazz is an open-source toolkit for building apps with *secure sync.***
- Ship faster & simplify your frontend and backend
- Get cross-device sync, real-time collaboration & offline support for free
Quickly build and ship apps with:
[Jazz Global Mesh](https://jazz.tools/mesh) is serverless sync & storage for Jazz apps. (currently free!)
- **Cross-device sync**
- **Collaborative features** (incl. real-time multiplayer)
- **Instantly reacting UIs**
- Local-first storage & offline support
- File upload and real-time media streaming
# What is *secure sync*?
**Sync** means that, *instead of making API requests*, you:
- **Read and write data as if it was local** &mdash; from anywhere in your app.
- **Always have data synced to wherever it's needed, instantly:** to other devices of the same user, to other users, to your backend, to your local machine for debugging, etc.
**Secure** means that, *instead of relying on your API or DB for access control*, you:
- **Set fine-grained, role-based permissions in `Group`s** that are **synced along with your data**.
- **Permissions *verifiably enforced* everywhere,** using encryption & signatures under the hood.
- **Change roles dynamically** for evolving teams, expiring invite links and more.
# What's special about Jazz?
Compared to other libraries and frameworks for local-first, sync-based or real-time apps, these are some of the things that make Jazz unique:
- **Jazz is a *batteries-included,* vertically integrated toolkit,** offering everything you need to build an app, including auth, permissions, data model, sync, conflict resolution, blob storage, file uploads, real-time media streaming and more.
- **Jazz has a *small API surface* of only a few abstractions to learn,** which combine in powerful ways to implement a broad set of features.
- **Jazz *granularly* loads and caches *only the data that is needed*,** combining *local-first* instant UI reactivity and offline support with the on-demand data efficiency of conventional APIs
- **Jazz supports end-to-end encryption, but doesn't require it,** allowing you to either manage your user's secret keys for them (based on existing auth flows) or letting your users
- **Jazz is based on CoJSON, a soon-to-be *open standard,*** which means that there will be a whole ecosystem of compatible libraries and frameworks in a variety of environments &mdash; and it will be easy to achieve (secure) interop between Jazz/CoJSON-based apps and services.
# Jazz Global Mesh
Jazz is open source and you can run your own sync & storage server, but to really provide you with everything you need, we're also running
**[Jazz Global Mesh](https://jazz.tools/mesh)**, a globally distributed mesh of servers optimized for:
- **Ultra-low-latency sync** (with geo-aware edge caching and optimal routing)
- **Low-cost, reliable storage**
**Jazz Global Mesh is free for small volumes of data** and it's the **default syncing peer,** so you can **start building multi-user Jazz apps with persistent data in minutes,** using only frontend code!
## What is Secure Telepathic Data?
# Getting started
**Telepathic** means:
## Example App Walkthrough
- **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 &mdash; or to other users (coming soon: to your backend, workers, etc.)
**For now the best tutorial is the walkthrough of the [Todo List Example App](#todo-list).**
**Secure** means:
## General Scenarios
- **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.
### Building a new, entirely sync-based React app
## How to build an app with Jazz?
1. Define your data model with [cojson Collaborative Values (CoValues)](./DOCS.md#covalue).
2. Implement permission logic using [cojson Groups](./DOCS.md#group).
3. Build a user interface with [jazz-react](./DOCS.md#jazz-react) and [auto-sub](./DOCS.md#useautosubid).
### Building a new app, completely with Jazz
### Gradually adding sync to an existing React app
It's still a bit early, but these are the rough steps:
Gradually migrate app features to use sync:
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).
1. Define data model for small aspect of your app with [cojson Collaborative Values (CoValues)](./DOCS.md#covalue).
- Schema adapters/importers for Prisma/Drizzle/PostgreSQL introspection coming soon.
2. Map existing permission logic with [cojson Groups](./DOCS.md#group) & integrate existing auth.
- Auth integrations coming soon.
3. Replace some of the React state and API requests in your UI with [jazz-react](./DOCS.md#jazz-react) and [auto-sub](./DOCS.md#useautosubid).
The best example is currently the [Todo List app](#example-app-todo-list).
# Example Apps
### Gradually adding Jazz to an existing app
## Todo List
Coming soon: Jazz will support gradual adoption by integrating with your existing UI, auth and database.
**A simple collaborative todo list app.**
## Example App: Todo List
Live version: https://example-todo.jazz.tools
The best example of Jazz is currently the Todo List app.
Source code & walkthrough: [`./examples/todo`](./examples/todo)
- Live version: https://example-todo.jazz.tools
- Source code: [`./examples/todo`](./examples/todo). See the README there for a walk-through and running instructions.
Demonstrates:
- Defining a data model with `CoMap`s and `CoList`s
- Creating data and setting permissions with `Group`s
- Fetching, rendering & editing data from nested `CoValue`s with reactive synced queries
# Documentation
Note: Since it's early days, this is the only source of documentation so far.
## Rate-My-Pet
If you want to build something with Jazz, [join the Jazz Discord](https://discord.gg/utDMjHYg42) for encouragement and help!
**A simple social polling app.**
## Overview: Main Packages
Live version: https://example-pets.jazz.tools
**`cojson`** → [DOCS](./DOCS.md#cojson)
Source code (walkthrough coming soon): [`./examples/pets`](./examples/pets)
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of secure telepathic data.
Demonstrates:
- Implementing per-account data streams (reactions) with `CoStream`s
- Implementing image upload and progressive image streaming using helpers from `jazz-react-media-images` (on top of CoJSON's `BinaryCoStreams` & `ImageDefinition` convention)
**`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.
# Documentation & API Reference
### Supporting packages
<small>
For now, docs are hosted in a single well-structured markdown file: [`./DOCS.md`](./DOCS.md).
**`cojson-simple-sync`**
- [Package Overview](./DOCS.md#overview)
- [`jazz-react` API](./DOCS.md#jazz-react)
- [`cojson` API](./DOCS.md#cojson)
- [`jazz-browser-media-images` API](./DOCS.md#jazz-browser-media-images)
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)
In the future we'll build a dedicated docs page on the Jazz homepage.
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.
</small>
Copyright 2023 &mdash; Garden Computing, Inc.

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/0_main.tsx"></script>
<script type="module" src="/src/2_main.tsx"></script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.5",
"version": "0.0.19",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,13 +16,15 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.2.1",
"jazz-react-auth-local": "^0.2.1",
"jazz-react-media-images": "^0.2.1",
"jazz-browser-media-images": "^0.4.7",
"jazz-react": "^0.4.6",
"jazz-react-auth-local": "^0.4.6",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"uniqolor": "^1.1.0"

View File

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

View File

@@ -1,4 +1,11 @@
import { CoMap, CoID, CoStream, Media } from "cojson";
import {
AccountMigration,
CoList,
CoMap,
CoStream,
Media,
Profile,
} from "cojson";
/** Walkthrough: Defining the data model with CoJSON
*
@@ -9,8 +16,8 @@ import { CoMap, CoID, CoStream, Media } from "cojson";
export type PetPost = CoMap<{
name: string;
image: CoID<Media.ImageDefinition>;
reactions: CoID<PetReactions>;
image: Media.ImageDefinition["id"];
reactions: PetReactions["id"];
}>;
export const REACTION_TYPES = [
@@ -26,4 +33,20 @@ export type ReactionType = (typeof REACTION_TYPES)[number];
export type PetReactions = CoStream<ReactionType>;
export type ListOfPosts = CoList<PetPost["id"]>;
export type PetAccountRoot = CoMap<{
posts: ListOfPosts["id"];
}>;
export const migration: AccountMigration<Profile, PetAccountRoot> = (account) => {
if (!account.get("root")) {
const root = account.createMap<PetAccountRoot>({
posts: account.createList<ListOfPosts>().id,
});
account.set("root", root.id);
console.log("Created root", root.id);
}
};
/** Walkthrough: Continue with ./2_App.tsx */

View File

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

View File

@@ -0,0 +1,115 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Link, RouterProvider, createHashRouter } from "react-router-dom";
import "./index.css";
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
import { LocalAuth } from "jazz-react-auth-local";
import {
Button,
ThemeProvider,
TitleAndLogo,
} from "./basicComponents/index.ts";
import { PrettyAuthUI } from "./components/Auth.tsx";
import { NewPetPostForm } from "./3_NewPetPostForm.tsx";
import { RatePetPostUI } from "./4_RatePetPostUI.tsx";
import { PetAccountRoot, migration } from "./1_types.ts";
import { AccountMigration, Profile } from "cojson";
/** 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} />
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
<WithJazz auth={auth} migration={migration as AccountMigration}>
<App />
</WithJazz>
</div>
</ThemeProvider>
</React.StrictMode>
);
/** 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() {
const { logOut } = useJazz();
const router = createHashRouter([
{
path: "/",
element: <PostOverview />,
},
{
path: "/new",
element: <NewPetPostForm />,
},
{
path: "/pet/:petPostId",
element: <RatePetPostUI />,
},
{
path: "/invite/*",
element: <p>Accepting invite...</p>,
},
]);
useAcceptInvite((petPostID) => router.navigate("/pet/" + petPostID));
return (
<>
<RouterProvider router={router} />
<Button
onClick={() => router.navigate("/").then(logOut)}
variant="outline"
>
Log Out
</Button>
</>
);
}
export function PostOverview() {
const { me } = useJazz<Profile, PetAccountRoot>();
const myPosts = me.root?.posts;
return (
<>
{myPosts?.length ? (
<>
<h1>My posts</h1>
{myPosts.map(
(post) =>
post && (
<Link key={post.id} to={"/pet/" + post.id}>
{post.name}
</Link>
)
)}
</>
) : undefined}
<Link to="/new">New post</Link>
</>
);
}

View File

@@ -1,103 +0,0 @@
import { ChangeEvent, useCallback, useState } from "react";
import { CoID } from "cojson";
import { useJazz, useTelepathicState } 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
*/
export function CreatePetPostForm({
onCreate,
}: {
onCreate: (id: CoID<PetPost>) => void;
}) {
const { localNode } = useJazz();
const [newPostId, setNewPostId] = useState<CoID<PetPost> | undefined>(
undefined
);
const newPetPost = useTelepathicState(newPostId);
const onChangeName = useCallback(
(name: string) => {
let petPost = newPetPost;
if (!petPost) {
const petPostGroup = localNode.createGroup();
petPost = petPostGroup.createMap<PetPost>();
const petReactions = petPostGroup.createStream<PetReactions>();
petPost = petPost.edit((petPost) => {
petPost.set("reactions", petReactions.id);
});
setNewPostId(petPost.id);
}
petPost.edit((petPost) => {
petPost.set("name", name);
});
},
[localNode, newPetPost]
);
const onImageSelected = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
if (!newPetPost || !event.target.files) return;
const imageDefinition = await createImage(
event.target.files[0],
newPetPost.group
);
newPetPost.edit((petPost) => {
petPost.set("image", imageDefinition.id);
});
},
[newPetPost]
);
const petImage = useLoadImage(newPetPost?.get("image"));
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?.get("name") || ""}
/>
{petImage ? (
<img
className="w-80 max-w-full rounded"
src={petImage.highestResSrc || petImage.placeholderDataURL}
/>
) : (
<Input
type="file"
disabled={!newPetPost?.get("name")}
onChange={onImageSelected}
/>
)}
{newPetPost?.get("name") && newPetPost?.get("image") && (
<Button
onClick={() => {
onCreate(newPetPost.id);
}}
>
Submit Post
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { ChangeEvent, useCallback, useState } from "react";
import { useNavigate } from "react-router";
import { CoID, CoMap, Media, Profile } from "cojson";
import { useAutoSub, useJazz } from "jazz-react";
import { BrowserImage, createImage } from "jazz-browser-media-images";
import { PetAccountRoot, PetPost, PetReactions } from "./1_types";
import { Input, Button } from "./basicComponents";
/** Walkthrough: TODO
*/
type PartialPetPost = CoMap<{
name: string;
image?: Media.ImageDefinition["id"];
reactions: PetReactions["id"];
}>;
export function NewPetPostForm() {
const { me } = useJazz<Profile, PetAccountRoot>();
const navigate = useNavigate();
const [newPostId, setNewPostId] = useState<
CoID<PartialPetPost> | undefined
>(undefined);
const newPetPost = useAutoSub(newPostId);
const onChangeName = useCallback(
(name: string) => {
if (newPetPost) {
newPetPost.set({ name });
} else {
const petPostGroup = me.createGroup();
const petPost = petPostGroup.createMap<PartialPetPost>({
name,
reactions: petPostGroup.createStream<PetReactions>().id,
});
setNewPostId(petPost.id);
}
},
[me, newPetPost]
);
const onImageSelected = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
if (!newPetPost || !event.target.files) return;
const image = await createImage(
event.target.files[0],
newPetPost.meta.group
);
newPetPost.set({ image: image.id });
},
[newPetPost]
);
const onSubmit = useCallback(() => {
if (!newPetPost) return;
const myPosts = me.root?.posts;
if (!myPosts) {
throw new Error("No posts list found");
}
myPosts.append(newPetPost.id as PetPost["id"]);
navigate("/pet/" + newPetPost.id);
}, [me.root?.posts, newPetPost, navigate]);
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 || ""}
/>
{newPetPost?.image ? (
<img
className="w-80 max-w-full rounded"
src={
newPetPost?.image.as(BrowserImage)
?.highestResSrcOrPlaceholder
}
/>
) : (
<Input
type="file"
disabled={!newPetPost?.name}
onChange={onImageSelected}
/>
)}
{newPetPost?.name && newPetPost?.image && (
<Button onClick={onSubmit}>Submit Post</Button>
)}
</div>
);
}

View File

@@ -1,12 +1,13 @@
import { AccountID, CoID } from "cojson";
import { useTelepathicState } from "jazz-react";
import { useParams } from "react-router";
import { CoID } from "cojson";
import { PetPost, PetReactions, ReactionType, REACTION_TYPES } from "./1_types";
import { PetPost, ReactionType, REACTION_TYPES, PetReactions } from "./1_types";
import { ShareButton } from "./components/ShareButton";
import { NameBadge } from "./components/NameBadge";
import { Button } from "./basicComponents";
import { useLoadImage } from "jazz-react-media-images";
import { Button, Skeleton } from "./basicComponents";
import { BrowserImage } from "jazz-browser-media-images";
import uniqolor from "uniqolor";
import { Resolved, useAutoSub } from "jazz-react";
/** Walkthrough: TODO
*/
@@ -20,22 +21,25 @@ const reactionEmojiMap: { [reaction in ReactionType]: string } = {
chonkers: "🐘",
};
export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
const petPost = useTelepathicState(petPostID);
const petReactions = useTelepathicState(petPost?.get("reactions"));
const petImage = useLoadImage(petPost?.get("image"));
export function RatePetPostUI() {
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
const petPost = useAutoSub(petPostID);
return (
<div className="flex flex-col gap-8">
<div className="flex justify-between">
<h1 className="text-3xl font-bold">{petPost?.get("name")}</h1>
<h1 className="text-3xl font-bold">{petPost?.name}</h1>
<ShareButton petPost={petPost} />
</div>
{petImage && (
{petPost?.image && (
<img
className="w-80 max-w-full rounded"
src={petImage.highestResSrc || petImage.placeholderDataURL}
src={
petPost.image.as(BrowserImage)
?.highestResSrcOrPlaceholder
}
/>
)}
@@ -44,14 +48,12 @@ export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
<Button
key={reactionType}
variant={
petReactions?.getLastItemFromMe() === reactionType
petPost?.reactions?.me?.last === reactionType
? "default"
: "outline"
}
onClick={() => {
petReactions?.edit((reactions) => {
reactions.push(reactionType);
});
petPost?.reactions?.push(reactionType);
}}
title={`React with ${reactionType}`}
className="text-2xl px-2"
@@ -61,26 +63,28 @@ export function RatePetPostUI({ petPostID }: { petPostID: CoID<PetPost> }) {
))}
</div>
{petPost?.group.myRole() === "admin" && petReactions && (
<ReactionOverview petReactions={petReactions} />
{petPost?.meta.group.myRole() === "admin" && petPost.reactions && (
<ReactionOverview petReactions={petPost.reactions} />
)}
</div>
);
}
function ReactionOverview({ petReactions }: { petReactions: PetReactions }) {
function ReactionOverview({
petReactions,
}: {
petReactions: Resolved<PetReactions>;
}) {
return (
<div>
<h2>Reactions</h2>
<div className="flex flex-col gap-1">
{REACTION_TYPES.map((reactionType) => {
const accountsWithThisReaction = Object.entries(
petReactions.getLastItemsPerAccount()
).flatMap(([accountID, reaction]) =>
reaction === reactionType ? [accountID] : []
);
const reactionsOfThisType = petReactions.perAccount
.map(([, reaction]) => reaction)
.filter(({ last }) => last === reactionType);
if (accountsWithThisReaction.length === 0) return null;
if (reactionsOfThisType.length === 0) return null;
return (
<div
@@ -88,12 +92,22 @@ function ReactionOverview({ petReactions }: { petReactions: PetReactions }) {
key={reactionType}
>
{reactionEmojiMap[reactionType]}{" "}
{accountsWithThisReaction.map((accountID) => (
<NameBadge
key={accountID}
accountID={accountID as AccountID}
/>
))}
{reactionsOfThisType.map((reaction, idx) =>
reaction.by?.profile?.name ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={uniqueColoring(reaction.by.id)}
key={reaction.by.id}
>
{reaction.by.profile.name}
</span>
) : (
<Skeleton
className="mt-1 w-[50px] h-[1em] rounded-full"
key={idx}
/>
)
)}
</div>
);
})}
@@ -101,3 +115,12 @@ function ReactionOverview({ petReactions }: { petReactions: PetReactions }) {
</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,
};
}

View File

@@ -1,46 +0,0 @@
import { AccountID } from "cojson";
import { useProfile } from "jazz-react";
import { Skeleton } from "@/basicComponents";
import uniqolor from "uniqolor";
/** Walkthrough: Getting user profiles in `<NameBadge/>`
*
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
* useTelepathicState on an account's profile.
*
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
* additional properties).
*
* In our case, we just display the profile name (which is set by the LocalAuth
* provider when we first create an account).
*/
export function NameBadge({ accountID }: { accountID?: AccountID }) {
const profile = useProfile(accountID);
return accountID && profile?.get("name") ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={randomUserColor(accountID)}
>
{profile.get("name")}
</span>
) : (
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
);
}
function randomUserColor(accountID: AccountID) {
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
return {
color: theme == "light" ? darkColor : brightColor,
background: theme == "light" ? brightColor : darkColor,
};
}

View File

@@ -2,17 +2,17 @@ import { useState } from "react";
import { PetPost } from "../1_types";
import { createInviteLink } from "jazz-react";
import { Resolved, createInviteLink } from "jazz-react";
import QRCode from "qrcode";
import { useToast, Button } from "../basicComponents";
export function ShareButton({ petPost }: { petPost?: PetPost }) {
export function ShareButton({ petPost }: { petPost?: Resolved<PetPost> }) {
const [existingInviteLink, setExistingInviteLink] = useState<string>();
const { toast } = useToast();
return (
petPost?.group.myRole() === "admin" && (
petPost?.meta.group.myRole() === "admin" && (
<Button
size="sm"
className="py-0"

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { CoID, LocalNode, CoValueImpl } from "cojson";
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
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;
}

View File

@@ -27,29 +27,28 @@ 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
- [`src/basicComponents`](./src/basicComponents): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
- [`src/1_types.ts`](./src/1_types.ts),
[`src/2_main.tsx`](./src/2_main.tsx),
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): 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)
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.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)
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
This is the whole Todo List app!
@@ -62,4 +61,4 @@ If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or
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).
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/2_main.tsx](./src/2_main.tsx).

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/0_main.tsx"></script>
<script type="module" src="/src/2_main.tsx"></script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.30",
"version": "0.0.43",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,12 +16,14 @@
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.2.1",
"jazz-react-auth-local": "^0.2.1",
"jazz-react": "^0.4.6",
"jazz-react-auth-local": "^0.4.6",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"uniqolor": "^1.1.0"

View File

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

View File

@@ -1,28 +1,42 @@
import { CoMap, CoList, CoID } from "cojson";
import { CoMap, CoList, AccountMigration, Profile } 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:
* CoMap values and CoLists items can contain:
* - arbitrary immutable JSON
* - references to other CoValues by their CoID
* - CoIDs are strings that look like `co_zXPuWmH1D1cKdMpDW6CMzWb3LpY`
* - In TypeScript, CoIDs take a generic parameter for the type of the
* referenced CoValue, e.g. `CoID<Task>` - to make the references precise
**/
/** An individual task which collaborators can tick or rename */
export type Task = CoMap<{ done: boolean; text: string; }>;
/** A collaborative, ordered list of task references */
export type ListOfTasks = CoList<CoID<Task>>;
export type ListOfTasks = CoList<Task["id"]>;
/** Our top level object: a project with a title, referencing a list of tasks */
export type TodoProject = CoMap<{
title: string;
tasks: CoID<ListOfTasks>;
/** A collaborative, ordered list of tasks */
tasks: ListOfTasks["id"];
}>;
/** Walkthrough: Continue with ./2_App.tsx */
export type ListOfProjects = CoList<TodoProject["id"]>;
export type TodoAccountRoot = CoMap<{
projects: ListOfProjects["id"];
}>;
export const migration: AccountMigration<Profile, TodoAccountRoot> = (account) => {
if (!account.get("root")) {
account.set(
"root",
account.createMap<TodoAccountRoot>({
projects: account.createList<ListOfProjects>().id,
}).id
);
}
}
/** Walkthrough: Continue with ./2_main.tsx */

View File

@@ -1,78 +0,0 @@
import { useCallback } from "react";
import { useJazz } from "jazz-react";
import { TodoProject, ListOfTasks } from "./1_types";
import { SubmittableInput, Button } from "./basicComponents";
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
import { TodoTable } from "./3_TodoTable";
/** Walkthrough: Creating todo projects & routing in `<App/>`
*
* <App> is the main app component, handling client-side routing based
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
* - which can also contain invite links.
*/
export default function App() {
// A `LocalNode` represents a local view of loaded & created CoValues.
// It is associated with a current user account, which will determine
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
const { localNode, logOut } = useJazz();
// This sets up routing and accepting invites, skip for now
const [currentProjectId, navigateToProjectId] =
useSimpleHashRouterThatAcceptsInvites<TodoProject>(localNode);
const createProject = useCallback(
(title: string) => {
if (!title) return;
// To create a new todo project, we first create a `Group`,
// which is a scope for defining access rights (reader/writer/admin)
// of its members, which will apply to all CoValues owned by that group.
const projectGroup = localNode.createGroup();
// Then we create an empty todo project and list of tasks within that group.
const project = projectGroup.createMap<TodoProject>();
const tasks = projectGroup.createList<ListOfTasks>();
// We edit the todo project to initialise it.
// Inside the `.edit` callback we can mutate a CoValue
project.edit((project) => {
project.set("title", title);
project.set("tasks", tasks.id);
});
navigateToProjectId(project.id);
},
[localNode, navigateToProjectId]
);
return (
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
{currentProjectId ? (
<TodoTable projectId={currentProjectId} />
) : (
<SubmittableInput
onSubmit={createProject}
label="Create New Project"
placeholder="New project title"
/>
)}
<Button
onClick={() => {
navigateToProjectId(undefined);
logOut();
}}
variant="outline"
>
Log Out
</Button>
</div>
);
}
/** Walkthrough: continue with ./3_TodoTable.tsx */

View File

@@ -0,0 +1,121 @@
import React from "react";
import ReactDOM from "react-dom/client";
import {
RouterProvider,
createHashRouter,
useNavigate,
} from "react-router-dom";
import "./index.css";
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
import { LocalAuth } from "jazz-react-auth-local";
import {
Button,
ThemeProvider,
TitleAndLogo,
} from "./basicComponents/index.ts";
import { PrettyAuthUI } from "./components/Auth.tsx";
import { NewProjectForm } from "./3_NewProjectForm.tsx";
import { ProjectTodoTable } from "./4_ProjectTodoTable.tsx";
import { TodoAccountRoot, migration } from "./1_types.ts";
import { AccountMigration, Profile } from "cojson";
/**
* 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} />
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
<WithJazz auth={auth} migration={migration as AccountMigration}>
<App />
</WithJazz>
</div>
</ThemeProvider>
</React.StrictMode>
);
/**
* 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.
*/
function App() {
// logOut logs out the AuthProvider passed to `<WithJazz/>` above.
const { logOut } = useJazz();
const router = createHashRouter([
{
path: "/",
element: <HomeScreen />,
},
{
path: "/project/:projectId",
element: <ProjectTodoTable />,
},
{
path: "/invite/*",
element: <p>Accepting invite...</p>,
},
]);
// `useAcceptInvite()` is a hook that accepts an invite link from the URL hash,
// and on success calls our callback where we navigate to the project that we were just invited to.
useAcceptInvite((projectID) => router.navigate("/project/" + projectID));
return (
<>
<RouterProvider router={router} />
<Button
onClick={() => router.navigate("/").then(logOut)}
variant="outline"
>
Log Out
</Button>
</>
);
}
export function HomeScreen() {
const { me } = useJazz<Profile, TodoAccountRoot>();
const navigate = useNavigate();
return (
<>
{me.root?.projects?.length ? <h1>My Projects</h1> : null}
{me.root?.projects?.map((project) => {
return (
<Button
key={project?.id}
onClick={() => navigate("/project/" + project?.id)}
variant="ghost"
>
{project?.title}
</Button>
);
})}
<NewProjectForm />
</>
);
}
/** Walkthrough: Continue with ./3_NewProjectForm.tsx */

View File

@@ -0,0 +1,49 @@
import { useCallback } from "react";
import { useJazz } from "jazz-react";
import { ListOfTasks, TodoAccountRoot, TodoProject } from "./1_types";
import { SubmittableInput } from "./basicComponents";
import { useNavigate } from "react-router";
import { Profile } from "cojson";
export function NewProjectForm() {
// `me` represents the current user account, which will determine
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
const { me } = useJazz<Profile, TodoAccountRoot>();
const navigate = useNavigate();
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 = me.createGroup();
// Then we create an empty todo project within that group
const project = projectGroup.createMap<TodoProject>({
title,
tasks: projectGroup.createList<ListOfTasks>().id,
});
me.root?.projects?.append(project.id);
navigate("/project/" + project.id);
},
[me, navigate]
);
return (
<SubmittableInput
onSubmit={createProject}
label="Create New Project"
placeholder="New project title"
/>
);
}
/** Walkthrough: continue with ./4_ProjectTodoTable.tsx */

View File

@@ -1,162 +0,0 @@
import { useCallback } from "react";
import { CoID } from "cojson";
import { useTelepathicState } from "jazz-react";
import { TodoProject, Task } from "./1_types";
import {
Checkbox,
SubmittableInput,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./basicComponents";
import { InviteButton } from "./components/InviteButton";
import { NameBadge } from "./components/NameBadge";
/** Walkthrough: Reactively rendering a todo project as a table,
* adding and editing tasks
*
* Here in `<TodoTable/>`, we use `useTelepathicData()` for the first time,
* in this case to load the CoValue for our `TodoProject` as well as
* the `ListOfTasks` referenced in it.
*/
export function TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
// `useTelepathicData()` reactively subscribes to updates to a CoValue's
// content - whether we create edits locally, load persisted data, or receive
// sync updates from other devices or participants!
const project = useTelepathicState(projectId);
const projectTasks = useTelepathicState(project?.get("tasks"));
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
// for a new task (in the same group as the list of tasks/the project), and then
// adding it as an item to the project's list of tasks.
const createTask = useCallback(
(text: string) => {
if (!projectTasks || !text) return;
const task = projectTasks.group.createMap<Task>();
task.edit((task) => {
task.set("text", text);
task.set("done", false);
});
projectTasks.edit((projectTasks) => {
projectTasks.push(task.id);
});
},
[projectTasks]
);
return (
<div className="max-w-full w-4xl">
<div className="flex justify-between items-center gap-4 mb-4">
<h1>
{
// This is how we can access properties from the project,
// accounting for the fact that it might not be loaded yet
project?.get("title") ? (
<>
{project.get("title")}{" "}
<span className="text-sm">({project.id})</span>
</>
) : (
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
)
}
</h1>
<InviteButton list={project} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">Done</TableHead>
<TableHead>Task</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
// Here, we iterate over the items of our `ListOfTasks`
// and render a `<TaskRow>` for each.
projectTasks?.map((taskId: CoID<Task>) => (
<TaskRow key={taskId} taskId={taskId} />
))
}
<NewTaskInputRow
createTask={createTask}
disabled={!project}
/>
</TableBody>
</Table>
</div>
);
}
export function TaskRow({ taskId }: { taskId: CoID<Task> }) {
// `<TaskRow/>` uses `useTelepathicState()` as well, to granularly load and
// subscribe to changes for that particular task.
const task = useTelepathicState(taskId);
return (
<TableRow>
<TableCell>
<Checkbox
className="mt-1"
checked={task?.get("done")}
onCheckedChange={(checked) => {
// (the only thing we let the user change is the "done" status)
task?.edit((task) => {
task.set("done", !!checked);
});
}}
/>
</TableCell>
<TableCell>
<div className="flex flex-row justify-between items-center gap-2">
<span className={task?.get("done") ? "line-through" : ""}>
{task?.get("text") || (
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
)}
</span>
{/* We also use a `<NameBadge/>` helper component to render the name
of the author of the task. We get the author by using the collaboration
feature `whoEdited(key)` on our `Task` CoMap, which returns the accountID
of the last account that changed a given key in the CoMap. */}
<NameBadge accountID={task?.whoEdited("text")} />
</div>
</TableCell>
</TableRow>
);
}
function NewTaskInputRow({
createTask,
disabled,
}: {
createTask: (text: string) => void;
disabled: boolean;
}) {
return (
<TableRow>
<TableCell>
<Checkbox className="mt-1" disabled />
</TableCell>
<TableCell>
<SubmittableInput
onSubmit={(taskText) => createTask(taskText)}
label="Add"
placeholder="New task"
disabled={disabled}
/>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,177 @@
import { useCallback } from "react";
import { CoID } from "cojson";
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";
import { useParams } from "react-router";
import { Resolved, useAutoSub } from "jazz-react";
/** Walkthrough: Reactively rendering a todo project as a table,
* adding and editing tasks
*
* Here in `<TodoTable/>`, we use `useAutoSub()` for the first time,
* in this case to load the CoValue for our `TodoProject` as well as
* the `ListOfTasks` referenced in it.
*/
export function ProjectTodoTable() {
const projectId = useParams<{ projectId: CoID<TodoProject> }>().projectId;
// `useAutoSub()` 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!
// It also recursively resolves and subsribes to all referenced CoValues.
const project = useAutoSub(projectId);
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
// for a new task (in the same group as the project), and then
// adding that as an item to the project's list of tasks.
const createTask = useCallback(
(text: string) => {
if (!project?.tasks || !text) return;
const task = project.meta.group.createMap<Task>({
done: false,
text,
});
// project.tasks is immutable, but `append` will create an edit
// that will cause useAutoSub to rerender this component
// - here and on other devices!
project.tasks.append(task.id);
},
[project?.tasks, project?.meta.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 note everything might 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: Resolved<Task> | undefined }) {
return (
<TableRow>
<TableCell>
<Checkbox
className="mt-1"
checked={task?.done}
onCheckedChange={(checked) => {
// Tick or untick the task
// Task is also immutable, but this will update all queries
// that include this task as a reference
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" />
)}
{
// Here we see for the first time how we can access edit history
// for a CoValue, and use it to display who created the task.
task?.meta.edits.text?.by?.profile?.name ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={uniqueColoring(task.meta.edits.text.by.id)}
>
{task.meta.edits.text.by.profile.name}
</span>
) : (
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
)
}
</div>
</TableCell>
</TableRow>
);
}
/** Walkthrough: This is the end of the walkthrough so far! */
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,
};
}

View File

@@ -1,27 +1,26 @@
import { useState } from "react";
import { TodoProject } from "../1_types";
import { createInviteLink } from "jazz-react";
import QRCode from "qrcode";
import { useToast, Button } from "../basicComponents";
import { CoValue } from "cojson";
import { Resolved, createInviteLink } from "jazz-react";
export function InviteButton({ list }: { list?: TodoProject }) {
export function InviteButton<T extends CoValue>({ value }: { value?: Resolved<T> }) {
const [existingInviteLink, setExistingInviteLink] = useState<string>();
const { toast } = useToast();
return (
list?.group.myRole() === "admin" && (
value?.meta.group?.myRole() === "admin" && (
<Button
size="sm"
className="py-0"
disabled={!list}
disabled={!value.meta.group || !value.id}
variant="outline"
onClick={async () => {
let inviteLink = existingInviteLink;
if (list && !inviteLink) {
inviteLink = createInviteLink(list, "writer");
if (value.meta.group && value.id && !inviteLink) {
inviteLink = createInviteLink(value, "writer");
setExistingInviteLink(inviteLink);
}
if (inviteLink) {

View File

@@ -1,46 +0,0 @@
import { AccountID } from "cojson";
import { useProfile } from "jazz-react";
import { Skeleton } from "@/basicComponents";
import uniqolor from "uniqolor";
/** Walkthrough: Getting user profiles in `<NameBadge/>`
*
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
* useTelepathicState on an account's profile.
*
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
* additional properties).
*
* In our case, we just display the profile name (which is set by the LocalAuth
* provider when we first create an account).
*/
export function NameBadge({ accountID }: { accountID?: AccountID }) {
const profile = useProfile(accountID);
return accountID && profile?.get("name") ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={randomUserColor(accountID)}
>
{profile.get("name")}
</span>
) : (
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
);
}
function randomUserColor(accountID: AccountID) {
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
return {
color: theme == "light" ? darkColor : brightColor,
background: theme == "light" ? brightColor : darkColor,
};
}

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { CoID, LocalNode, CoValueImpl } from "cojson";
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
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;
}

View File

@@ -0,0 +1,18 @@
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/twit/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

11
examples/twit/.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}

4
examples/twit/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM caddy:2.7.3-alpine
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
COPY ./dist /usr/share/caddy/

64
examples/twit/README.md Normal file
View File

@@ -0,0 +1,64 @@
# Jazz Todo List Example
Live version: https://example-todo.jazz.tools
## Installing & running the example locally
Start by checking out just the example app to a folder:
```bash
npx degit gardencmp/jazz/examples/todo jazz-example-todo
cd jazz-example-todo
```
(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): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
- [`src/1_types.ts`](./src/1_types.ts),
[`src/2_main.tsx`](./src/2_main.tsx),
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): the main files for this example, see the walkthrough below
## Walkthrough
### Main parts
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
### Helpers
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
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/2_main.tsx](./src/2_main.tsx).

View File

@@ -0,0 +1,16 @@
{
"$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"
}
}

13
examples/twit/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>Twit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/2_main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,56 @@
job "twit$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 = "twit$BRANCH_SUFFIX"
port = "http"
provider = "consul"
}
resources {
cpu = 50 # MHz
memory = 50 # MB
}
}
}
}
# deploy bump 4

View File

@@ -0,0 +1,50 @@
{
"name": "jazz-example-twit",
"private": true,
"version": "0.0.6",
"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-popover": "^1.0.7",
"@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",
"javascript-time-ago": "^2.5.9",
"jazz-browser-media-images": "^0.4.7",
"jazz-react": "^0.4.6",
"jazz-react-auth-local": "^0.4.6",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-time-ago": "^7.2.1",
"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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,64 @@
import { CoMap, CoList, Media, CoStream, Group, AccountMigration, EVERYONE, Profile } from 'cojson';
export type Twit = CoMap<{
text?: string;
images?: ListOfImages['id'];
likes: LikeStream['id'];
replies: ReplyStream['id'];
isReplyTo?: Twit['id'];
}>;
export type ListOfImages = CoList<Media.ImageDefinition['id']>;
export type LikeStream = CoStream<'❤️' | null>;
export type ReplyStream = CoStream<Twit['id']>;
export type ListOfTwits = CoList<Twit['id']>;
export type ListOfProfiles = CoList<TwitProfile['id']>;
export type StreamOfFollowers = CoStream<TwitProfile['id'] | null>;
export type TwitProfile = Profile<
{
name: string;
bio: string;
avatar?: Media.ImageDefinition['id'];
twits: ListOfTwits['id'];
following: ListOfProfiles['id'];
followers: StreamOfFollowers['id'];
twitStyle?: { fontFamily: string; color: string };
}
>;
export type TwitAccountRoot = CoMap<{
peopleWhoCanSeeMyTwits: Group['id'];
peopleWhoCanSeeMyFollows: Group['id'];
peopleWhoCanFollowMe: Group['id'];
peopleWhoCanInteractWithMe: Group['id'];
}>;
export const migration: AccountMigration<TwitProfile, TwitAccountRoot> = (account, profile) => {
if (!account.get('root')) {
const peopleWhoCanSeeMyTwits = account.createGroup();
const peopleWhoCanSeeMyFollows = account.createGroup();
const peopleWhoCanFollowMe = account.createGroup();
const peopleWhoCanInteractWithMe = account.createGroup();
peopleWhoCanFollowMe?.addMember(EVERYONE, 'writer');
peopleWhoCanSeeMyTwits?.addMember(EVERYONE, 'reader');
peopleWhoCanSeeMyFollows?.addMember(EVERYONE, 'reader');
peopleWhoCanInteractWithMe?.addMember(EVERYONE, 'writer');
const root = account.createMap<TwitAccountRoot>({
peopleWhoCanSeeMyTwits: peopleWhoCanSeeMyTwits.id,
peopleWhoCanSeeMyFollows: peopleWhoCanSeeMyFollows.id,
peopleWhoCanFollowMe: peopleWhoCanFollowMe.id,
peopleWhoCanInteractWithMe: peopleWhoCanInteractWithMe.id
});
account.set('root', root.id);
profile.set('twits', peopleWhoCanSeeMyTwits.createList<ListOfTwits>().id, 'trusting');
profile.set('following', peopleWhoCanSeeMyFollows.createList<ListOfProfiles>().id, 'trusting');
profile.set('followers', peopleWhoCanFollowMe.createStream<StreamOfFollowers>().id, 'trusting');
console.log('MIGRATION SUCCESSFUL!');
}
};

View File

@@ -0,0 +1,71 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider, createHashRouter } from 'react-router-dom';
import './index.css';
import { AccountMigration } from 'cojson';
import { WithJazz, useJazz } from 'jazz-react';
import { LocalAuth } from 'jazz-react-auth-local';
import { Button, ThemeProvider, TitleAndLogo } from './basicComponents/index.tsx';
import { PrettyAuthUI } from './components/Auth.tsx';
import { migration } from './1_dataModel.ts';
import { ChronoFeed } from './3_ChronoFeed.tsx';
import { ProfilePage } from './5_ProfilePage.tsx';
const appName = 'Jazz Twit Example';
const auth = LocalAuth({
appName,
Component: PrettyAuthUI
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<TitleAndLogo name={appName} />
<div className="flex flex-col h-full items-stretch justify-start gap-10 pt-10 pb-10 px-5 w-full max-w-xl mx-auto">
<WithJazz auth={auth} migration={migration as AccountMigration}>
<App />
</WithJazz>
</div>
</ThemeProvider>
</React.StrictMode>
);
function App() {
const { me, logOut } = useJazz();
const router = createHashRouter([
{
path: '/',
element: <ChronoFeed />
},
{
path: '/:profileId',
element: <ProfilePage />
},
{
path: '/me',
loader: () => router.navigate('/' + me.profile?.id)
}
]);
return (
<>
<div className="flex gap-2">
<Button onClick={() => router.navigate('/')} variant="link" className="-ml-3">
Home
</Button>
<Button onClick={() => router.navigate('/me')} variant="link" className="ml-auto">
My Profile
</Button>
<Button onClick={() => router.navigate('/').then(logOut)} variant="outline">
Log Out
</Button>
</div>
<RouterProvider router={router} />
</>
);
}

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react';
import { useJazz } from 'jazz-react';
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
import { TwitComponent } from './4_TwitComponent.tsx';
import { MainH1 } from './basicComponents/index.tsx';
export function ChronoFeed() {
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
const myTwits = me.profile?.twits;
const twitsFromFollows = useMemo(
() => me.profile?.following?.flatMap(follow => follow?.twits || []) || [],
[me.profile?.following]
);
const allTwitsSorted = useMemo(
() =>
[...(myTwits || []), ...twitsFromFollows]
.flatMap(tw => (tw ? (tw.isReplyTo ? [] : tw) : []))
.sort((a, b) => (b.meta.edits.text?.at?.getTime() || 0) - (a.meta.edits.text?.at?.getTime() || 0)),
[myTwits, twitsFromFollows]
);
return (
<div className="flex flex-col items-stretch">
<CreateTwitForm className="mb-10" />
<MainH1>From people you follow</MainH1>
{allTwitsSorted?.map(twit => (
<TwitComponent twit={twit} key={twit.id} />
))}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import React from 'react';
import { Link } from 'react-router-dom';
import {
ButtonWithCount,
ProfilePicImg,
ReactionsContainer,
RepliesContainer,
SubtleRelativeTimeAgo,
TwitContainer,
TwitWithRepliesContainer,
TwitImg,
TwitImgGallery,
TwitHeader,
TwitBody,
TwitText,
} from './basicComponents/index.tsx';
import { Twit, TwitProfile } from './1_dataModel.ts';
import { BrowserImage } from 'jazz-browser-media-images';
import { HeartIcon, MessagesSquareIcon } from 'lucide-react';
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
import { Resolved } from 'jazz-react';
export function TwitComponent({
twit,
alreadyInReplies: alreadyInReplies
}: {
twit?: Resolved<Twit>;
alreadyInReplies?: boolean;
}) {
const [showReplyForm, setShowReplyForm] = React.useState(false);
const posterProfile = twit?.meta.edits.text?.by?.profile as Resolved<TwitProfile> | undefined;
const isTopLevel = !twit?.isReplyTo || alreadyInReplies;
return (
<TwitWithRepliesContainer isTopLevel={isTopLevel}>
<TwitContainer>
<ProfilePicImg
src={posterProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
linkTo={'/' + posterProfile?.id}
initial={posterProfile?.name[0]}
size={twit?.isReplyTo && "sm"}
/>
<TwitBody>
<TwitHeader>
<Link to={'/' + posterProfile?.id} className="font-bold hover:underline">
{posterProfile?.name}
</Link>
<SubtleRelativeTimeAgo dateTime={twit?.meta.edits.text?.at} />
</TwitHeader>
<TwitText style={posterProfile?.twitStyle}>
{/* This is where the tweet text goes */}
{twit?.text}
</TwitText>
{twit?.images && (
<TwitImgGallery>
{twit.images.map(image => (
<TwitImg src={image?.as(BrowserImage)?.highestResSrcOrPlaceholder} key={image?.id} />
))}
</TwitImgGallery>
)}
<ReactionsContainer>
<ButtonWithCount
active={twit?.likes?.me?.last === '❤️'}
onClick={() => twit?.likes?.push(twit?.likes?.me?.last ? null : '❤️')}
count={twit?.likes?.perAccount.filter(([, liked]) => liked.last === '❤️').length || 0}
icon={<HeartIcon size="18" />}
activeIcon={<HeartIcon color="red" size="18" fill="red" />}
/>
<ButtonWithCount
onClick={() => setShowReplyForm(s => !s)}
count={twit?.replies?.perAccount.flatMap(([, byAccount]) => byAccount.all).length || 0}
icon={<MessagesSquareIcon size="18" />}
/>
</ReactionsContainer>
</TwitBody>
</TwitContainer>
<RepliesContainer>
{showReplyForm && (
<CreateTwitForm
inReplyTo={twit}
onSubmit={() => setShowReplyForm(false)}
className={'mt-5 ' + (isTopLevel ? 'ml-14' : 'ml-12')}
/>
)}
{twit?.replies?.perAccount
.flatMap(([, byAccount]) => byAccount.all)
.sort((a, b) => b.at.getTime() - a.at.getTime())
.map(replyEntry => (
<TwitComponent twit={replyEntry.value} key={replyEntry.value?.id} alreadyInReplies={!!twit?.isReplyTo} />
))}
</RepliesContainer>
</TwitWithRepliesContainer>
);
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useJazz, useAutoSub } from 'jazz-react';
import QRCode from 'qrcode';
import {
BioInput,
ChooseProfilePicInput,
FollowerStatsContainer,
Popover,
ProfileName,
ProfilePicImg,
ProfileTitleContainer,
SmallInlineButton
} from './basicComponents/index.tsx';
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { CoID } from 'cojson';
import { BrowserImage, createImage } from 'jazz-browser-media-images';
import { FollowButton, FollowerList, FollowingList } from './7_FollowStuff.tsx';
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
import { TwitComponent } from './4_TwitComponent.tsx';
import { PopoverContent, PopoverTrigger } from '@radix-ui/react-popover';
export function ProfilePage() {
const { profileId } = useParams<{ profileId: CoID<TwitProfile> }>();
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
const profile = useAutoSub(profileId);
const isMe = profile?.id == me.profile?.id;
const profileTwitsAndRepliedToTwits = useMemo(() => {
return profile?.twits?.map((twit, _, allTwits) =>
twit?.isReplyTo
? allTwits.some(
tw =>
tw?.id === twit?.isReplyTo?.id ||
tw?.id === twit?.isReplyTo?.isReplyTo?.id ||
tw?.id === twit?.isReplyTo?.isReplyTo?.isReplyTo?.id
)
? null
: twit?.isReplyTo
: twit
);
}, [profile?.twits]);
const [qr, setQr] = useState<string>('');
useEffect(() => {
QRCode.toDataURL(
window.location.protocol + '//' + window.location.host + window.location.pathname + '#/' + profile?.id,
{
errorCorrectionLevel: 'L'
}
).then(setQr);
}, [profile?.id]);
return (
<div>
<div className="py-2 mb-5 flex gap-4">
<div className="flex flex-col items-stretch">
<ProfilePicImg
src={profile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
initial={profile?.name[0]}
size="xxl"
/>
{isMe && (
<ChooseProfilePicInput
onChange={(file: File) =>
me.root?.peopleWhoCanSeeMyTwits &&
createImage(file, me.root.peopleWhoCanSeeMyTwits, 256).then(image => {
me.profile?.set({ avatar: image.id }, 'trusting');
})
}
/>
)}
</div>
<div className="grow">
<ProfileTitleContainer>
<ProfileName>{profile?.name}</ProfileName>
{!isMe && <FollowButton profile={profile} />}
</ProfileTitleContainer>
<div>
{isMe ? (
<BioInput
value={profile?.bio}
onChange={newBio => {
profile?.set({ bio: newBio }, 'trusting');
// prettier-ignore
if (newBio.startsWith('{')) { profile?.set('twitStyle', JSON.parse(newBio), 'trusting'); } else { profile?.set('twitStyle', undefined, 'trusting'); }
}}
/>
) : (
profile?.bio || '(No bio)'
)}
</div>
<FollowerStatsContainer>
<Popover>
<PopoverTrigger>
<SmallInlineButton>
{profile?.followers?.perAccount?.filter(([, status]) => status.last).length} Followers
</SmallInlineButton>
</PopoverTrigger>
<PopoverContent>
<FollowerList profile={profile} />
</PopoverContent>
</Popover>
<span className="hidden md:block">&mdash;</span> <br className="md:hidden" />
<Popover>
<PopoverTrigger>
<SmallInlineButton>{new Set(profile?.following || []).size} Following</SmallInlineButton>
</PopoverTrigger>
<PopoverContent>
<FollowingList profile={profile} />
</PopoverContent>
</Popover>
</FollowerStatsContainer>
</div>
{isMe && <img src={qr} className="rounded w-28 h-28 -mr-3 dark:invert max-sm:w-16 max-sm:h-16" />}
</div>
{isMe && <CreateTwitForm className="mb-4" />}
{profileTwitsAndRepliedToTwits?.map(twit => twit && <TwitComponent twit={twit} key={twit?.id} />)}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import React, { useCallback, useEffect } from 'react';
import { Resolved, useJazz } from 'jazz-react';
import { AddTwitPicsInput, TwitImg, TwitTextInput } from './basicComponents/index.tsx';
import { LikeStream, ListOfImages, ReplyStream, Twit, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { createImage } from 'jazz-browser-media-images';
export function CreateTwitForm(
props: {
inReplyTo?: Resolved<Twit>;
onSubmit?: () => void;
className?: string;
} = {}
) {
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
const [pics, setPics] = React.useState<File[]>([]);
const onSubmit = useCallback(
(twitText: string) => {
const audience = me.root?.peopleWhoCanSeeMyTwits;
const interactors = me.root?.peopleWhoCanInteractWithMe;
if (!audience || !interactors) return;
const twit = audience.createMap<Twit>({
text: twitText,
likes: interactors.createStream<LikeStream>().id,
replies: interactors.createStream<ReplyStream>().id
});
me.profile?.twits?.prepend(twit?.id as Twit['id']);
if (props.inReplyTo) {
props.inReplyTo.replies?.push(twit.id);
twit.set({ isReplyTo: props.inReplyTo.id });
}
Promise.all(pics.map(pic => createImage(pic, twit.group, 1024))).then(createdPics => {
twit.set({ images: audience.createList<ListOfImages>(createdPics.map(pic => pic.id)).id });
});
setPics([]);
props.onSubmit?.();
},
[me.profile?.twits, me.root?.peopleWhoCanSeeMyTwits, me.root?.peopleWhoCanInteractWithMe, props, pics]
);
const [picPreviews, setPicPreviews] = React.useState<string[]>([]);
useEffect(() => {
const previews = pics.map(pic => URL.createObjectURL(pic));
setPicPreviews(previews);
return () => previews.forEach(preview => URL.revokeObjectURL(preview));
}, [pics]);
return (
<div className={props.className}>
<TwitTextInput onSubmit={onSubmit} submitButtonLabel={props.inReplyTo ? 'Reply!' : 'Twit!'} />
{picPreviews.length ? (
<div className="flex gap-2 mt-2">
{picPreviews.map(preview => (
<TwitImg src={preview} />
))}
</div>
) : (
<AddTwitPicsInput
onChange={(newPics: File[]) => {
setPics(newPics);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import { Resolved, useJazz } from 'jazz-react';
import { Button, ProfilePicImg } from './basicComponents/index.tsx';
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
import { Link } from 'react-router-dom';
import { BrowserImage } from 'jazz-browser-media-images';
export function FollowButton({ profile }: { profile?: Resolved<TwitProfile> }) {
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
const alreadyFollowing = profile?.followers?.perAccount?.some(([acc, status]) => acc === me.id && !!status.last);
const theyFollowMe = profile?.following?.some(f => f?.id === me.profile?.id);
const followOrUnfollow = useCallback(() => {
if (!profile?.followers || !me.profile?.following) return;
if (alreadyFollowing) {
me.profile.following.delete(me.profile.following.findIndex(f => f?.id === profile.id));
profile.followers.push(null);
} else {
me.profile.following.append(profile.id);
profile.followers.push(me.profile.id);
}
}, [alreadyFollowing, me.profile, profile]);
return profile?.id === me.profile?.id ? (
<div className="ml-auto text-neutral-500">That's you!</div>
) : (
<Button onClick={followOrUnfollow} className="ml-auto" variant={alreadyFollowing ? 'ghost' : 'default'}>
{alreadyFollowing ? 'Unfollow' : theyFollowMe ? 'Follow Back' : 'Follow'}
</Button>
);
}
export function FollowerList({ profile }: { profile?: Resolved<TwitProfile> }) {
return (
<div className="flex flex-col gap-4 p-4 bg-background rounded-lg border shadow-lg w-96 max-w-full m-2">
{profile?.followers?.perAccount.map(([, followEntry]) => {
const followerProfile = followEntry.last;
// not following anymore?
if (!followerProfile) return null;
return (
<div key={followerProfile.id} className="flex items-center">
<ProfilePicImg
src={followerProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
linkTo={'/' + followerProfile?.id}
initial={followerProfile?.name[0]}
/>
<Link to={'/' + followerProfile?.id} className="font-bold hover:underline">
{followerProfile?.name}
</Link>
<FollowButton profile={followerProfile} />
</div>
);
})}
</div>
);
}
export function FollowingList({ profile }: { profile?: Resolved<TwitProfile> }) {
return (
<div className="flex flex-col gap-4 p-4 bg-background rounded-lg border shadow-lg w-96 max-w-full m-2">
{[...new Set(profile?.following || [])].map(followingProfile => {
return (
<div key={followingProfile?.id} className="flex items-center">
<ProfilePicImg
src={followingProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
linkTo={'/' + followingProfile?.id}
initial={followingProfile?.name[0]}
/>
<Link to={'/' + followingProfile?.id} className="font-bold hover:underline">
{followingProfile?.name}
</Link>
<FollowButton profile={followingProfile} />
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,39 @@
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>
);
}

View File

@@ -0,0 +1,10 @@
import { Toaster } from ".";
export function TitleAndLogo({name}: {name: string}) {
return <>
<div className="flex items-center gap-2 justify-center mt-5">
<img src="jazz-logo.png" className="h-5" /> {name}
</div>
<Toaster />
</>
}

View File

@@ -0,0 +1,228 @@
import ReactTimeAgo from 'react-time-ago';
import { Button, ButtonProps } from './ui/button';
export { Button } from './ui/button';
export { Checkbox } from './ui/checkbox';
import { Input } from './ui/input';
import { Link } from 'react-router-dom';
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';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card';
export { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import TimeAgo from 'javascript-time-ago';
import en from 'javascript-time-ago/locale/en.json';
TimeAgo.addDefaultLocale(en);
export function BioInput(props: { value?: string; onChange: (value: string) => void }) {
return (
<Input
type="text"
value={props.value}
autoComplete="off"
onChange={e => {
props.onChange(e.target.value);
}}
placeholder="Add a bio..."
className="w-full p-2 border rounded max-md:text-base"
/>
);
}
export function ProfileTitleContainer(props: { children: React.ReactNode }) {
return <div className="flex items-baseline">{props.children}</div>;
}
export function ProfileName(props: { children: React.ReactNode }) {
return <h1 className="text-2xl">{props.children}</h1>;
}
export function FollowerStatsContainer(props: { children: React.ReactNode }) {
return <div className="flex gap-2 mt-2 text-neutral-500">{props.children}</div>;
}
export function ChooseProfilePicInput(props: { onChange: (file: File) => void }) {
return (
<Button asChild className="mt-2" size="sm" variant="secondary">
<label className="cursor-pointer text-xs">
Choose Pic
<Input
type="file"
accept="image/*"
onChange={e => {
e.target.files?.[0] && props.onChange(e.target.files[0]);
e.target.value = '';
}}
className="hidden"
/>
</label>
</Button>
);
}
export function ProfilePicImg(props: { src?: string; size?: 'sm' | 'xxl'; linkTo?: string; initial?: string }) {
return (
<Link to={props.linkTo || ''}>
{props.src ? (
<img
src={props.src}
className={
'bg-neutral-200 rounded-full mr-2 object-cover shrink-0' +
(props.size === 'sm' ? ' w-8 h-8' : props.size === 'xxl' ? ' w-20 h-20' : ' w-10 h-10')
}
/>
) : (
<div
className={
'bg-neutral-200 rounded-full mr-2 object-cover shrink-0 flex items-center justify-center text-neutral-700 ' +
(props.size === 'sm'
? ' w-8 h-8 text-[1.5rem]'
: props.size === 'xxl'
? ' w-20 h-20 text-[3.75rem]'
: ' w-10 h-10 text-[1.875rem]')
}
>
<div className="-mt-[8%]">{props.initial}</div>
</div>
)}
</Link>
);
}
export function SubtleRelativeTimeAgo(props: { dateTime?: Date }) {
return (
<div className="ml-auto text-neutral-300 text-xs whitespace-nowrap">
<ReactTimeAgo date={props.dateTime || 0} />
</div>
);
}
export function TwitImg(props: { src?: string }) {
return <img src={props.src} className="h-40 rounded object-cover" />;
}
export function ReactionsContainer(props: { children: React.ReactNode }) {
return <div className="flex gap-4 mt-2">{props.children}</div>;
}
export function RepliesContainer(props: { children: React.ReactNode }) {
return <div className="flex flex-col items-stretch gap-2 mt-2">{props.children}</div>;
}
export function ButtonWithCount(props: {
count: number;
onClick: () => void;
active?: boolean;
icon: React.ReactNode;
activeIcon?: React.ReactNode;
}) {
return (
<div className="flex items-center">
<Button
className="w-10 h-7 p-1 mr-1"
variant={props.active ? 'secondary' : 'outline'}
onClick={props.onClick}
size="icon"
>
{props.active ? props.activeIcon : props.icon}
</Button>{' '}
<span className="tabular-nums">{props.count}</span>
</div>
);
}
export function TwitTextInput(props: { onSubmit: (text: string) => void; submitButtonLabel: string }) {
return (
<form
onSubmit={event => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const text = form.twitText.value;
text && props.onSubmit(text);
form.twitText.value = '';
}}
className="flex gap-2 items-end"
>
<Input
type="text"
name="twitText"
placeholder="What's happenin'"
autoComplete="off"
className="p-2 border rounded grow max-md:text-base"
/>
<Button asChild>
<input type="submit" value={props.submitButtonLabel} />
</Button>
</form>
);
}
export function AddTwitPicsInput(props: { onChange: (files: File[]) => void }) {
return (
<Button asChild className="mt-2" size="sm" variant="secondary">
<label className="cursor-pointer text-xs">
Add Pics
<Input
type="file"
onChange={e => {
props.onChange(Array.from(e.target.files || []));
}}
className="hidden"
accept="image/*"
multiple
/>
</label>
</Button>
);
}
export function TwitWithRepliesContainer(props: { children: React.ReactNode; isTopLevel?: boolean }) {
return (
<div className={'py-2 flex flex-col items-stretch' + (props.isTopLevel ? ' border-t' : ' ml-14')}>
{props.children}
</div>
);
}
export function TwitContainer(props: { children: React.ReactNode }) {
return <div className="flex gap-2">{props.children}</div>;
}
export function TwitBody(props: { children: React.ReactNode }) {
return <div className="grow flex flex-col items-stretch">{props.children}</div>;
}
export function TwitHeader(props: { children: React.ReactNode }) {
return <div className="flex items-baseline">{props.children}</div>;
}
export function TwitImgGallery(props: { children: React.ReactNode }) {
return <div className="flex gap-2 mt-2 max-w-full overflow-auto">{props.children}</div>;
}
export function TwitText(props: { children: React.ReactNode; style?: React.CSSProperties }) {
return <div style={props.style}>{props.children}</div>;
}
export function QuoteContainer(props: { children: React.ReactNode }) {
return <div className="border rounded">{props.children}</div>;
}
export function MainH1(props: { children: React.ReactNode }) {
return <h1 className="text-2xl mb-4">{props.children}</h1>;
}
export function SmallInlineButton(props: { children: React.ReactNode } & ButtonProps) {
const {children, ...rest} = props
return (
<Button variant={'ghost'} className="h-6 px-1 -mx-1" {...rest}>
{children}
</Button>
);
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,72 @@
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;
};

View File

@@ -0,0 +1,56 @@
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 }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/basicComponents/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/basicComponents/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,25 @@
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 }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/basicComponents/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,15 @@
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 }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/basicComponents/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,127 @@
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,
}

View File

@@ -0,0 +1,33 @@
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>
)
}

View File

@@ -0,0 +1,192 @@
// 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 }

View File

@@ -0,0 +1,48 @@
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>
);
};

View File

@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--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;
}
body {
@apply bg-background text-foreground;
}
}

1
examples/twit/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,76 @@
/** @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")],
}

View File

@@ -0,0 +1,29 @@
{
"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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,16 @@
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
}
})

View File

@@ -1,5 +1,5 @@
import { readFile, writeFile } from "fs/promises";
import { Application, JSONOutput } from "typedoc";
import { Application, JSONOutput, ReflectionKind } from "typedoc";
const manuallyIgnore = new Set(["CojsonInternalTypes"]);
@@ -8,14 +8,18 @@ async function main() {
// 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",
cojson: "index.ts",
"jazz-browser": "index.ts",
"jazz-browser-media-images": "index.ts",
"jazz-autosub": "index.ts",
}).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"],
categorizeByGroup: false
});
const project = await app.convert();
@@ -35,33 +39,67 @@ async function main() {
docs
.groups!.map((group) => {
return group.children
?.map((childId) => {
?.flatMap((childId) => {
const child = docs.children!.find(
(child) => child.id === childId
)!;
if (manuallyIgnore.has(child.name)) {
return "";
if (
manuallyIgnore.has(child.name) ||
child.comment?.blockTags?.some(
(tag) =>
tag.tag === "@deprecated" ||
tag.tag === "@internal" ||
tag.tag === "@ignore"
) ||
child.signatures?.every((signature) =>
signature.comment?.blockTags?.some(
(tag) =>
tag.tag === "@deprecated" ||
tag.tag === "@internal" ||
tag.tag === "@ignore"
)
)
) {
return [];
}
return (
`## \`${renderChildName(child)}\` (${group.title
`## \`${renderChildName(
child
)}\`\n\n<sup>(${group.title
.toLowerCase()
.replace("bles", "ble")
.replace("ces", "ce")
.replace(/es$/, "")
.replace(
"ns",
"n"
)} in \`${packageName}\`)\n\n` +
)} in \`${packageName}\`)</sup>\n\n` +
renderChildType(child) +
renderComment(child.comment) +
(child.kind === 128 || child.kind === 256
? child.groups
?.map((group) =>
renderChildGroup(child, group)
(child.kind === ReflectionKind.Class ||
child.kind === ReflectionKind.Interface ||
child.kind === ReflectionKind.Namespace
? renderSummary(child.comment) +
renderExamples(child.comment) +
(child.categories || child.groups)
?.map((category) =>
renderChildCategory(child, category)
)
.join("\n\n")
: "TODO: doc generator not implemented yet")
.join("<br/>\n\n")
: child.kind === ReflectionKind.Function
? renderSummary(
child.signatures?.[0].comment
) +
renderParamComments(
child.signatures?.[0].parameters || []
) +
renderExamples(
child.signatures?.[0].comment
) +
"\n\n"
: "TODO: doc generator not implemented yet " +
child.kind)
);
})
.join("\n\n----\n\n");
@@ -69,7 +107,7 @@ async function main() {
.join("\n\n----\n\n")
);
function renderComment(comment?: JSONOutput.Comment): string {
function renderSummary(comment?: JSONOutput.Comment): string {
if (comment) {
return (
comment.summary
@@ -80,11 +118,50 @@ async function main() {
)
.join("") +
"\n\n" +
(comment.blockTags || [])
.map((blockTag) =>
blockTag.tag === "@example"
? "##### Example:\n\n" +
blockTag.content
"\n\n"
);
} else {
return "TODO: document\n\n";
}
}
function renderExamples(comment?: JSONOutput.Comment): string {
return (comment?.blockTags || [])
.map((blockTag) =>
blockTag.tag === "@example"
? "##### Example:\n\n" +
blockTag.content
.map((token) =>
token.kind === "text" || token.kind === "code"
? token.text
: ""
)
.join("") +
"\n\n"
: ""
)
.join("");
}
function renderParamComments(params: JSONOutput.ParameterReflection[]) {
const paramDocs = params.flatMap((param) => {
if (param.type?.type === "reflection") {
return param.type.declaration.children?.flatMap((child) => {
if (
child.name === "children" &&
child.type?.type === "reference" &&
child.type?.name === "ReactNode"
) {
return [];
}
return (
`| \`${param.name}.${child.name}${
child.flags.isOptional || child.defaultValue
? "?"
: ""
}\` | ` +
(child.comment
? child.comment.summary
.map((token) =>
token.kind === "text" ||
token.kind === "code"
@@ -92,13 +169,37 @@ async function main() {
: ""
)
.join("")
: "TODO: document") +
" |"
);
});
} else {
const comment = param.comment;
return [
`| \`${param.name}${
param.flags.isOptional || param.defaultValue
? "?"
: ""
)
.join("\n\n") +
"\n\n"
);
} else {
return "TODO: document\n\n";
}\` | ` +
(comment
? comment.summary
.map((token) =>
token.kind === "text" ||
token.kind === "code"
? token.text
: ""
)
.join("")
: "TODO: document ") +
" |",
];
}
});
if (paramDocs.length) {
return `### Parameters:\n\n| name | description |\n| ----: | ---- |\n${paramDocs.join(
"\n"
)}\n\n`;
}
}
@@ -126,26 +227,30 @@ async function main() {
function renderChildType(
child: JSONOutput.DeclarationReflection
): string {
const isClass = child.kind === 128;
const isTypeDef = child.kind === 2097152;
const isInterface = child.kind === 256;
const isClass = child.kind === ReflectionKind.Class;
const isTypeAlias = child.kind === ReflectionKind.TypeAlias;
const isInterface = child.kind === ReflectionKind.Interface;
const isNamespace = child.kind === ReflectionKind.Namespace;
const isFunction = !!child.signatures;
const kind = isClass
? "class"
: isTypeAlias
? "type"
: isFunction
? "function"
: isInterface
? "interface"
: isNamespace
? "namespace"
: "";
return (
"```typescript\n" +
`export ${
isClass
? "class"
: isTypeDef
? "type"
: isFunction
? "function"
: isInterface
? "interface"
: ""
} ${child.name}` +
(child.typeParameters
`export ${kind} ${child.name}` +
((child.typeParameters || child.signatures?.[0].typeParameter)
? "<" +
child.typeParameters.map(renderTypeParam).join(", ") +
(child.typeParameters || child.signatures?.[0].typeParameter || []).map(renderTypeParam).join(", ") +
">"
: "") +
(child.extendedTypes
@@ -156,9 +261,9 @@ async function main() {
? " implements " +
child.implementedTypes.map(renderType).join(", ")
: "") +
(isClass || isInterface
(isClass || isInterface || isNamespace
? " {...}"
: isTypeDef
: isTypeAlias
? ` = ${renderType(child.type)}`
: child.signatures
? `(${(child.signatures[0].parameters || [])
@@ -171,33 +276,69 @@ async function main() {
);
}
function renderChildGroup(
function renderChildCategory(
child: JSONOutput.DeclarationReflection,
group: JSONOutput.ReflectionGroup
category: JSONOutput.ReflectionGroup
): string {
return (
`### ${group.title}\n\n` +
group.children
`### \`${child.name}\`: ${category.title.replace(/[^d]+\./, "")}\n\n` +
category.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 ""
if (
member.signatures?.every(
(sig) =>
sig.comment?.modifierTags?.includes(
"@internal"
) ||
sig.comment?.modifierTags?.includes(
"@deprecated"
)
)
) {
return "";
} else {
return documentConstructorOrMethod(member, child);
return documentConstructorOrMethod(
member,
child
);
}
} else if (
member.kind === 1024 ||
member.kind === 262144
) {
if (member.comment?.modifierTags?.includes("@internal")) {
return ""
if (
member.comment?.modifierTags?.includes(
"@internal"
) ||
member.comment?.modifierTags?.includes(
"@deprecated"
)
) {
return "";
} else {
return documentProperty(member, child);
}
} else if (member.kind === 2097152) {
if (
member.comment?.modifierTags?.includes(
"@internal"
) ||
member.comment?.modifierTags?.includes(
"@deprecated"
)
) {
return "";
} else {
return documentProperty(
{ ...member, flags: { isStatic: true } },
child
);
}
} else {
return "Unknown member kind " + member.kind;
}
@@ -220,9 +361,39 @@ async function main() {
} else if (t.type === "literal") {
return JSON.stringify(t.value);
} else if (t.type === "union") {
return [...new Set(t.types.map(renderType))].join(" | ");
const seen = new Set<string>();
return t.types
.flatMap((t) => {
const rendered =
t.type === "intersection" || t.type === "union"
? `(${renderType(t)})`
: renderType(t);
if (seen.has(rendered)) {
return [];
} else {
seen.add(rendered);
return [rendered];
}
})
.join(" | ");
} else if (t.type === "intersection") {
return [...new Set(t.types.map(renderType))].join(" & ");
const seen = new Set<string>();
return t.types
.flatMap((t) => {
const rendered =
t.type === "intersection" || t.type === "union"
? `(${renderType(t)})`
: renderType(t);
if (seen.has(rendered)) {
return [];
} else {
seen.add(rendered);
return [rendered];
}
})
.join(" & ");
} else if (t.type === "indexedAccess") {
return (
renderType(t.objectType) +
@@ -233,40 +404,84 @@ async function main() {
} else if (t.type === "reflection") {
if (t.declaration.indexSignature) {
return (
"{ [" +
`{${
t.declaration.children
? t.declaration.children
.map(
(child) =>
` ${child.name}${
child.flags.isOptional
? "?"
: ""
}: ${indentEnd(
renderType(child.type)
)},`
)
.join("\n")
: ""
}\n [` +
t.declaration.indexSignature?.parameters?.[0].name +
": " +
renderType(
t.declaration.indexSignature?.parameters?.[0].type
) +
"]: " +
renderType(t.declaration.indexSignature?.type) +
indentEnd(
renderType(t.declaration.indexSignature?.type)
) +
" }"
);
} else if (t.declaration.children) {
return `{${t.declaration.children
.map(
(child) =>
`${child.name}${
child.flags.isOptional ? "?" : ""
}: ${renderType(child.type)}`
return `{\n${t.declaration.children
.map((child) =>
child.signatures
? child.signatures
.map(
(signature) =>
` ${child.name}(${
signature.parameters
? "\n " +
indent(
signature.parameters
.map((p) =>
indentEnd(
renderParam(
p
)
)
)
.join(",\n ")
) +
"\n )"
: "()"
}: ${indentEnd(
renderType(signature.type)
)}`
)
.join("\n") + ",\n"
: ` ${child.name}${
child.flags.isOptional ? "?" : ""
}: ${indentEnd(renderType(child.type))},\n`
)
.join(", ")}}`;
.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
)}`;
}
return t.declaration.signatures
.map(
(signature) =>
`(${(signature.parameters || [])
.map(renderParam)
.join(", ")}) => ${renderType(
signature.type
)}`
)
.join("\n");
} 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) =>
@@ -296,9 +511,55 @@ async function main() {
return "AgentID";
}
} else {
return "TEMPLATE_LITERAL";
return (
"`" +
t.head +
t.tail
.map(
(bit) =>
"${" + renderType(bit[0]) + "}" + bit[1]
)
.join("") +
"`"
);
}
}
} else if (t.type === "conditional") {
const trueRendered = renderType(t.trueType);
const falseRendered = renderType(t.falseType);
if (
trueRendered.includes("\n") ||
falseRendered.includes("\n")
) {
return (
renderType(t.checkType) +
" extends " +
renderType(t.extendsType) +
"\n ? " +
indentEnd(renderType(t.trueType)) +
"\n : " +
indentEnd(renderType(t.falseType))
);
} else {
return (
renderType(t.checkType) +
" extends " +
renderType(t.extendsType) +
" ? " +
renderType(t.trueType) +
" : " +
renderType(t.falseType)
);
}
} else if (t.type === "inferred") {
return "infer " + t.name;
} else if (t.type === "typeOperator") {
return t.operator + " " + renderType(t.target);
} else if (t.type === "mapped") {
return `{\n [${t.parameter} in ${renderType(
t.parameterType
)}]: ${indentEnd(renderType(t.templateType))}\n}`;
} else {
return "COMPLEX_TYPE_" + t.type;
}
@@ -341,101 +602,186 @@ async function main() {
(child) =>
child.name + (child.flags.isOptional ? "?" : "")
)
.join(", ")}}${param.defaultValue ? "?" : ""}`
: param.name + (param.defaultValue ? "?" : "");
.join(", ")}}${
param.flags.isOptional || param.defaultValue ? "?" : ""
}`
: param.name +
(param.flags.isOptional || param.defaultValue ? "?" : "");
}
function documentConstructorOrMethod(
member: JSONOutput.DeclarationReflection,
child: JSONOutput.DeclarationReflection
) {
const isInClass = child.kind === 128;
const isInTypeDef = child.kind === 2097152;
const isInInterface = child.kind === 256;
const isInNamespace = child.kind === 4;
const isInFunction = !!child.signatures;
const inKind = isInClass
? "class"
: isInTypeDef
? "type"
: isInFunction
? "function"
: isInInterface
? "interface"
: isInNamespace
? "namespace"
: "";
const stem =
member.name === "constructor"
? "new " + child.name
: (member.flags.isStatic
? child.name
: child.name[0].toLowerCase() + child.name.slice(1)) +
? "new " + child.name + "</code></b>"
: (member.flags.isStatic ? child.name : "") +
"." +
member.name;
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 member.signatures
?.map((signature) => {
return (
"```typescript\n" +
`${stem}${
signature.typeParameter
? `<${signature.typeParameter
.map(renderTypeParam)
.join(", ")}>`
`<details>\n<summary><b><code>${stem}(${(
signature?.parameters?.map(renderParamSimple) || []
).join(", ")})</code></b> ${
member.inheritedFrom
? "<sub><sup>from <code>" +
member.inheritedFrom.name.split(".")[0] +
"</code></sup></sub> "
: ""
}(${
(
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)
} ${
signature?.comment
? ""
: "<sub><sup>(undocumented)</sup></sub>"
}</summary>\n\n` +
("```typescript\n" +
`${inKind} ${child.name}${
child.typeParameters
? `<${child.typeParameters
.map((t) => t.name)
.join(", ")}>`
: ""
} {\n\n${indent(
`${member.name}${
signature.typeParameter
? `<${signature.typeParameter
.map(renderTypeParam)
.join(", ")}>`
: ""
}(${
(
signature.parameters?.map(
(param) =>
`\n ${param.name}${
param.flags.isOptional ||
param.defaultValue
? "?"
: ""
}: ${indentEnd(
renderType(param.type)
)}${
param.defaultValue
? ` = ${param.defaultValue}`
: ""
}`
) || []
).join(",") +
(signature.parameters?.length ? "\n" : "")
}): ${renderType(signature.type)} {...}`
)}\n\n}\n` +
"```\n" +
renderSummary(signature.comment)) +
renderParamComments(signature.parameters || []) +
renderExamples(signature.comment) +
"</details>\n\n"
);
}) +
"</details>\n\n"
);
})
.join("\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);
const isInClass = child.kind === 128;
const isInTypeDef = child.kind === 2097152;
const isInInterface = child.kind === 256;
const isInNamespace = child.kind === 4;
const isInFunction = !!child.signatures;
const inKind = isInClass
? "class"
: isInTypeDef
? "type"
: isInFunction
? "function"
: isInInterface
? "interface"
: isInNamespace
? "namespace"
: "";
const stem = member.flags.isStatic ? child.name : "";
return (
`<details>\n<summary><code>${stem}.${member.name}</code> ${
`<details>\n<summary><b><code>${stem}.${
member.name
}</code></b> ${
member.inheritedFrom
? "(from <code>" +
? "<sub><sup>from <code>" +
member.inheritedFrom.name.split(".")[0] +
"</code>) "
"</code></sup></sub> "
: ""
} ${
member.comment ? "" : "(undocumented)"
member.comment ? "" : "<sub><sup>(undocumented)</sup></sub>"
}</summary>\n\n` +
"```typescript\n" +
`${member.getSignature ? "get " : ""}${stem}.${member.name}${
member.getSignature ? "()" : ""
}: ${renderType(member.type || member.getSignature?.type)}\n` +
"```\n" +
renderComment(member.comment) +
`${inKind} ${child.name}${
child.typeParameters
? `<${child.typeParameters
.map((t) => t.name)
.join(", ")}>`
: ""
} {\n\n${indent(
`${member.getSignature ? "get " : ""}${member.name}${
member.getSignature ? "()" : ""
}: ${renderType(member.type || member.getSignature?.type)}${
member.getSignature ? " {...}" : ""
}`
)}` +
"\n\n}\n```\n" +
renderSummary(member.comment) +
renderExamples(member.comment) +
"</details>\n\n"
);
}
});
const docsContent = await readFile("./DOCS.md", "utf8");
await writeFile(
"./DOCS.md",
(await Promise.all(packageDocs)).join("\n\n\n")
docsContent.slice(
0,
docsContent.indexOf("<!-- AUTOGENERATED DOCS AFTER THIS POINT -->")
) +
"<!-- AUTOGENERATED DOCS AFTER THIS POINT -->\n" +
(await Promise.all(packageDocs)).join("\n\n\n")
);
}
function indent(text: string): string {
return text
.split("\n")
.map((line) => " " + line)
.join("\n");
}
function indentEnd(text: string): string {
return text
.split("\n")
.map((line, i) => (i === 0 ? line : " " + line))
.join("\n");
}
main().catch(console.error);

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

35
homepage/homepage-jazz/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

View File

@@ -0,0 +1,22 @@
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,113 @@
import Image from 'next/image'
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
Get started by editing&nbsp;
<code className="font-mono font-bold">app/page.tsx</code>
</p>
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
<a
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{' '}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className="dark:invert"
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
<Image
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Docs{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Find in-depth information about Next.js features and API.
</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Learn{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Learn about Next.js in an interactive course with&nbsp;quizzes!
</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Templates{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Explore the Next.js 13 playground.
</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
target="_blank"
rel="noopener noreferrer"
>
<h2 className={`mb-3 text-2xl font-semibold`}>
Deploy{' '}
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
-&gt;
</span>
</h2>
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
)
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,27 @@
{
"name": "homepage-jazz",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "latest",
"react-dom": "latest",
"next": "latest"
},
"devDependencies": {
"typescript": "latest",
"@types/react": "latest",
"@types/node": "latest",
"@types/react-dom": "latest",
"autoprefixer": "latest",
"postcss": "latest",
"tailwindcss": "latest",
"eslint": "latest",
"eslint-config-next": "latest"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,20 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}
export default config

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -4,7 +4,7 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.2.1",
"version": "0.4.6",
"devDependencies": {
"@types/jest": "^29.5.3",
"@types/ws": "^8.5.5",
@@ -16,8 +16,8 @@
"typescript": "5.0.2"
},
"dependencies": {
"cojson": "^0.2.1",
"cojson-storage-sqlite": "^0.2.1",
"cojson": "^0.4.6",
"cojson-storage-sqlite": "^0.4.6",
"ws": "^8.13.0"
},
"scripts": {

View File

@@ -16,7 +16,7 @@ const localNode = new LocalNode(
);
SQLiteStorage.asPeer({ filename: "./sync.db" })
.then((storage) => localNode.sync.addPeer(storage))
.then((storage) => localNode.syncManager.addPeer(storage))
.catch((e) => console.error(e));
wss.on("connection", function connection(ws, req) {
@@ -34,8 +34,6 @@ wss.on("connection", function connection(ws, req) {
clearInterval(pinging);
});
const clientAddress =
(req.headers["x-forwarded-for"] as string | undefined)
?.split(",")[0]
@@ -43,7 +41,7 @@ wss.on("connection", function connection(ws, req) {
const clientId = clientAddress + "@" + new Date().toISOString();
localNode.sync.addPeer({
localNode.syncManager.addPeer({
id: clientId,
role: "client",
incoming: websocketReadableStream(ws),

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-storage-indexeddb",
"version": "0.2.1",
"name": "cojson-storage-indexeddb",
"version": "0.4.6",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.2.1",
"cojson": "^0.4.6",
"typescript": "^5.1.6"
},
"devDependencies": {
@@ -16,7 +16,7 @@
"scripts": {
"test": "vitest --browser chrome",
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
},
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"

View File

@@ -12,7 +12,7 @@ test.skip("Should be able to initialize and load from empty DB", async () => {
)
);
node.sync.addPeer(await IDBStorage.asPeer({ trace: true }));
node.syncManager.addPeer(await IDBStorage.asPeer({ trace: true }));
console.log("yay!");
@@ -20,7 +20,7 @@ test.skip("Should be able to initialize and load from empty DB", async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
expect(node.sync.peers["storage"]).toBeDefined();
expect(node.syncManager.peers["storage"]).toBeDefined();
});
test("Should be able to sync data to database and then load that from a new node", async () => {
@@ -33,7 +33,7 @@ test("Should be able to sync data to database and then load that from a new node
)
);
node1.sync.addPeer(
node1.syncManager.addPeer(
await IDBStorage.asPeer({ trace: true, localNodeName: "node1" })
);
@@ -56,7 +56,7 @@ test("Should be able to sync data to database and then load that from a new node
)
);
node2.sync.addPeer(
node2.syncManager.addPeer(
await IDBStorage.asPeer({ trace: true, localNodeName: "node2" })
);

View File

@@ -5,8 +5,8 @@ import {
Peer,
CojsonInternalTypes,
MAX_RECOMMENDED_TX_SIZE,
AccountID,
} from "cojson";
import { Signature } from "cojson/dist/crypto";
import {
ReadableStream,
WritableStream,
@@ -97,7 +97,7 @@ export class IDBStorage {
toLocalNode: WritableStream<SyncMessage>
) {
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("jazz-storage", 3);
const request = indexedDB.open("jazz-storage", 4);
request.onerror = () => {
reject(request.error);
};
@@ -139,10 +139,12 @@ export class IDBStorage {
keyPath: ["ses", "idx"],
});
}
if (ev.oldVersion !== 0 && ev.oldVersion === 2) {
if (ev.oldVersion !== 0 && ev.oldVersion <= 3) {
// fix embarrassing off-by-one error for transaction indices
console.log("Migration: fixing off-by-one error");
const transaction = (ev.target as unknown as {transaction: IDBTransaction}).transaction;
const transaction = (
ev.target as unknown as { transaction: IDBTransaction }
).transaction;
const txsStore = transaction.objectStore("transactions");
const txs = await promised(txsStore.getAll());
@@ -236,11 +238,11 @@ export class IDBStorage {
)
);
console.log(
theirKnown.id,
"signaturesAndIdxs",
JSON.stringify(signaturesAndIdxs)
);
// console.log(
// theirKnown.id,
// "signaturesAndIdxs",
// JSON.stringify(signaturesAndIdxs)
// );
const newTxInSession = await promised<TransactionRow[]>(
transactions.getAll(
@@ -253,11 +255,11 @@ export class IDBStorage {
let idx = firstNewTxIdx;
console.log(
theirKnown.id,
"newTxInSession",
newTxInSession.length
);
// console.log(
// theirKnown.id,
// "newTxInSession",
// newTxInSession.length
// );
for (const tx of newTxInSession) {
let sessionEntry =
@@ -267,7 +269,8 @@ export class IDBStorage {
if (!sessionEntry) {
sessionEntry = {
after: idx,
lastSignature: "WILL_BE_REPLACED" as Signature,
lastSignature:
"WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
newTransactions: [],
};
newContentPieces[newContentPieces.length - 1]!.new[
@@ -329,7 +332,25 @@ export class IDBStorage {
})
)
: coValueRow?.header.ruleset.type === "ownedByGroup"
? [coValueRow?.header.ruleset.group]
? [
coValueRow?.header.ruleset.group,
...new Set(
newContentPieces.flatMap((piece) =>
Object.keys(piece)
.map((sessionID) =>
cojsonInternals.accountOrAgentIDfromSessionID(
sessionID as SessionID
)
)
.filter(
(accountID): accountID is AccountID =>
cojsonInternals.isAccountID(
accountID
) && accountID !== theirKnown.id
)
)
),
]
: [];
for (const dependedOnCoValue of dependedOnCoValues) {
@@ -350,7 +371,7 @@ export class IDBStorage {
(piece) => piece.header || Object.keys(piece.new).length > 0
);
console.log(theirKnown.id, nonEmptyNewContentPieces);
// console.log(theirKnown.id, nonEmptyNewContentPieces);
for (const piece of nonEmptyNewContentPieces) {
await this.toLocalNode.write(piece);

View File

@@ -1,18 +1,18 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.2.1",
"version": "0.4.6",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.5.2",
"cojson": "^0.2.1",
"cojson": "^0.4.6",
"typescript": "^5.1.6"
},
"scripts": {
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
},
"devDependencies": {

View File

@@ -4,8 +4,8 @@ import {
Peer,
CojsonInternalTypes,
SessionID,
// CojsonInternalTypes,
// SessionID,
MAX_RECOMMENDED_TX_SIZE,
AccountID
} from "cojson";
import {
ReadableStream,
@@ -15,7 +15,6 @@ import {
} from "isomorphic-streams";
import Database, { Database as DatabaseT } from "better-sqlite3";
import { RawCoID } from "cojson/dist/ids";
type CoValueRow = {
id: CojsonInternalTypes.RawCoID;
@@ -29,6 +28,7 @@ type SessionRow = {
sessionID: SessionID;
lastIdx: number;
lastSignature: CojsonInternalTypes.Signature;
bytesSinceLastSignature?: number;
};
type StoredSessionRow = SessionRow & { rowID: number };
@@ -39,6 +39,12 @@ type TransactionRow = {
tx: string;
};
type SignatureAfterRow = {
ses: number;
idx: number;
signature: CojsonInternalTypes.Signature;
};
export class SQLiteStorage {
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
@@ -98,41 +104,99 @@ export class SQLiteStorage {
const db = Database(filename);
db.pragma("journal_mode = WAL");
db.prepare(
`CREATE TABLE IF NOT EXISTS transactions (
ses INTEGER,
idx INTEGER,
tx TEXT NOT NULL ,
PRIMARY KEY (ses, idx)
) WITHOUT ROWID;`
).run();
const oldVersion = (
db.pragma("user_version") as [{ user_version: number }]
)[0].user_version as number;
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();
console.log("DB version", oldVersion);
db.prepare(
`CREATE INDEX IF NOT EXISTS sessionsByCoValue ON sessions (coValue);`
).run();
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 coValues (
rowID INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
header TEXT NOT NULL UNIQUE
);`
).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 coValuesByID ON coValues (id);`
).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);
}
@@ -174,17 +238,31 @@ export class SQLiteStorage {
sessions: {},
};
const parsedHeader = (coValueRow?.header &&
JSON.parse(coValueRow.header)) as
| CojsonInternalTypes.CoValueHeader
| undefined;
let parsedHeader;
const newContent: CojsonInternalTypes.NewContentMessage = {
action: "content",
id: theirKnown.id,
header: theirKnown.header ? undefined : parsedHeader,
new: {},
};
try {
parsedHeader = (coValueRow?.header &&
JSON.parse(coValueRow.header)) as
| CojsonInternalTypes.CoValueHeader
| undefined;
} catch (e) {
console.warn(
theirKnown.id,
"Invalid JSON in header",
e,
coValueRow?.header
);
return;
}
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;
@@ -196,48 +274,153 @@ export class SQLiteStorage {
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 > ?`
`SELECT * FROM transactions WHERE ses = ? AND idx >= ?`
)
.all(sessionRow.rowID, firstNewTxIdx) as TransactionRow[];
newContent.new[sessionRow.sessionID] = {
after: firstNewTxIdx,
lastSignature: sessionRow.lastSignature,
newTransactions: newTxInSession.map((row) =>
JSON.parse(row.tx)
),
};
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;
}
let parsedTx;
try {
parsedTx = JSON.parse(tx.tx);
} catch (e) {
console.warn(
theirKnown.id,
"Invalid JSON in transaction",
e,
tx.tx
);
break;
}
sessionEntry.newTransactions.push(parsedTx);
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"
? Object.values(newContent.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_")
);
})
)
? newContentPieces
.flatMap((piece) => Object.values(piece.new))
.flatMap((sessionEntry) =>
sessionEntry.newTransactions.flatMap((tx) => {
if (tx.privacy !== "trusting") return [];
// TODO: avoid parsing here?
let parsedChanges;
try {
parsedChanges = cojsonInternals.parseJSON(
tx.changes
);
} catch (e) {
console.warn(
theirKnown.id,
"Invalid JSON in transaction",
e,
tx.changes
);
return [];
}
return parsedChanges
.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]
? [
parsedHeader?.ruleset.group,
...new Set(
newContentPieces.flatMap((piece) =>
Object.keys(piece)
.map((sessionID) =>
cojsonInternals.accountOrAgentIDfromSessionID(
sessionID as SessionID
)
)
.filter(
(accountID): accountID is AccountID =>
cojsonInternals.isAccountID(accountID) &&
accountID !== theirKnown.id
)
)
),
]
: [];
for (const dependedOnCoValue of dependedOnCoValues) {
@@ -253,8 +436,15 @@ export class SQLiteStorage {
asDependencyOf,
});
if (newContent.header || Object.keys(newContent.new).length > 0) {
await this.toLocalNode.write(newContent);
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));
}
}
@@ -265,7 +455,9 @@ export class SQLiteStorage {
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
let storedCoValueRowID = (
this.db
.prepare<RawCoID>(`SELECT rowID FROM coValues WHERE id = ?`)
.prepare<CojsonInternalTypes.RawCoID>(
`SELECT rowID FROM coValues WHERE id = ?`
)
.get(msg.id) as StoredCoValueRow | undefined
)?.rowID;
@@ -284,7 +476,7 @@ export class SQLiteStorage {
}
storedCoValueRowID = this.db
.prepare<[RawCoID, string]>(
.prepare<[CojsonInternalTypes.RawCoID, string]>(
`INSERT INTO coValues (id, header) VALUES (?, ?)`
)
.run(msg.id, JSON.stringify(header)).lastInsertRowid as number;
@@ -326,37 +518,72 @@ export class SQLiteStorage {
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:
(sessionRow?.lastIdx || 0) +
actuallyNewTransactions.length,
lastIdx: newLastIdx,
lastSignature: msg.new[sessionID]!.lastSignature,
bytesSinceLastSignature: newBytesSinceLastSignature,
};
const upsertedSession = this.db
.prepare<[number, string, number, string]>(
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature) VALUES (?, ?, ?, ?)
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature
.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.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) {
nextIdx++;
this.db
.prepare<[number, number, string]>(
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
@@ -366,6 +593,7 @@ export class SQLiteStorage {
nextIdx,
JSON.stringify(newTransaction)
);
nextIdx++;
}
}
}

View File

@@ -9,6 +9,7 @@ module.exports = {
parserOptions: {
project: "./tsconfig.json",
},
ignorePatterns: [".eslint.cjs", "**/tests/*"],
root: true,
rules: {
"no-unused-vars": "off",

View File

@@ -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.1",
"version": "0.4.6",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -26,7 +26,7 @@
"scripts": {
"test": "jest",
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
},
"jest": {

View File

@@ -1,17 +1,29 @@
import { JsonObject, JsonValue } from "./jsonValue.js";
import { RawCoID } from "./ids.js";
import { CoMap } from "./coValues/coMap.js";
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
import { Static } from "./coValues/static.js";
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
import { CoList } from "./coValues/coList.js";
import { CoValueCore } from "./coValueCore.js";
import { Group } from "./group.js";
import { Group } from "./coValues/group.js";
import { Account, Profile } from "./index.js";
export type CoID<T extends CoValueImpl> = RawCoID & {
export type CoID<T extends CoValue> = RawCoID & {
readonly __type: T;
};
export interface ReadableCoValue extends CoValue {
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 */
headerMeta: JsonObject | null;
/** The `Group` this `CoValue` belongs to (determining permissions) */
group: Group;
/** Returns an immutable JSON presentation of this `CoValue` */
toJSON(): JsonValue;
atTime(time: number): this;
/** 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.
@@ -19,46 +31,38 @@ export interface ReadableCoValue extends CoValue {
* Returns an unsubscribe function.
*
* Used internally by `useTelepathicData()` for reactive updates on changes to a `CoValue`. */
subscribe(listener: (coValue: CoValueImpl) => 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: WriteableCoValue) => void) => CoValueImpl)
| undefined;
subscribe(listener: (coValue: this) => void): () => void;
}
export interface CoValue {
/** The `CoValue`'s (precisely typed) `CoID` */
id: CoID<CoValueImpl>;
core: CoValueCore;
/** Specifies which kind of `CoValue` this is */
type: CoValueImpl["type"];
/** 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;
}
export type AnyCoValue =
| CoMap
| Group
| Account
| Profile
| CoList
| CoStream
| BinaryCoStream;
export interface WriteableCoValue extends CoValue {}
export type CoValueImpl =
| CoMap<{ [key: string]: JsonValue | undefined; }, JsonObject | null>
| CoList<JsonValue, JsonObject | null>
| CoStream<JsonValue, JsonObject | null>
| BinaryCoStream<BinaryCoStreamMeta>
| Static<JsonObject>;
export function expectMap(
content: CoValueImpl
): CoMap<{ [key: string]: string }, JsonObject | null> {
export function expectMap(content: CoValue): CoMap {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
return content as CoMap;
}
export function expectList(content: CoValue): CoList {
if (content.type !== "colist") {
throw new Error("Expected list");
}
return content as CoList;
}
export function expectStream(content: CoValue): CoStream {
if (content.type !== "costream") {
throw new Error("Expected stream");
}
return content as CoStream;
}

View File

@@ -1,6 +1,5 @@
import { randomBytes } from "@noble/hashes/utils";
import { CoValueImpl } from "./coValue.js";
import { Static } from "./coValues/static.js";
import { AnyCoValue, CoValue } from "./coValue.js";
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
import { CoMap } from "./coValues/coMap.js";
import {
@@ -27,18 +26,24 @@ import {
determineValidTransactions,
isKeyForKeyField,
} from "./permissions.js";
import { Group, expectGroupContent } from "./group.js";
import { LocalNode } from "./node.js";
import { Group, expectGroup } from "./coValues/group.js";
import { LocalNode } from "./localNode.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 {
Account,
AccountID,
GeneralizedControlledAccount,
isAccountID,
} from "./coValues/account.js";
import { Stringified, stableStringify } from "./jsonStringify.js";
import { coreToCoValue } from "./coreToCoValue.js";
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
export type CoValueHeader = {
type: CoValueImpl["type"];
type: AnyCoValue["type"];
ruleset: RulesetDef;
meta: JsonObject | null;
createdAt: `2${string}` | null;
@@ -99,8 +104,8 @@ export class CoValueCore {
node: LocalNode;
header: CoValueHeader;
_sessions: { [key: SessionID]: SessionLog };
_cachedContent?: CoValueImpl;
listeners: Set<(content?: CoValueImpl) => void> = new Set();
_cachedContent?: CoValue;
listeners: Set<(content?: CoValue) => void> = new Set();
_decryptionCache: {
[key: Encrypted<JsonValue[], JsonValue>]:
| Stringified<JsonValue[]>
@@ -164,7 +169,15 @@ export class CoValueCore {
}
nextTransactionID(): TransactionID {
const sessionID = this.node.currentSessionID;
// This is an ugly hack to get a unique but stable session ID for editing the current account
const sessionID =
this.header.meta?.type === "account"
? (this.node.currentSessionID.replace(
this.node.account.id,
this.node.account.currentAgentID()
) as SessionID)
: this.node.currentSessionID;
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
@@ -376,7 +389,7 @@ export class CoValueCore {
}
}
subscribe(listener: (content?: CoValueImpl) => void): () => void {
subscribe(listener: (content?: CoValue) => void): () => void {
this.listeners.add(listener);
listener(this.getCurrentContent());
@@ -468,7 +481,14 @@ export class CoValueCore {
};
}
const sessionID = this.node.currentSessionID;
// This is an ugly hack to get a unique but stable session ID for editing the current account
const sessionID =
this.header.meta?.type === "account"
? (this.node.currentSessionID.replace(
this.node.account.id,
this.node.account.currentAgentID()
) as SessionID)
: this.node.currentSessionID;
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
transaction,
@@ -487,41 +507,33 @@ export class CoValueCore {
);
if (success) {
void this.node.sync.syncCoValue(this);
void this.node.syncManager.syncCoValue(this);
}
return success;
}
getCurrentContent(): CoValueImpl {
if (this._cachedContent) {
getCurrentContent(options?: { ignorePrivateTransactions: true }): CoValue {
if (!options?.ignorePrivateTransactions && 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}`);
const newContent = coreToCoValue(this, options);
if (!options?.ignorePrivateTransactions) {
this._cachedContent = newContent;
}
return this._cachedContent;
return newContent;
}
getValidSortedTransactions(): DecryptedTransaction[] {
getValidSortedTransactions(options?: {
ignorePrivateTransactions: true;
}): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = validTransactions
.map(({ txID, tx }) => {
.flatMap(({ txID, tx }) => {
if (tx.privacy === "trusting") {
return {
txID,
@@ -529,6 +541,9 @@ export class CoValueCore {
changes: tx.changes,
};
} else {
if (options?.ignorePrivateTransactions) {
return undefined;
}
const readKey = this.getReadKey(tx.keyUsed);
if (!readKey) {
@@ -577,7 +592,7 @@ export class CoValueCore {
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "group") {
const content = expectGroupContent(this.getCurrentContent());
const content = expectGroup(this.getCurrentContent());
const currentKeyId = content.get("readKey");
@@ -603,45 +618,58 @@ export class CoValueCore {
}
getReadKey(keyID: KeyID): KeySecret | undefined {
if (readKeyCache.get(this)?.[keyID]) {
return readKeyCache.get(this)?.[keyID];
let key = readKeyCache.get(this)?.[keyID];
if (!key) {
key = this.getUncachedReadKey(keyID);
if (key) {
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = key;
}
}
return key;
}
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
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}`
const content = expectGroup(
this.getCurrentContent({ ignorePrivateTransactions: true })
);
if (readKeyEntry) {
const revealer = accountOrAgentIDfromSessionID(
readKeyEntry.txID.sessionID
);
const keyForEveryone = content.get(`${keyID}_for_everyone`);
if (keyForEveryone) return keyForEveryone;
// Try to find key revelation for us
const lookupAccountOrAgentID =
this.header.meta?.type === "account"
? this.node.account.currentAgentID()
: this.node.account.id;
const lastReadyKeyEdit = content.lastEditAt(
`${keyID}_for_${lookupAccountOrAgentID}`
);
if (lastReadyKeyEdit?.value) {
const revealer = lastReadyKeyEdit.by;
const revealerAgent = this.node.resolveAccountAgent(
revealer,
"Expected to know revealer"
);
const secret = unseal(
readKeyEntry.value,
lastReadyKeyEdit.value,
this.node.account.currentSealerSecret(),
getAgentSealerID(revealerAgent),
{
in: this.id,
tx: readKeyEntry.txID,
tx: lastReadyKeyEdit.tx,
}
);
if (secret) {
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = secret;
return secret as KeySecret;
}
}
@@ -670,13 +698,6 @@ export class CoValueCore {
);
if (secret) {
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = secret;
return secret as KeySecret;
} else {
console.error(
@@ -703,13 +724,10 @@ export class CoValueCore {
throw new Error("Only values owned by groups have groups");
}
return new Group(
expectGroupContent(
this.node
.expectCoValueLoaded(this.header.ruleset.group)
.getCurrentContent()
),
return expectGroup(
this.node
.expectCoValueLoaded(this.header.ruleset.group)
.getCurrentContent()
);
}
@@ -784,15 +802,16 @@ export class CoValueCore {
sessionEntry = {
after: sentState[sessionID] ?? 0,
newTransactions: [],
lastSignature: "WILL_BE_REPLACED" as Signature
lastSignature: "WILL_BE_REPLACED" as Signature,
};
currentPiece.new[sessionID] = sessionEntry;
}
sessionEntry.newTransactions.push(...txsToAdd);
sessionEntry.lastSignature = nextKnownSignatureIdx === undefined
? log.lastSignature!
: log.signatureAfter[nextKnownSignatureIdx]!
sessionEntry.lastSignature =
nextKnownSignatureIdx === undefined
? log.lastSignature!
: log.signatureAfter[nextKnownSignatureIdx]!;
sentState[sessionID] =
(sentState[sessionID] || 0) + txsToAdd.length;
@@ -812,11 +831,25 @@ export class CoValueCore {
getDependedOnCoValues(): RawCoID[] {
return this.header.ruleset.type === "group"
? expectGroupContent(this.getCurrentContent())
? expectGroup(this.getCurrentContent())
.keys()
.filter((k): k is AccountID => k.startsWith("co_"))
: this.header.ruleset.type === "ownedByGroup"
? [this.header.ruleset.group]
? [
this.header.ruleset.group,
...new Set(
Object.keys(this._sessions)
.map((sessionID) =>
accountOrAgentIDfromSessionID(
sessionID as SessionID
)
)
.filter(
(session): session is AccountID =>
isAccountID(session) && session !== this.id
)
),
]
: [];
}
}

View File

@@ -1,5 +1,5 @@
import { CoValueHeader } from "./coValueCore.js";
import { CoID } from "./coValue.js";
import { CoValueCore, CoValueHeader } from "../coValueCore.js";
import { CoID, CoValue } from "../coValue.js";
import {
AgentSecret,
SealerID,
@@ -11,11 +11,10 @@ import {
getAgentSealerSecret,
getAgentSignerID,
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";
} from "../crypto.js";
import { AgentID } from "../ids.js";
import { CoMap } from "./coMap.js";
import { Group, InviteSecret } from "./group.js";
export function accountHeaderForInitialAgentSecret(
agentSecret: AgentSecret
@@ -32,15 +31,15 @@ export function accountHeaderForInitialAgentSecret(
};
}
export class Account extends Group {
get id(): AccountID {
return this.underlyingMap.id as AccountID;
}
export class Account<
P extends Profile = Profile,
R extends CoMap = CoMap,
Meta extends AccountMeta = AccountMeta
> extends Group<P, R, Meta> {
getCurrentAgentID(): AgentID {
const agents = this.underlyingMap
.keys()
.filter((k): k is AgentID => k.startsWith("sealer_"));
const agents = this.keys().filter((k): k is AgentID =>
k.startsWith("sealer_")
);
if (agents.length !== 1) {
throw new Error(
@@ -64,22 +63,37 @@ export interface GeneralizedControlledAccount {
}
/** @hidden */
export class ControlledAccount
extends Account
export class ControlledAccount<
P extends Profile = Profile,
R extends CoMap = CoMap,
Meta extends AccountMeta = AccountMeta
>
extends Account<P, R, Meta>
implements GeneralizedControlledAccount
{
agentSecret: AgentSecret;
constructor(
agentSecret: AgentSecret,
groupMap: CoMap<AccountContent, AccountMeta>,
node: LocalNode
) {
super(groupMap, node);
constructor(core: CoValueCore, agentSecret: AgentSecret) {
super(core);
this.agentSecret = agentSecret;
}
/**
* Creates a new group (with the current account as the group's first admin).
* @category 1. High-level
*/
createGroup() {
return this.core.node.createGroup();
}
async acceptInvite<T extends CoValue>(
groupOrOwnedValueID: CoID<T>,
inviteSecret: InviteSecret
): Promise<void> {
return this.core.node.acceptInvite(groupOrOwnedValueID, inviteSecret);
}
currentAgentID(): AgentID {
return getAgentID(this.agentSecret);
}
@@ -136,17 +150,22 @@ export class AnonymousControlledAccount
}
}
export type AccountContent = GroupContent & { profile: CoID<Profile> };
export type AccountMeta = { type: "account" };
export type AccountMap = CoMap<AccountContent, AccountMeta>;
export type AccountID = CoID<AccountMap>;
export type AccountID = CoID<Account>;
export function isAccountID(id: AccountID | AgentID): id is AccountID {
return id.startsWith("co_");
}
export type ProfileContent = {
export type ProfileShape = {
name: string;
};
export type ProfileMeta = { type: "profile" };
export type Profile = CoMap<ProfileContent, ProfileMeta>;
export class Profile<Shape extends ProfileShape = ProfileShape, Meta extends ProfileMeta = ProfileMeta> extends CoMap<Shape, Meta> {
}
export type AccountMigration< P extends Profile = Profile,
R extends CoMap = CoMap,
Meta extends AccountMeta = AccountMeta> = (account: ControlledAccount<P, R, Meta>, profile: P) => void;

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