Compare commits
34 Commits
cojson-sim
...
cojson-sim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27779ac792 | ||
|
|
3f1bfa4629 | ||
|
|
15a693c3ed | ||
|
|
b1d620e145 | ||
|
|
478fbd0aa9 | ||
|
|
ee906b7351 | ||
|
|
dd15f21ccb | ||
|
|
d7cd5fda7c | ||
|
|
174300b00f | ||
|
|
b2c8d8c855 | ||
|
|
2bad2b6bfe | ||
|
|
880d0ff855 | ||
|
|
e66cbee6cd | ||
|
|
03e470721e | ||
|
|
ecf73bcfa7 | ||
|
|
2c3a500286 | ||
|
|
8b83061cf4 | ||
|
|
e75c3207d6 | ||
|
|
41d4b5ba0b | ||
|
|
21fa1b168b | ||
|
|
91e5e7f2ab | ||
|
|
e3f7e2f1bd | ||
|
|
084cf80c60 | ||
|
|
632e3bbb08 | ||
|
|
17d17833b2 | ||
|
|
8e22bd9c1e | ||
|
|
98213743f3 | ||
|
|
bb855ed83d | ||
|
|
a8ef49e228 | ||
|
|
e0ad32dbd2 | ||
|
|
62bf769cad | ||
|
|
7488ff25b2 | ||
|
|
b69c9da983 | ||
|
|
d30fdef8aa |
4
.github/workflows/build-and-deploy.yaml
vendored
4
.github/workflows/build-and-deploy.yaml
vendored
@@ -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
|
||||
|
||||
@@ -113,4 +113,4 @@ In the future we'll build a dedicated docs page on the Jazz homepage.
|
||||
|
||||
----
|
||||
|
||||
Copyright 2023: Garden Computing, Inc.
|
||||
Copyright 2023 — Garden Computing, Inc.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,9 +16,9 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.3.3",
|
||||
"jazz-react-auth-local": "^0.3.3",
|
||||
"jazz-react-media-images": "^0.3.3",
|
||||
"jazz-browser-media-images": "^0.4.1",
|
||||
"jazz-react": "^0.4.1",
|
||||
"jazz-react-auth-local": "^0.4.1",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { CoMap, 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, CoStream, Media } from "cojson";
|
||||
|
||||
export type PetPost = CoMap<{
|
||||
name: string;
|
||||
image: Media.ImageDefinition;
|
||||
reactions: 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 */
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import {
|
||||
Link,
|
||||
RouterProvider,
|
||||
createHashRouter,
|
||||
} from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
|
||||
@@ -14,6 +18,8 @@ import {
|
||||
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/>`
|
||||
*
|
||||
@@ -31,9 +37,14 @@ const auth = LocalAuth({
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -50,6 +61,10 @@ export default function App() {
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <PostOverview />,
|
||||
},
|
||||
{
|
||||
path: "/new",
|
||||
element: <NewPetPostForm />,
|
||||
},
|
||||
{
|
||||
@@ -65,22 +80,40 @@ export default function App() {
|
||||
useAcceptInvite((petPostID) => router.navigate("/pet/" + petPostID));
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<RouterProvider router={router} />
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: continue with ./3_CreatePetPostForm.tsx */
|
||||
export function PostOverview() {
|
||||
const { me } = useJazz<Profile, PetAccountRoot>();
|
||||
|
||||
/** Walkthrough: Continue with ./1_types.ts */
|
||||
const myPosts = me.root?.posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
root: {JSON.stringify(me.root?.coMap.asObject())}
|
||||
posts: {JSON.stringify(me.root?.posts?.coList?.asArray())}
|
||||
<h1>My posts</h1>
|
||||
{myPosts?.length
|
||||
? myPosts.map(
|
||||
(post) =>
|
||||
post && (
|
||||
<Link key={post.id} to={"/pet/" + post.id}>
|
||||
{post.name}
|
||||
</Link>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<Link to="/new">New post</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { CoID, CoMap, Media } from "cojson";
|
||||
import { CoID, CoMap, Media, Profile } from "cojson";
|
||||
import { useJazz, useSyncedQuery } from "jazz-react";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
import { BrowserImage, createImage } from "jazz-browser-media-images";
|
||||
|
||||
import { PetReactions } from "./1_types";
|
||||
import { PetAccountRoot, PetPost, PetReactions } from "./1_types";
|
||||
|
||||
import { Input, Button } from "./basicComponents";
|
||||
import { useLoadImage } from "jazz-react-media-images";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
type PartialPetPost = CoMap<{
|
||||
name: string;
|
||||
image?: Media.ImageDefinition;
|
||||
reactions: PetReactions;
|
||||
image?: Media.ImageDefinition["id"];
|
||||
reactions: PetReactions["id"];
|
||||
}>;
|
||||
|
||||
export function NewPetPostForm() {
|
||||
const { localNode } = useJazz();
|
||||
const { me } = useJazz<Profile, PetAccountRoot>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [newPostId, setNewPostId] = useState<
|
||||
@@ -34,16 +33,16 @@ export function NewPetPostForm() {
|
||||
if (newPetPost) {
|
||||
newPetPost.set({ name });
|
||||
} else {
|
||||
const petPostGroup = localNode.createGroup();
|
||||
const petPostGroup = me.createGroup();
|
||||
const petPost = petPostGroup.createMap<PartialPetPost>({
|
||||
name,
|
||||
reactions: petPostGroup.createStream<PetReactions>(),
|
||||
reactions: petPostGroup.createStream<PetReactions>().id,
|
||||
});
|
||||
|
||||
setNewPostId(petPost.id);
|
||||
}
|
||||
},
|
||||
[localNode, newPetPost]
|
||||
[me, newPetPost]
|
||||
);
|
||||
|
||||
const onImageSelected = useCallback(
|
||||
@@ -55,12 +54,23 @@ export function NewPetPostForm() {
|
||||
newPetPost.group
|
||||
);
|
||||
|
||||
newPetPost.set({ image });
|
||||
newPetPost.set({ image: image.id });
|
||||
},
|
||||
[newPetPost]
|
||||
);
|
||||
|
||||
const petImage = useLoadImage(newPetPost?.image?.id);
|
||||
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">
|
||||
@@ -73,10 +83,13 @@ export function NewPetPostForm() {
|
||||
value={newPetPost?.name || ""}
|
||||
/>
|
||||
|
||||
{petImage ? (
|
||||
{newPetPost?.image ? (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={petImage.highestResSrc || petImage.placeholderDataURL}
|
||||
src={
|
||||
newPetPost?.image.as(BrowserImage)
|
||||
?.highestResSrcOrPlaceholder
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -87,13 +100,7 @@ export function NewPetPostForm() {
|
||||
)}
|
||||
|
||||
{newPetPost?.name && newPetPost?.image && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate("/pet/" + newPetPost.id);
|
||||
}}
|
||||
>
|
||||
Submit Post
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>Submit Post</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PetPost, ReactionType, REACTION_TYPES, PetReactions } from "./1_types";
|
||||
|
||||
import { ShareButton } from "./components/ShareButton";
|
||||
import { Button, Skeleton } from "./basicComponents";
|
||||
import { useLoadImage } from "jazz-react-media-images";
|
||||
import { BrowserImage } from "jazz-browser-media-images";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
@@ -25,7 +25,6 @@ export function RatePetPostUI() {
|
||||
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
|
||||
|
||||
const petPost = useSyncedQuery(petPostID);
|
||||
const petImage = useLoadImage(petPost?.image);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -34,10 +33,13 @@ export function RatePetPostUI() {
|
||||
<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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -78,9 +80,9 @@ function ReactionOverview({
|
||||
<h2>Reactions</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{REACTION_TYPES.map((reactionType) => {
|
||||
const reactionsOfThisType = Object.values(
|
||||
petReactions.perAccount
|
||||
).filter(({ last }) => last === reactionType);
|
||||
const reactionsOfThisType = petReactions.perAccount
|
||||
.map(([, reaction]) => reaction)
|
||||
.filter(({ last }) => last === reactionType);
|
||||
|
||||
if (reactionsOfThisType.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.37",
|
||||
"version": "0.0.41",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,8 +16,8 @@
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.3.3",
|
||||
"jazz-react-auth-local": "^0.3.3",
|
||||
"jazz-react": "^0.4.1",
|
||||
"jazz-react-auth-local": "^0.4.1",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CoMap, CoList } from "cojson";
|
||||
import { CoMap, CoList, AccountMigration, Profile } from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
@@ -7,17 +7,36 @@ import { CoMap, CoList } from "cojson";
|
||||
*
|
||||
* CoMap values and CoLists items can contain:
|
||||
* - arbitrary immutable JSON
|
||||
* - references to other CoValues (internally stored by their CoID)
|
||||
* - references to other CoValues by their CoID
|
||||
**/
|
||||
|
||||
/** An individual task which collaborators can tick or rename */
|
||||
export type Task = CoMap<{ done: boolean; text: string; }>;
|
||||
|
||||
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;
|
||||
/** A collaborative, ordered list of tasks */
|
||||
tasks: CoList<Task>;
|
||||
tasks: ListOfTasks["id"];
|
||||
}>;
|
||||
|
||||
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 */
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import { NewProjectForm } from "./3_NewProjectForm.tsx";
|
||||
import { ProjectTodoTable } from "./4_ProjectTodoTable.tsx";
|
||||
import { migration } from "./1_types.ts";
|
||||
import { AccountMigration } from "cojson";
|
||||
|
||||
/**
|
||||
* Walkthrough: The top-level provider `<WithJazz/>`
|
||||
@@ -33,9 +35,14 @@ const auth = LocalAuth({
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -62,8 +69,8 @@ function App() {
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>
|
||||
}
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
// `useAcceptInvite()` is a hook that accepts an invite link from the URL hash,
|
||||
@@ -71,20 +78,16 @@ function App() {
|
||||
useAcceptInvite((projectID) => router.navigate("/project/" + projectID));
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { Task, TodoProject } from "./1_types";
|
||||
import { ListOfTasks, TodoProject } from "./1_types";
|
||||
|
||||
import { SubmittableInput } from "./basicComponents";
|
||||
|
||||
import { CoList } from "cojson";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export function NewProjectForm() {
|
||||
@@ -28,7 +27,7 @@ export function NewProjectForm() {
|
||||
// Then we create an empty todo project within that group
|
||||
const project = projectGroup.createMap<TodoProject>({
|
||||
title,
|
||||
tasks: projectGroup.createList<CoList<Task>>(),
|
||||
tasks: projectGroup.createList<ListOfTasks>().id,
|
||||
});
|
||||
|
||||
navigate("/project/" + project.id);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ProjectTodoTable() {
|
||||
// project.tasks is immutable, but `append` will create an edit
|
||||
// that will cause useSyncedQuery to rerender this component
|
||||
// - here and on other devices!
|
||||
project.tasks.append(task);
|
||||
project.tasks.append(task.id);
|
||||
},
|
||||
[project?.tasks, project?.group]
|
||||
);
|
||||
|
||||
18
examples/twit/.eslintrc.cjs
Normal file
18
examples/twit/.eslintrc.cjs
Normal 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
24
examples/twit/.gitignore
vendored
Normal 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
11
examples/twit/.prettierrc
Normal 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
4
examples/twit/Dockerfile
Normal 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
64
examples/twit/README.md
Normal 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).
|
||||
16
examples/twit/components.json
Normal file
16
examples/twit/components.json
Normal 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
13
examples/twit/index.html
Normal 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>Jazz Todo List Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
examples/twit/job-template.nomad
Normal file
56
examples/twit/job-template.nomad
Normal 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
|
||||
50
examples/twit/package.json
Normal file
50
examples/twit/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "jazz-example-twit",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"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.1",
|
||||
"jazz-react": "^0.4.1",
|
||||
"jazz-react-auth-local": "^0.4.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
examples/twit/postcss.config.js
Normal file
6
examples/twit/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
examples/twit/public/jazz-logo.png
Normal file
BIN
examples/twit/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
64
examples/twit/src/1_dataModel.ts
Normal file
64
examples/twit/src/1_dataModel.ts
Normal 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!');
|
||||
}
|
||||
};
|
||||
71
examples/twit/src/2_main.tsx
Normal file
71
examples/twit/src/2_main.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
examples/twit/src/3_ChronoFeed.tsx
Normal file
35
examples/twit/src/3_ChronoFeed.tsx
Normal 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.edits.text?.at?.getTime() || 0) - (a.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>
|
||||
);
|
||||
}
|
||||
101
examples/twit/src/4_TwitComponent.tsx
Normal file
101
examples/twit/src/4_TwitComponent.tsx
Normal 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 { Queried } from 'cojson';
|
||||
import { BrowserImage } from 'jazz-browser-media-images';
|
||||
import { HeartIcon, MessagesSquareIcon } from 'lucide-react';
|
||||
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
|
||||
|
||||
export function TwitComponent({
|
||||
twit,
|
||||
alreadyInReplies: alreadyInReplies
|
||||
}: {
|
||||
twit?: Queried<Twit>;
|
||||
alreadyInReplies?: boolean;
|
||||
}) {
|
||||
const [showReplyForm, setShowReplyForm] = React.useState(false);
|
||||
|
||||
const posterProfile = twit?.edits.text?.by?.profile as Queried<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?.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>
|
||||
);
|
||||
}
|
||||
127
examples/twit/src/5_ProfilePage.tsx
Normal file
127
examples/twit/src/5_ProfilePage.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useJazz, useSyncedQuery } 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 = useSyncedQuery(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.group, 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">—</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>
|
||||
);
|
||||
}
|
||||
74
examples/twit/src/6_CreateTwitForm.tsx
Normal file
74
examples/twit/src/6_CreateTwitForm.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useJazz } from 'jazz-react';
|
||||
import { AddTwitPicsInput, TwitImg, TwitTextInput } from './basicComponents/index.tsx';
|
||||
import { LikeStream, ListOfImages, ReplyStream, Twit, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { Queried } from 'cojson';
|
||||
import { createImage } from 'jazz-browser-media-images';
|
||||
|
||||
export function CreateTwitForm(
|
||||
props: {
|
||||
inReplyTo?: Queried<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>
|
||||
);
|
||||
}
|
||||
81
examples/twit/src/7_FollowStuff.tsx
Normal file
81
examples/twit/src/7_FollowStuff.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useJazz } from 'jazz-react';
|
||||
import { Button, ProfilePicImg } from './basicComponents/index.tsx';
|
||||
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { Queried } from 'cojson';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BrowserImage } from 'jazz-browser-media-images';
|
||||
|
||||
export function FollowButton({ profile }: { profile?: Queried<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?: Queried<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?: Queried<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>
|
||||
);
|
||||
}
|
||||
39
examples/twit/src/basicComponents/SubmittableInput.tsx
Normal file
39
examples/twit/src/basicComponents/SubmittableInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
examples/twit/src/basicComponents/TitleAndLogo.tsx
Normal file
10
examples/twit/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Toaster } from ".";
|
||||
|
||||
export function TitleAndLogo({name}: {name: string}) {
|
||||
return <>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
}
|
||||
228
examples/twit/src/basicComponents/index.tsx
Normal file
228
examples/twit/src/basicComponents/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
examples/twit/src/basicComponents/lib/utils.ts
Normal file
6
examples/twit/src/basicComponents/lib/utils.ts
Normal 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))
|
||||
}
|
||||
72
examples/twit/src/basicComponents/themeProvider.tsx
Normal file
72
examples/twit/src/basicComponents/themeProvider.tsx
Normal 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;
|
||||
};
|
||||
56
examples/twit/src/basicComponents/ui/button.tsx
Normal file
56
examples/twit/src/basicComponents/ui/button.tsx
Normal 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 }
|
||||
79
examples/twit/src/basicComponents/ui/card.tsx
Normal file
79
examples/twit/src/basicComponents/ui/card.tsx
Normal 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 }
|
||||
28
examples/twit/src/basicComponents/ui/checkbox.tsx
Normal file
28
examples/twit/src/basicComponents/ui/checkbox.tsx
Normal 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 }
|
||||
25
examples/twit/src/basicComponents/ui/input.tsx
Normal file
25
examples/twit/src/basicComponents/ui/input.tsx
Normal 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 }
|
||||
29
examples/twit/src/basicComponents/ui/popover.tsx
Normal file
29
examples/twit/src/basicComponents/ui/popover.tsx
Normal 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 }
|
||||
15
examples/twit/src/basicComponents/ui/skeleton.tsx
Normal file
15
examples/twit/src/basicComponents/ui/skeleton.tsx
Normal 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 }
|
||||
114
examples/twit/src/basicComponents/ui/table.tsx
Normal file
114
examples/twit/src/basicComponents/ui/table.tsx
Normal 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,
|
||||
}
|
||||
127
examples/twit/src/basicComponents/ui/toast.tsx
Normal file
127
examples/twit/src/basicComponents/ui/toast.tsx
Normal 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,
|
||||
}
|
||||
33
examples/twit/src/basicComponents/ui/toaster.tsx
Normal file
33
examples/twit/src/basicComponents/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
examples/twit/src/basicComponents/ui/use-toast.ts
Normal file
192
examples/twit/src/basicComponents/ui/use-toast.ts
Normal 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 }
|
||||
48
examples/twit/src/components/Auth.tsx
Normal file
48
examples/twit/src/components/Auth.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
76
examples/twit/src/index.css
Normal file
76
examples/twit/src/index.css
Normal 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
1
examples/twit/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
76
examples/twit/tailwind.config.js
Normal file
76
examples/twit/tailwind.config.js
Normal 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")],
|
||||
}
|
||||
29
examples/twit/tsconfig.json
Normal file
29
examples/twit/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
examples/twit/tsconfig.node.json
Normal file
10
examples/twit/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/twit/vite.config.ts
Normal file
16
examples/twit/vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -10,7 +10,6 @@ async function main() {
|
||||
const packageDocs = Object.entries({
|
||||
"jazz-react": "index.tsx",
|
||||
cojson: "index.ts",
|
||||
"jazz-react-media-images": "index.tsx",
|
||||
"jazz-browser": "index.ts",
|
||||
"jazz-browser-media-images": "index.ts",
|
||||
}).map(async ([packageName, entryPoint]) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
@@ -16,8 +16,8 @@
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.3",
|
||||
"cojson-storage-sqlite": "^0.3.3",
|
||||
"cojson": "^0.4.1",
|
||||
"cojson-storage-sqlite": "^0.4.1",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.3",
|
||||
"cojson": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Peer,
|
||||
CojsonInternalTypes,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
AccountID,
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
@@ -141,7 +142,9 @@ export class IDBStorage {
|
||||
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());
|
||||
@@ -266,7 +269,8 @@ export class IDBStorage {
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
lastSignature:
|
||||
"WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
@@ -328,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) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.3.3",
|
||||
"cojson": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
AccountID
|
||||
} from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
@@ -237,19 +238,31 @@ export class SQLiteStorage {
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const parsedHeader = (coValueRow?.header &&
|
||||
JSON.parse(coValueRow.header)) as
|
||||
| CojsonInternalTypes.CoValueHeader
|
||||
| undefined;
|
||||
let parsedHeader;
|
||||
|
||||
const newContentPieces: 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;
|
||||
@@ -265,7 +278,10 @@ export class SQLiteStorage {
|
||||
.prepare<[number, number]>(
|
||||
`SELECT * FROM signatureAfter WHERE ses = ? AND idx >= ?`
|
||||
)
|
||||
.all(sessionRow.rowID, firstNewTxIdx) as SignatureAfterRow[];
|
||||
.all(
|
||||
sessionRow.rowID,
|
||||
firstNewTxIdx
|
||||
) as SignatureAfterRow[];
|
||||
|
||||
// console.log(
|
||||
// theirKnown.id,
|
||||
@@ -295,7 +311,8 @@ export class SQLiteStorage {
|
||||
if (!sessionEntry) {
|
||||
sessionEntry = {
|
||||
after: idx,
|
||||
lastSignature: "WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
lastSignature:
|
||||
"WILL_BE_REPLACED" as CojsonInternalTypes.Signature,
|
||||
newTransactions: [],
|
||||
};
|
||||
newContentPieces[newContentPieces.length - 1]!.new[
|
||||
@@ -303,7 +320,21 @@ export class SQLiteStorage {
|
||||
] = sessionEntry;
|
||||
}
|
||||
|
||||
sessionEntry.newTransactions.push(JSON.parse(tx.tx));
|
||||
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] &&
|
||||
@@ -331,30 +362,65 @@ export class SQLiteStorage {
|
||||
const dependedOnCoValues =
|
||||
parsedHeader?.ruleset.type === "group"
|
||||
? newContentPieces
|
||||
.flatMap((piece) => Object.values(piece.new)).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
// TODO: avoid parsing here?
|
||||
return cojsonInternals
|
||||
.parseJSON(tx.changes)
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
.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) {
|
||||
@@ -499,7 +565,7 @@ export class SQLiteStorage {
|
||||
sessionUpdate.sessionID,
|
||||
sessionUpdate.lastIdx,
|
||||
sessionUpdate.lastSignature,
|
||||
sessionUpdate.bytesSinceLastSignature,
|
||||
sessionUpdate.bytesSinceLastSignature
|
||||
) as { rowID: number };
|
||||
|
||||
const sessionRowID = upsertedSession.rowID;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} 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 CoValue> = RawCoID & {
|
||||
readonly __type: T;
|
||||
@@ -37,55 +38,35 @@ export interface CoValue {
|
||||
subscribe(listener: (coValue: this) => void): () => void;
|
||||
}
|
||||
|
||||
export type AnyCoMap = CoMap<
|
||||
{ [key: string]: JsonValue | CoValue | undefined },
|
||||
JsonObject | null
|
||||
>;
|
||||
export type AnyCoValue = CoMap | Group | Account | Profile | CoList | CoStream | BinaryCoStream;
|
||||
|
||||
export type AnyCoList = CoList<JsonValue | CoValue, JsonObject | null>;
|
||||
|
||||
export type AnyCoStream = CoStream<JsonValue | CoValue, JsonObject | null>;
|
||||
|
||||
export type AnyBinaryCoStream = BinaryCoStream<BinaryCoStreamMeta>;
|
||||
|
||||
|
||||
export type AnyCoValue =
|
||||
| AnyCoMap
|
||||
| AnyCoList
|
||||
| AnyCoStream
|
||||
| AnyBinaryCoStream
|
||||
|
||||
export function expectMap(
|
||||
content: CoValue
|
||||
): AnyCoMap {
|
||||
export function expectMap(content: CoValue): CoMap {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as AnyCoMap;
|
||||
return content as CoMap;
|
||||
}
|
||||
|
||||
export function expectList(
|
||||
content: CoValue
|
||||
): AnyCoList {
|
||||
export function expectList(content: CoValue): CoList {
|
||||
if (content.type !== "colist") {
|
||||
throw new Error("Expected list");
|
||||
}
|
||||
|
||||
return content as AnyCoList;
|
||||
return content as CoList;
|
||||
}
|
||||
|
||||
export function expectStream(
|
||||
content: CoValue
|
||||
): AnyCoStream {
|
||||
export function expectStream(content: CoValue): CoStream {
|
||||
if (content.type !== "costream") {
|
||||
throw new Error("Expected stream");
|
||||
}
|
||||
|
||||
return content as AnyCoStream;
|
||||
return content as CoStream;
|
||||
}
|
||||
|
||||
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
||||
export function isCoValue(
|
||||
value: JsonValue | CoValue | undefined
|
||||
): value is CoValue {
|
||||
return (
|
||||
value instanceof CoMap ||
|
||||
value instanceof CoList ||
|
||||
|
||||
@@ -26,12 +26,17 @@ import {
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.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";
|
||||
|
||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
||||
@@ -163,7 +168,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,
|
||||
@@ -467,7 +480,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,
|
||||
@@ -492,33 +512,51 @@ export class CoValueCore {
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): CoValue {
|
||||
if (this._cachedContent) {
|
||||
getCurrentContent(options?: { ignorePrivateTransactions: true }): CoValue {
|
||||
if (!options?.ignorePrivateTransactions && this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
let newContent;
|
||||
if (this.header.type === "comap") {
|
||||
this._cachedContent = new CoMap(this);
|
||||
if (this.header.ruleset.type === "group") {
|
||||
if (
|
||||
this.header.meta?.type === "account" &&
|
||||
!options?.ignorePrivateTransactions
|
||||
) {
|
||||
newContent = new Account(this);
|
||||
} else {
|
||||
newContent = new Group(this, options);
|
||||
}
|
||||
} else {
|
||||
newContent = new CoMap(this);
|
||||
}
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
newContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
if (this.header.meta && this.header.meta.type === "binary") {
|
||||
this._cachedContent = new BinaryCoStream(this);
|
||||
newContent = new BinaryCoStream(this);
|
||||
} else {
|
||||
this._cachedContent = new CoStream(this);
|
||||
newContent = new CoStream(this);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this._cachedContent;
|
||||
if (!options?.ignorePrivateTransactions) {
|
||||
this._cachedContent = newContent;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -526,6 +564,9 @@ export class CoValueCore {
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
if (options?.ignorePrivateTransactions) {
|
||||
return undefined;
|
||||
}
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
@@ -574,7 +615,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");
|
||||
|
||||
@@ -600,16 +641,38 @@ 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());
|
||||
const content = expectGroup(
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true })
|
||||
);
|
||||
|
||||
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_${this.node.account.id}`
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
@@ -630,13 +693,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;
|
||||
}
|
||||
}
|
||||
@@ -665,13 +721,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(
|
||||
@@ -698,13 +747,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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -808,11 +854,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
|
||||
)
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "./localNode.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 AccountGroup 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 AccountGroup
|
||||
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 = { profile: Profile } & GroupContent;
|
||||
export type AccountMeta = { type: "account" };
|
||||
export type Account = CoMap<AccountContent, AccountMeta>;
|
||||
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;
|
||||
@@ -2,21 +2,21 @@ import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
import { Group } from "./group.js";
|
||||
|
||||
type OpID = TransactionID & { changeIdx: number };
|
||||
|
||||
type InsertionOpPayload<T extends JsonValue | CoValue> =
|
||||
type InsertionOpPayload<T extends JsonValue> =
|
||||
| {
|
||||
op: "pre";
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: T;
|
||||
before: OpID | "end";
|
||||
}
|
||||
| {
|
||||
op: "app";
|
||||
value: T extends CoValue ? CoID<T> : Exclude<T, CoValue>;
|
||||
value: T;
|
||||
after: OpID | "start";
|
||||
};
|
||||
|
||||
@@ -25,11 +25,11 @@ type DeletionOpPayload = {
|
||||
insertion: OpID;
|
||||
};
|
||||
|
||||
export type ListOpPayload<T extends JsonValue | CoValue> =
|
||||
export type ListOpPayload<T extends JsonValue> =
|
||||
| InsertionOpPayload<T>
|
||||
| DeletionOpPayload;
|
||||
|
||||
type InsertionEntry<T extends JsonValue | CoValue> = {
|
||||
type InsertionEntry<T extends JsonValue> = {
|
||||
madeAt: number;
|
||||
predecessors: OpID[];
|
||||
successors: OpID[];
|
||||
@@ -41,7 +41,7 @@ type DeletionEntry = {
|
||||
} & DeletionOpPayload;
|
||||
|
||||
export class CoListView<
|
||||
Item extends JsonValue | CoValue,
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> implements CoValue
|
||||
{
|
||||
@@ -220,11 +220,7 @@ export class CoListView<
|
||||
*
|
||||
* @category 1. Reading
|
||||
*/
|
||||
get(
|
||||
idx: number
|
||||
):
|
||||
| (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)
|
||||
| undefined {
|
||||
get(idx: number): Item | undefined {
|
||||
const entry = this.entries()[idx];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
@@ -237,18 +233,18 @@ export class CoListView<
|
||||
*
|
||||
* @category 1. Reading
|
||||
**/
|
||||
asArray(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
||||
asArray(): Item[] {
|
||||
return this.entries().map((entry) => entry.value);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
entries(): {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
entries(): {
|
||||
value: Item;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[] {
|
||||
const arr: {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[] = [];
|
||||
@@ -265,7 +261,7 @@ export class CoListView<
|
||||
private fillArrayFromOpID(
|
||||
opID: OpID,
|
||||
arr: {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
madeAt: number;
|
||||
opID: OpID;
|
||||
}[]
|
||||
@@ -299,7 +295,7 @@ export class CoListView<
|
||||
*
|
||||
* @category 1. Reading
|
||||
*/
|
||||
toJSON(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
||||
toJSON(): Item[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
@@ -309,7 +305,7 @@ export class CoListView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
}
|
||||
| undefined {
|
||||
const entry = this.entries()[idx];
|
||||
@@ -377,8 +373,8 @@ export class CoListView<
|
||||
}
|
||||
|
||||
export class CoList<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoListView<Item, Meta>
|
||||
implements CoValue
|
||||
@@ -392,7 +388,7 @@ export class CoList<
|
||||
* @category 2. Editing
|
||||
**/
|
||||
append(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
item: Item,
|
||||
after?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
@@ -440,7 +436,7 @@ export class CoList<
|
||||
* @category 2. Editing
|
||||
*/
|
||||
prepend(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
item: Item,
|
||||
before?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
@@ -518,8 +514,8 @@ export class CoList<
|
||||
}
|
||||
|
||||
export class MutableCoList<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoListView<Item, Meta>
|
||||
implements CoValue
|
||||
@@ -533,7 +529,7 @@ export class MutableCoList<
|
||||
* @category 2. Mutating
|
||||
**/
|
||||
append(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
item: Item,
|
||||
after?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
@@ -558,7 +554,7 @@ export class MutableCoList<
|
||||
* * @category 2. Mutating
|
||||
**/
|
||||
prepend(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
item: Item,
|
||||
before?: number,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
|
||||
@@ -2,25 +2,22 @@ import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { AgentID, TransactionID } from "../ids.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
import { Group } from "./group.js";
|
||||
|
||||
type MapOp<K extends string, V extends JsonValue | CoValue | undefined> = {
|
||||
type MapOp<K extends string, V extends JsonValue | undefined> = {
|
||||
txID: TransactionID;
|
||||
madeAt: number;
|
||||
changeIdx: number;
|
||||
} & MapOpPayload<K, V>;
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
export type MapOpPayload<
|
||||
K extends string,
|
||||
V extends JsonValue | CoValue | undefined
|
||||
> =
|
||||
export type MapOpPayload<K extends string, V extends JsonValue | undefined> =
|
||||
| {
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V extends CoValue ? CoID<V> : Exclude<V, CoValue>;
|
||||
value: V;
|
||||
}
|
||||
| {
|
||||
op: "del";
|
||||
@@ -28,8 +25,10 @@ export type MapOpPayload<
|
||||
};
|
||||
|
||||
export class CoMapView<
|
||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
Shape extends { [key: string]: JsonValue | undefined } = {
|
||||
[key: string]: JsonValue | undefined;
|
||||
},
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
> implements CoValue
|
||||
{
|
||||
/** @category 6. Meta */
|
||||
@@ -48,16 +47,17 @@ export class CoMapView<
|
||||
readonly _shape!: Shape;
|
||||
|
||||
/** @internal */
|
||||
constructor(core: CoValueCore) {
|
||||
constructor(
|
||||
core: CoValueCore,
|
||||
options?: { ignorePrivateTransactions: true }
|
||||
) {
|
||||
this.id = core.id as CoID<this>;
|
||||
this.core = core;
|
||||
this.ops = {};
|
||||
|
||||
for (const {
|
||||
txID,
|
||||
changes,
|
||||
madeAt,
|
||||
} of core.getValidSortedTransactions()) {
|
||||
for (const { txID, changes, madeAt } of core.getValidSortedTransactions(
|
||||
options
|
||||
)) {
|
||||
for (const [changeIdx, changeUntyped] of parseJSON(
|
||||
changes
|
||||
).entries()) {
|
||||
@@ -121,13 +121,11 @@ export class CoMapView<
|
||||
* Get all keys currently in the map.
|
||||
*
|
||||
* @category 1. Reading */
|
||||
keys(): (keyof Shape & string)[] {
|
||||
const keys = Object.keys(this.ops) as (keyof Shape & string)[];
|
||||
keys<K extends (keyof Shape & string) = (keyof Shape & string)>(): K[] {
|
||||
const keys = Object.keys(this.ops) as K[];
|
||||
|
||||
if (this.atTimeFilter) {
|
||||
return keys.filter((key) => {
|
||||
this.timeFilteredOps(key)?.length;
|
||||
});
|
||||
return keys.filter((key) => this.timeFilteredOps(key)?.length);
|
||||
} else {
|
||||
return keys;
|
||||
}
|
||||
@@ -138,13 +136,7 @@ export class CoMapView<
|
||||
*
|
||||
* @category 1. Reading
|
||||
**/
|
||||
get<K extends keyof Shape & string>(
|
||||
key: K
|
||||
):
|
||||
| (Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>)
|
||||
| undefined {
|
||||
get<K extends keyof Shape & string>(key: K): Shape[K] | undefined {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
@@ -164,14 +156,10 @@ export class CoMapView<
|
||||
|
||||
/** @category 1. Reading */
|
||||
asObject(): {
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
[K in keyof Shape & string]: Shape[K];
|
||||
} {
|
||||
const object: Partial<{
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
[K in keyof Shape & string]: Shape[K];
|
||||
}> = {};
|
||||
|
||||
for (const key of this.keys()) {
|
||||
@@ -182,17 +170,13 @@ export class CoMapView<
|
||||
}
|
||||
|
||||
return object as {
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
[K in keyof Shape & string]: Shape[K];
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 1. Reading */
|
||||
toJSON(): {
|
||||
[K in keyof Shape & string]: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
[K in keyof Shape & string]: Shape[K];
|
||||
} {
|
||||
return this.asObject();
|
||||
}
|
||||
@@ -206,9 +190,7 @@ export class CoMapView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value?: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
value?: Shape[K];
|
||||
}
|
||||
| undefined {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
@@ -238,9 +220,7 @@ export class CoMapView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value?: Shape[K] extends CoValue
|
||||
? CoID<Shape[K]>
|
||||
: Exclude<Shape[K], CoValue>;
|
||||
value?: Shape[K];
|
||||
}
|
||||
| undefined {
|
||||
const ops = this.timeFilteredOps(key);
|
||||
@@ -272,14 +252,14 @@ export class CoMapView<
|
||||
|
||||
/** A collaborative map with precise shape `Shape` and optional static metadata `Meta` */
|
||||
export class CoMap<
|
||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
Shape extends { [key: string]: JsonValue | undefined } = {
|
||||
[key: string]: JsonValue | undefined;
|
||||
},
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoMapView<Shape, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
|
||||
|
||||
/** Returns a new version of this CoMap with a new value for the given key.
|
||||
*
|
||||
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
|
||||
@@ -290,14 +270,12 @@ export class CoMap<
|
||||
**/
|
||||
set<K extends keyof Shape & string>(
|
||||
key: K,
|
||||
value: Shape[K] extends CoValue ? Shape[K] | CoID<Shape[K]> : Shape[K],
|
||||
value: Shape[K],
|
||||
privacy?: "private" | "trusting"
|
||||
): this;
|
||||
set(
|
||||
kv: {
|
||||
[K in keyof Shape & string]?: Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K];
|
||||
[K in keyof Shape & string]?: Shape[K];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): this;
|
||||
@@ -305,19 +283,11 @@ export class CoMap<
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof Shape & string]?: Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K];
|
||||
[K in keyof Shape & string]?: Shape[K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [
|
||||
K,
|
||||
Shape[K] extends CoValue
|
||||
? Shape[K] | CoID<Shape[K]>
|
||||
: Shape[K],
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [K, Shape[K], ("private" | "trusting")?]
|
||||
): this {
|
||||
if (typeof args[0] === "string") {
|
||||
const [key, value, privacy = "private"] = args;
|
||||
@@ -387,7 +357,9 @@ export class CoMap<
|
||||
mutate(mutator: (mutable: MutableCoMap<Shape, Meta>) => void): this {
|
||||
const mutable = new MutableCoMap<Shape, Meta>(this.core);
|
||||
mutator(mutable);
|
||||
return new CoMap(this.core) as this;
|
||||
return new (this.constructor as new (core: CoValueCore) => this)(
|
||||
this.core
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use `mutate` instead. */
|
||||
@@ -397,8 +369,10 @@ export class CoMap<
|
||||
}
|
||||
|
||||
export class MutableCoMap<
|
||||
Shape extends { [key: string]: JsonValue | CoValue | undefined },
|
||||
Meta extends JsonObject | null = null
|
||||
Shape extends { [key: string]: JsonValue | undefined } = {
|
||||
[key: string]: JsonValue | undefined;
|
||||
},
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoMapView<Shape, Meta>
|
||||
implements CoValue
|
||||
@@ -413,7 +387,7 @@ export class MutableCoMap<
|
||||
*/
|
||||
set<K extends keyof Shape & string>(
|
||||
key: K,
|
||||
value: Shape[K] extends CoValue ? Shape[K] | CoID<Shape[K]> : Shape[K],
|
||||
value: Shape[K],
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): void {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoValue, CoID, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { Group } from "./group.js";
|
||||
import { AgentID, SessionID, TransactionID } from "../ids.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { AccountID, isAccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
|
||||
export type BinaryStreamInfo = {
|
||||
@@ -33,15 +33,15 @@ export type BinaryStreamItem =
|
||||
| BinaryStreamChunk
|
||||
| BinaryStreamEnd;
|
||||
|
||||
export type CoStreamItem<Item extends JsonValue | CoValue> = {
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
export type CoStreamItem<Item extends JsonValue> = {
|
||||
value: Item;
|
||||
tx: TransactionID;
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
export class CoStreamView<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
> implements CoValue
|
||||
{
|
||||
id: CoID<this>;
|
||||
@@ -82,9 +82,7 @@ export class CoStreamView<
|
||||
changes,
|
||||
} of this.core.getValidSortedTransactions()) {
|
||||
for (const changeUntyped of parseJSON(changes)) {
|
||||
const change = changeUntyped as Item extends CoValue
|
||||
? CoID<Item>
|
||||
: Exclude<Item, CoValue>;
|
||||
const change = changeUntyped as Item;
|
||||
let entries = this.items[txID.sessionID];
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
@@ -96,7 +94,7 @@ export class CoStreamView<
|
||||
}
|
||||
|
||||
getSingleStream():
|
||||
| (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[]
|
||||
| (Item)[]
|
||||
| undefined {
|
||||
if (Object.keys(this.items).length === 0) {
|
||||
return undefined;
|
||||
@@ -113,8 +111,8 @@ export class CoStreamView<
|
||||
return Object.keys(this.items) as SessionID[];
|
||||
}
|
||||
|
||||
accounts(): Set<AccountID | AgentID> {
|
||||
return new Set(this.sessions().map(accountOrAgentIDfromSessionID));
|
||||
accounts(): Set<AccountID> {
|
||||
return new Set(this.sessions().map(accountOrAgentIDfromSessionID).filter(isAccountID));
|
||||
}
|
||||
|
||||
nthItemIn(
|
||||
@@ -125,7 +123,7 @@ export class CoStreamView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
}
|
||||
| undefined {
|
||||
const items = this.items[sessionID];
|
||||
@@ -147,7 +145,7 @@ export class CoStreamView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
}
|
||||
| undefined {
|
||||
const items = this.items[sessionID];
|
||||
@@ -163,7 +161,7 @@ export class CoStreamView<
|
||||
by: accountOrAgentIDfromSessionID(sessionID),
|
||||
tx: item.tx,
|
||||
at: new Date(item.madeAt),
|
||||
value: item.value,
|
||||
value: item.value as Item,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -173,7 +171,7 @@ export class CoStreamView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
}
|
||||
| undefined {
|
||||
let latestItem:
|
||||
@@ -181,9 +179,7 @@ export class CoStreamView<
|
||||
by: AccountID | AgentID;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: Item extends CoValue
|
||||
? CoID<Item>
|
||||
: Exclude<Item, CoValue>;
|
||||
value: Item;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -226,9 +222,7 @@ export class CoStreamView<
|
||||
}
|
||||
|
||||
toJSON(): {
|
||||
[key: SessionID]: (Item extends CoValue
|
||||
? CoID<Item>
|
||||
: Exclude<Item, CoValue>)[];
|
||||
[key: SessionID]: (Item )[];
|
||||
} {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.items).map(([sessionID, items]) => [
|
||||
@@ -246,14 +240,14 @@ export class CoStreamView<
|
||||
}
|
||||
|
||||
export class CoStream<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoStreamView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
push(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
item: Item,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
): this {
|
||||
this.core.makeTransaction([isCoValue(item) ? item.id : item], privacy);
|
||||
@@ -273,14 +267,14 @@ export class CoStream<
|
||||
}
|
||||
|
||||
export class MutableCoStream<
|
||||
Item extends JsonValue | CoValue,
|
||||
Meta extends JsonObject | null = null
|
||||
Item extends JsonValue,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
>
|
||||
extends CoStreamView<Item, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
push(
|
||||
item: Item extends CoValue ? Item | CoID<Item> : Item,
|
||||
item: Item,
|
||||
privacy: "private" | "trusting" = "private"
|
||||
) {
|
||||
this.core.makeTransaction([isCoValue(item) ? item.id : item], privacy);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CoID, CoValue, AnyCoValue, AnyCoMap, AnyCoList } from "./coValue.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { CoID, CoValue, expectMap } from "../coValue.js";
|
||||
import { CoMap } from "./coMap.js";
|
||||
import { CoList } from "./coList.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { BinaryCoStream, CoStream } from "./coStream.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
@@ -14,37 +16,40 @@ import {
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import { AgentID, SessionID, isAgentID } from "./ids.js";
|
||||
import { AccountID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
import { Role } from "./permissions.js";
|
||||
} from "../crypto.js";
|
||||
import { AgentID, isAgentID } from "../ids.js";
|
||||
import { AccountID, Profile } from "./account.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
BinaryCoStreamMeta,
|
||||
CoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
|
||||
export type GroupContent = {
|
||||
profile?: CoID<Profile> | null;
|
||||
export const EVERYONE = "everyone" as const;
|
||||
export type Everyone = "everyone";
|
||||
|
||||
export type GroupShape<P extends Profile, R extends CoMap> = {
|
||||
profile?: CoID<P> | null;
|
||||
root?: CoID<R> | null;
|
||||
[key: AccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
readKey?: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectGroupContent(
|
||||
content: CoValue
|
||||
): CoMap<GroupContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
export function expectGroup(content: CoValue): Group {
|
||||
const map = expectMap(content);
|
||||
if (map.core.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
return content as CoMap<GroupContent, JsonObject | null>;
|
||||
if (!(map instanceof Group)) {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
@@ -68,30 +73,11 @@ export function expectGroupContent(
|
||||
* const localNode.createGroup();
|
||||
* ```
|
||||
* */
|
||||
export class Group {
|
||||
/** @category 4. Underlying CoMap */
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>;
|
||||
/** @internal */
|
||||
node: LocalNode;
|
||||
|
||||
/** @internal */
|
||||
constructor(
|
||||
underlyingMap: CoMap<GroupContent, JsonObject | null>,
|
||||
node: LocalNode
|
||||
) {
|
||||
this.underlyingMap = underlyingMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `CoID` of the `Group`.
|
||||
*
|
||||
* @category 4. Underlying CoMap
|
||||
*/
|
||||
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
|
||||
return this.underlyingMap.id;
|
||||
}
|
||||
|
||||
export class Group<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends JsonObject | null = JsonObject | null
|
||||
> extends CoMap<GroupShape<P, R>, Meta> {
|
||||
/**
|
||||
* Returns the current role of a given account.
|
||||
*
|
||||
@@ -103,7 +89,7 @@ export class Group {
|
||||
|
||||
/** @internal */
|
||||
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
|
||||
return this.underlyingMap.get(accountID);
|
||||
return this.get(accountID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +98,7 @@ export class Group {
|
||||
* @category 1. Role reading
|
||||
*/
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOfInternal(this.node.account.id);
|
||||
return this.roleOfInternal(this.core.node.account.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,64 +107,81 @@ export class Group {
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
addMember(accountID: AccountID, role: Role) {
|
||||
this.addMemberInternal(accountID, role);
|
||||
addMember(accountID: AccountID | Everyone, role: Role): this {
|
||||
return this.addMemberInternal(accountID, role);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addMemberInternal(accountID: AccountID | AgentID, role: Role) {
|
||||
this.underlyingMap = this.underlyingMap.mutate((map) => {
|
||||
const currentReadKey = this.underlyingMap.core.getCurrentReadKey();
|
||||
addMemberInternal(
|
||||
accountID: AccountID | AgentID | Everyone,
|
||||
role: Role
|
||||
): this {
|
||||
return this.mutate((mutable) => {
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to group"
|
||||
);
|
||||
if (accountID === EVERYONE) {
|
||||
if (!(role === "reader" || role === "writer")) {
|
||||
throw new Error(
|
||||
"Can't make everyone something other than reader or writer"
|
||||
);
|
||||
}
|
||||
mutable.set(accountID, role, "trusting");
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
if (mutable.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
mutable.set(
|
||||
`${currentReadKey.id}_for_${EVERYONE}`,
|
||||
currentReadKey.secret,
|
||||
"trusting"
|
||||
);
|
||||
} else {
|
||||
const agent = this.core.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to group"
|
||||
);
|
||||
mutable.set(accountID, role, "trusting");
|
||||
|
||||
if (mutable.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
mutable.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal({
|
||||
message: currentReadKey.secret,
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(agent),
|
||||
nOnceMaterial: {
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal({
|
||||
message: currentReadKey.secret,
|
||||
from: this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(agent),
|
||||
nOnceMaterial: {
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.underlyingMap
|
||||
.keys()
|
||||
.filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.underlyingMap.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as (AccountID | AgentID)[];
|
||||
rotateReadKey(): this {
|
||||
const currentlyPermittedReaders = this.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as (AccountID | AgentID)[];
|
||||
|
||||
const maybeCurrentReadKey = this.underlyingMap.core.getCurrentReadKey();
|
||||
const maybeCurrentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
@@ -193,29 +196,29 @@ export class Group {
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.underlyingMap = this.underlyingMap.mutate((map) => {
|
||||
return this.mutate((mutable) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
const reader = this.core.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
mutable.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal({
|
||||
message: newReadKey.secret,
|
||||
from: this.underlyingMap.core.node.account.currentSealerSecret(),
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: getAgentSealerID(reader),
|
||||
nOnceMaterial: {
|
||||
in: this.underlyingMap.core.id,
|
||||
tx: this.underlyingMap.core.nextTransactionID(),
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
mutable.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
@@ -224,7 +227,7 @@ export class Group {
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
mutable.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,17 +238,17 @@ export class Group {
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
removeMember(accountID: AccountID) {
|
||||
this.removeMemberInternal(accountID);
|
||||
removeMember(accountID: AccountID): this {
|
||||
return this.removeMemberInternal(accountID);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(accountID: AccountID | AgentID) {
|
||||
this.underlyingMap = this.underlyingMap.mutate((map) => {
|
||||
removeMemberInternal(accountID: AccountID | AgentID): this {
|
||||
const afterRevoke = this.mutate((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
return afterRevoke.rotateReadKey();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,21 +275,17 @@ export class Group {
|
||||
*
|
||||
* @category 3. Value creation
|
||||
*/
|
||||
createMap<M extends AnyCoMap>(
|
||||
init?: {
|
||||
[K in keyof M["_shape"]]: M["_shape"][K] extends AnyCoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
},
|
||||
createMap<M extends CoMap>(
|
||||
init?: M["_shape"],
|
||||
meta?: M["meta"],
|
||||
initPrivacy: "trusting" | "private" = "trusting"
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): M {
|
||||
let map = this.node
|
||||
let map = this.core.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
@@ -308,19 +307,17 @@ export class Group {
|
||||
*
|
||||
* @category 3. Value creation
|
||||
*/
|
||||
createList<L extends AnyCoList>(
|
||||
init?: (L["_item"] extends CoValue
|
||||
? CoID<L["_item"]> | L["_item"]
|
||||
: L["_item"])[],
|
||||
createList<L extends CoList>(
|
||||
init?: L["_item"][],
|
||||
meta?: L["meta"],
|
||||
initPrivacy: "trusting" | "private" = "trusting"
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): L {
|
||||
let list = this.node
|
||||
let list = this.core.node
|
||||
.createCoValue({
|
||||
type: "colist",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
@@ -337,15 +334,13 @@ export class Group {
|
||||
}
|
||||
|
||||
/** @category 3. Value creation */
|
||||
createStream<C extends CoStream<JsonValue | CoValue, JsonObject | null>>(
|
||||
meta?: C["meta"]
|
||||
): C {
|
||||
return this.node
|
||||
createStream<C extends CoStream>(meta?: C["meta"]): C {
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
@@ -354,36 +349,21 @@ export class Group {
|
||||
}
|
||||
|
||||
/** @category 3. Value creation */
|
||||
createBinaryStream<C extends BinaryCoStream<BinaryCoStreamMeta>>(
|
||||
createBinaryStream<C extends BinaryCoStream>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
): C {
|
||||
return this.node
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Group {
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.underlyingMap.core
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type InviteSecret = `inviteSecret_z${string}`;
|
||||
@@ -22,23 +22,27 @@ let blake3incrementalUpdateSLOW_WITH_DEVTOOLS: (
|
||||
let blake3digestForState: (state: Uint8Array) => Uint8Array;
|
||||
|
||||
export const cryptoReady = new Promise<void>((resolve) => {
|
||||
createBLAKE3().then((bl3) => {
|
||||
blake3Instance = bl3;
|
||||
blake3HashOnce = (data) => {
|
||||
return bl3.init().update(data).digest("binary");
|
||||
};
|
||||
blake3HashOnceWithContext = (data, { context }) => {
|
||||
return bl3.init().update(context).update(data).digest("binary");
|
||||
};
|
||||
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
|
||||
bl3.load(state).update(data);
|
||||
return bl3.save();
|
||||
};
|
||||
blake3digestForState = (state) => {
|
||||
return bl3.load(state).digest("binary");
|
||||
};
|
||||
resolve();
|
||||
});
|
||||
createBLAKE3()
|
||||
.then((bl3) => {
|
||||
blake3Instance = bl3;
|
||||
blake3HashOnce = (data) => {
|
||||
return bl3.init().update(data).digest("binary");
|
||||
};
|
||||
blake3HashOnceWithContext = (data, { context }) => {
|
||||
return bl3.init().update(context).update(data).digest("binary");
|
||||
};
|
||||
blake3incrementalUpdateSLOW_WITH_DEVTOOLS = (state, data) => {
|
||||
bl3.load(state).update(data);
|
||||
return bl3.save();
|
||||
};
|
||||
blake3digestForState = (state) => {
|
||||
return bl3.load(state).digest("binary");
|
||||
};
|
||||
resolve();
|
||||
})
|
||||
.catch((e) =>
|
||||
console.error("Failed to load cryptography dependencies", e)
|
||||
);
|
||||
});
|
||||
|
||||
export type SignerSecret = `signerSecret_z${string}`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AccountID } from './account.js';
|
||||
import { AccountID } from './coValues/account.js';
|
||||
import { base58 } from "@scure/base";
|
||||
import { shortHashLength } from './crypto.js';
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CoValueCore,
|
||||
newRandomSessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
accountOrAgentIDfromSessionID
|
||||
} from "./coValueCore.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import type { CoValue } from "./coValue.js";
|
||||
@@ -25,18 +26,24 @@ import {
|
||||
cryptoReady,
|
||||
} from "./crypto.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import {
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
} from "./coValues/account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { Group, expectGroup, EVERYONE } from "./coValues/group.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { Account, Profile, isAccountID } from "./coValues/account.js";
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, AnyCoValue } from "./coValue.js";
|
||||
import type { Queried } from "./queries.js";
|
||||
import type { Queried, QueryExtension } from "./queries.js";
|
||||
import type { QueriedCoStream } from "./queriedCoValues/queriedCoStream.js";
|
||||
import type { QueriedCoList } from "./queriedCoValues/queriedCoList.js";
|
||||
import type { QueriedCoMap } from "./queriedCoValues/queriedCoMap.js";
|
||||
import { QueriedAccount } from "./queriedCoValues/queriedAccount.js";
|
||||
import { QueriedGroup } from "./queriedCoValues/queriedGroup.js";
|
||||
import type {
|
||||
BinaryStreamInfo,
|
||||
BinaryCoStreamMeta,
|
||||
@@ -44,8 +51,8 @@ import type {
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { SyncMessage, Peer } from "./sync.js";
|
||||
import type { AgentSecret } from "./crypto.js";
|
||||
import type { AccountID, Account, Profile } from "./account.js";
|
||||
import type { InviteSecret } from "./group.js";
|
||||
import type { AccountID, AccountMeta, AccountMigration, ProfileMeta } from "./coValues/account.js";
|
||||
import type { InviteSecret } from "./coValues/group.js";
|
||||
import type * as Media from "./media.js";
|
||||
|
||||
type Value = JsonValue | AnyCoValue;
|
||||
@@ -64,15 +71,18 @@ export const cojsonInternals = {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectGroupContent,
|
||||
expectGroup,
|
||||
base64URLtoBytes,
|
||||
bytesToBase64url,
|
||||
parseJSON,
|
||||
accountOrAgentIDfromSessionID,
|
||||
isAccountID,
|
||||
};
|
||||
|
||||
export {
|
||||
LocalNode,
|
||||
Group,
|
||||
EVERYONE,
|
||||
CoMap,
|
||||
MutableCoMap,
|
||||
CoList,
|
||||
@@ -88,9 +98,14 @@ export {
|
||||
QueriedCoMap,
|
||||
QueriedCoList,
|
||||
QueriedCoStream,
|
||||
AccountID,
|
||||
QueriedGroup,
|
||||
QueriedAccount,
|
||||
Account,
|
||||
AccountID,
|
||||
AccountMeta,
|
||||
AccountMigration,
|
||||
Profile,
|
||||
ProfileMeta,
|
||||
SessionID,
|
||||
Media,
|
||||
CoValueCore,
|
||||
@@ -107,6 +122,7 @@ export {
|
||||
AgentSecret,
|
||||
InviteSecret,
|
||||
SyncMessage,
|
||||
QueryExtension,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
@@ -120,9 +136,8 @@ export namespace CojsonInternalTypes {
|
||||
export type Transaction = import("./coValueCore.js").Transaction;
|
||||
export type Signature = import("./crypto.js").Signature;
|
||||
export type RawCoID = import("./ids.js").RawCoID;
|
||||
export type AccountContent = import("./account.js").AccountContent;
|
||||
export type ProfileContent = import("./account.js").ProfileContent;
|
||||
export type ProfileMeta = import("./account.js").ProfileMeta;
|
||||
export type ProfileShape = import("./coValues/account.js").ProfileShape;
|
||||
export type ProfileMeta = import("./coValues/account.js").ProfileMeta;
|
||||
export type SealerSecret = import("./crypto.js").SealerSecret;
|
||||
export type SignerSecret = import("./crypto.js").SignerSecret;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,16 @@ import {
|
||||
import {
|
||||
InviteSecret,
|
||||
Group,
|
||||
GroupContent,
|
||||
expectGroupContent,
|
||||
GroupShape,
|
||||
expectGroup,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./group.js";
|
||||
} from "./coValues/group.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { Queried, query } from "./queries.js";
|
||||
import {
|
||||
AccountGroup,
|
||||
Account,
|
||||
AccountMeta,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
GeneralizedControlledAccount,
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
AnonymousControlledAccount,
|
||||
AccountID,
|
||||
Profile,
|
||||
AccountContent,
|
||||
} from "./account.js";
|
||||
isAccountID,
|
||||
AccountMigration,
|
||||
} from "./coValues/account.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoValue } from "./index.js";
|
||||
import { QueriedAccount } from "./queriedCoValues/queriedAccount.js";
|
||||
|
||||
/** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
|
||||
|
||||
@@ -70,10 +72,19 @@ export class LocalNode {
|
||||
}
|
||||
|
||||
/** @category 2. Node Creation */
|
||||
static withNewlyCreatedAccount(
|
||||
name: string,
|
||||
initialAgentSecret = newRandomAgentSecret()
|
||||
): {
|
||||
static withNewlyCreatedAccount<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>({
|
||||
name,
|
||||
migration,
|
||||
initialAgentSecret = newRandomAgentSecret(),
|
||||
}: {
|
||||
name: string;
|
||||
migration?: AccountMigration<P, R, Meta>;
|
||||
initialAgentSecret?: AgentSecret;
|
||||
}): {
|
||||
node: LocalNode;
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
@@ -87,26 +98,52 @@ export class LocalNode {
|
||||
|
||||
const account = setupNode.createAccount(name, initialAgentSecret);
|
||||
|
||||
const nodeWithAccount = account.node.testWithDifferentAccount(
|
||||
const nodeWithAccount = account.core.node.testWithDifferentAccount(
|
||||
account,
|
||||
newRandomSessionID(account.id)
|
||||
);
|
||||
|
||||
const accountOnNodeWithAccount = nodeWithAccount.account as ControlledAccount<P, R, Meta>;
|
||||
|
||||
const profile = nodeWithAccount.expectProfileLoaded(
|
||||
accountOnNodeWithAccount.id,
|
||||
"After creating account"
|
||||
);
|
||||
|
||||
if (migration) {
|
||||
migration(accountOnNodeWithAccount, profile as P);
|
||||
nodeWithAccount.account = new ControlledAccount(
|
||||
accountOnNodeWithAccount.core,
|
||||
accountOnNodeWithAccount.agentSecret
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
node: nodeWithAccount,
|
||||
accountID: account.id,
|
||||
accountSecret: account.agentSecret,
|
||||
accountID: accountOnNodeWithAccount.id,
|
||||
accountSecret: accountOnNodeWithAccount.agentSecret,
|
||||
sessionID: nodeWithAccount.currentSessionID,
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 2. Node Creation */
|
||||
static async withLoadedAccount(
|
||||
accountID: AccountID,
|
||||
accountSecret: AgentSecret,
|
||||
sessionID: SessionID,
|
||||
peersToLoadFrom: Peer[]
|
||||
): Promise<LocalNode> {
|
||||
static async withLoadedAccount<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>({
|
||||
accountID,
|
||||
accountSecret,
|
||||
sessionID,
|
||||
peersToLoadFrom,
|
||||
migration,
|
||||
}: {
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
sessionID: SessionID;
|
||||
peersToLoadFrom: Peer[];
|
||||
migration?: AccountMigration<P, R, Meta>;
|
||||
}): Promise<LocalNode> {
|
||||
const loadingNode = new LocalNode(
|
||||
new AnonymousControlledAccount(accountSecret),
|
||||
newRandomSessionID(accountID)
|
||||
@@ -119,15 +156,38 @@ export class LocalNode {
|
||||
}
|
||||
|
||||
const account = await accountPromise;
|
||||
const controlledAccount = new ControlledAccount(
|
||||
account.core,
|
||||
accountSecret
|
||||
);
|
||||
|
||||
// since this is all synchronous, we can just swap out nodes for the SyncManager
|
||||
const node = loadingNode.testWithDifferentAccount(
|
||||
new ControlledAccount(accountSecret, account, loadingNode),
|
||||
controlledAccount,
|
||||
sessionID
|
||||
);
|
||||
node.syncManager = loadingNode.syncManager;
|
||||
node.syncManager.local = node;
|
||||
|
||||
controlledAccount.core.node = node;
|
||||
|
||||
const profileID = account.get("profile");
|
||||
if (!profileID) {
|
||||
throw new Error("Account has no profile");
|
||||
}
|
||||
const profile = await node.load(profileID);
|
||||
|
||||
if (migration) {
|
||||
migration(
|
||||
controlledAccount as ControlledAccount<P, R, Meta>,
|
||||
profile as P
|
||||
);
|
||||
node.account = new ControlledAccount(
|
||||
controlledAccount.core,
|
||||
controlledAccount.agentSecret
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -197,14 +257,47 @@ export class LocalNode {
|
||||
}
|
||||
|
||||
/** @category 1. High-level */
|
||||
|
||||
query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
callback: (update: Queried<T> | undefined) => void
|
||||
): () => void;
|
||||
query<
|
||||
P extends Profile = Profile,
|
||||
R extends CoMap = CoMap,
|
||||
Meta extends AccountMeta = AccountMeta
|
||||
>(
|
||||
id: "me",
|
||||
callback: (
|
||||
update: QueriedAccount<Account<P, R, Meta>> | undefined
|
||||
) => void
|
||||
): () => void;
|
||||
query(
|
||||
id: CoID<CoValue> | "me",
|
||||
callback: (
|
||||
update: Queried<CoValue> | QueriedAccount | undefined
|
||||
) => void
|
||||
): () => void;
|
||||
query(
|
||||
id: CoID<CoValue> | "me",
|
||||
callback: (
|
||||
// TODO: sort this out
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
update: any
|
||||
) => void
|
||||
): () => void {
|
||||
return query(id, this, callback);
|
||||
if (id === "me") {
|
||||
const meId = this.account.id;
|
||||
if (!isAccountID(meId)) {
|
||||
throw new Error("Can only query 'me' for accounts");
|
||||
}
|
||||
return query(meId, this, callback);
|
||||
} else {
|
||||
return query(id, this, callback);
|
||||
}
|
||||
}
|
||||
|
||||
/** @category 1. High-level */
|
||||
/** @deprecated Use Account.acceptInvite instead */
|
||||
async acceptInvite<T extends CoValue>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
@@ -213,16 +306,14 @@ export class LocalNode {
|
||||
|
||||
if (groupOrOwnedValue.core.header.ruleset.type === "ownedByGroup") {
|
||||
return this.acceptInvite(
|
||||
groupOrOwnedValue.core.header.ruleset.group as CoID<
|
||||
CoMap<GroupContent>
|
||||
>,
|
||||
groupOrOwnedValue.core.header.ruleset.group as CoID<Group>,
|
||||
inviteSecret
|
||||
);
|
||||
} else if (groupOrOwnedValue.core.header.ruleset.type !== "group") {
|
||||
throw new Error("Can only accept invites to groups");
|
||||
}
|
||||
|
||||
const group = new Group(expectGroupContent(groupOrOwnedValue), this);
|
||||
const group = expectGroup(groupOrOwnedValue);
|
||||
|
||||
const inviteAgentSecret = agentSecretFromSecretSeed(
|
||||
secretSeedFromInviteSecret(inviteSecret)
|
||||
@@ -230,8 +321,8 @@ export class LocalNode {
|
||||
const inviteAgentID = getAgentID(inviteAgentSecret);
|
||||
|
||||
const inviteRole = await new Promise((resolve, reject) => {
|
||||
group.underlyingMap.subscribe((groupMap) => {
|
||||
const role = groupMap.get(inviteAgentID);
|
||||
group.subscribe((groupUpdate) => {
|
||||
const role = groupUpdate.get(inviteAgentID);
|
||||
if (role) {
|
||||
resolve(role);
|
||||
}
|
||||
@@ -246,7 +337,7 @@ export class LocalNode {
|
||||
throw new Error("No invite found");
|
||||
}
|
||||
|
||||
const existingRole = group.underlyingMap.get(this.account.id);
|
||||
const existingRole = group.get(this.account.id);
|
||||
|
||||
if (
|
||||
existingRole === "admin" ||
|
||||
@@ -260,9 +351,13 @@ export class LocalNode {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupAsInvite = group.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
const groupAsInvite = expectGroup(
|
||||
group.core
|
||||
.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
)
|
||||
.getCurrentContent()
|
||||
);
|
||||
|
||||
groupAsInvite.addMemberInternal(
|
||||
@@ -274,12 +369,11 @@ export class LocalNode {
|
||||
: "reader"
|
||||
);
|
||||
|
||||
group.underlyingMap.core._sessions =
|
||||
groupAsInvite.underlyingMap.core.sessions;
|
||||
group.underlyingMap.core._cachedContent = undefined;
|
||||
group.core._sessions = groupAsInvite.core.sessions;
|
||||
group.core._cachedContent = undefined;
|
||||
|
||||
for (const groupListener of group.underlyingMap.core.listeners) {
|
||||
groupListener(group.underlyingMap.core.getCurrentContent());
|
||||
for (const groupListener of group.core.listeners) {
|
||||
groupListener(group.core.getCurrentContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +398,7 @@ export class LocalNode {
|
||||
/** @internal */
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectGroupContent(account.getCurrentContent()).get(
|
||||
const profileID = expectGroup(account.getCurrentContent()).get(
|
||||
"profile"
|
||||
);
|
||||
if (!profileID) {
|
||||
@@ -326,47 +420,51 @@ export class LocalNode {
|
||||
agentSecret = newRandomAgentSecret()
|
||||
): ControlledAccount {
|
||||
const accountAgentID = getAgentID(agentSecret);
|
||||
const account = this.createCoValue(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(accountAgentID)
|
||||
let account = expectGroup(
|
||||
this.createCoValue(accountHeaderForInitialAgentSecret(agentSecret))
|
||||
.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(accountAgentID)
|
||||
)
|
||||
.getCurrentContent()
|
||||
);
|
||||
|
||||
const accountAsGroup = new Group(
|
||||
expectGroupContent(account.getCurrentContent()),
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsGroup.underlyingMap.mutate((editable) => {
|
||||
account = account.mutate((editable) => {
|
||||
editable.set(accountAgentID, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
|
||||
const sealed = seal({
|
||||
message: readKey.secret,
|
||||
from: getAgentSealerSecret(agentSecret),
|
||||
to: getAgentSealerID(accountAgentID),
|
||||
nOnceMaterial: {
|
||||
in: account.id,
|
||||
tx: account.core.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Creating read key",
|
||||
getAgentSealerSecret(agentSecret),
|
||||
getAgentSealerID(accountAgentID),
|
||||
account.id,
|
||||
account.core.nextTransactionID(),
|
||||
"in session",
|
||||
account.core.node.currentSessionID,
|
||||
"=",
|
||||
sealed
|
||||
);
|
||||
editable.set(
|
||||
`${readKey.id}_for_${accountAgentID}`,
|
||||
seal({
|
||||
message: readKey.secret,
|
||||
from: getAgentSealerSecret(agentSecret),
|
||||
to: getAgentSealerID(accountAgentID),
|
||||
nOnceMaterial: {
|
||||
in: account.id,
|
||||
tx: account.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
sealed,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
const controlledAccount = new ControlledAccount(
|
||||
agentSecret,
|
||||
account.getCurrentContent() as CoMap<AccountContent, AccountMeta>,
|
||||
account.node
|
||||
);
|
||||
|
||||
const profile = accountAsGroup.createMap<Profile>(
|
||||
const profile = account.createMap<Profile>(
|
||||
{ name },
|
||||
{
|
||||
type: "profile",
|
||||
@@ -374,12 +472,12 @@ export class LocalNode {
|
||||
"trusting"
|
||||
);
|
||||
|
||||
accountAsGroup.underlyingMap.set("profile", profile.id, "trusting");
|
||||
account = account.set("profile", profile.id, "trusting");
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {
|
||||
...accountAsGroup.underlyingMap.core.sessions,
|
||||
...account.core.sessions,
|
||||
};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
@@ -390,7 +488,7 @@ export class LocalNode {
|
||||
};
|
||||
profileOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
return new ControlledAccount(accountOnThisNode, agentSecret);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -418,15 +516,11 @@ export class LocalNode {
|
||||
);
|
||||
}
|
||||
|
||||
return new AccountGroup(
|
||||
coValue.getCurrentContent() as CoMap<GroupContent, AccountMeta>,
|
||||
this
|
||||
).getCurrentAgentID();
|
||||
return new Account(coValue).getCurrentAgentID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new group (with the current account as the group's first admin).
|
||||
* @category 1. High-level
|
||||
* @deprecated use Account.createGroup() instead
|
||||
*/
|
||||
createGroup(): Group {
|
||||
const groupCoValue = this.createCoValue({
|
||||
@@ -436,9 +530,9 @@ export class LocalNode {
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
let groupContent = expectGroupContent(groupCoValue.getCurrentContent());
|
||||
let group = expectGroup(groupCoValue.getCurrentContent());
|
||||
|
||||
groupContent = groupContent.mutate((editable) => {
|
||||
group = group.mutate((editable) => {
|
||||
editable.set(this.account.id, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
@@ -460,7 +554,7 @@ export class LocalNode {
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
return new Group(groupContent, this);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -505,6 +599,15 @@ export class LocalNode {
|
||||
}
|
||||
}
|
||||
|
||||
if (account instanceof ControlledAccount) {
|
||||
// To make sure that when we edit the account, we're modifying the correct sessions
|
||||
const accountInNode = new ControlledAccount(newNode.expectCoValueLoaded(account.id), account.agentSecret);
|
||||
if (accountInNode.core.node !== newNode) {
|
||||
throw new Error("Account's node is not the new node");
|
||||
}
|
||||
newNode.account = accountInNode;
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ import { BinaryCoStream } from './coValues/coStream.js'
|
||||
export type ImageDefinition = CoMap<{
|
||||
originalSize: [number, number];
|
||||
placeholderDataURL?: string;
|
||||
[res: `${number}x${number}`]: BinaryCoStream;
|
||||
[res: `${number}x${number}`]: BinaryCoStream["id"];
|
||||
}>;
|
||||
@@ -5,13 +5,12 @@ import { KeyID } from "./crypto.js";
|
||||
import {
|
||||
CoValueCore,
|
||||
Transaction,
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValueCore.js";
|
||||
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import { AccountID, Profile } from "./account.js";
|
||||
import { Account, AccountID, Profile } from "./coValues/account.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { expectGroupContent } from "./group.js";
|
||||
import { EVERYONE, Everyone, expectGroup } from "./coValues/group.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "group"; initialAdmin: AccountID | AgentID }
|
||||
@@ -31,26 +30,21 @@ export function determineValidTransactions(
|
||||
coValue: CoValueCore
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (coValue.header.ruleset.type === "group") {
|
||||
const allTrustingTransactionsSorted = Object.entries(
|
||||
coValue.sessions
|
||||
).flatMap(([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions
|
||||
.map((tx, txIndex) => ({ sessionID, txIndex, tx }))
|
||||
.filter(({ tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Unexpected private transaction in Group");
|
||||
return false;
|
||||
}
|
||||
}) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: TrustingTransaction;
|
||||
}[];
|
||||
});
|
||||
const allTransactionsSorted = Object.entries(coValue.sessions).flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions.map((tx, txIndex) => ({
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
})) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: Transaction;
|
||||
}[];
|
||||
}
|
||||
);
|
||||
|
||||
allTrustingTransactionsSorted.sort((a, b) => {
|
||||
allTransactionsSorted.sort((a, b) => {
|
||||
return a.tx.madeAt - b.tx.madeAt;
|
||||
});
|
||||
|
||||
@@ -60,19 +54,33 @@ export function determineValidTransactions(
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AccountID | AgentID]: Role } = {};
|
||||
const memberState: {
|
||||
[agent: AccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
} = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
|
||||
[];
|
||||
|
||||
for (const {
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
} of allTrustingTransactionsSorted) {
|
||||
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
if (tx.privacy === "private") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
validTransactions.push({
|
||||
txID: { sessionID, txIndex },
|
||||
tx,
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
console.warn(
|
||||
"Only admins can make private transactions in groups"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let changes;
|
||||
|
||||
try {
|
||||
@@ -93,7 +101,7 @@ export function determineValidTransactions(
|
||||
}
|
||||
|
||||
const change = changes[0] as
|
||||
| MapOpPayload<AccountID | AgentID, Role>
|
||||
| MapOpPayload<AccountID | AgentID | Everyone, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (changes.length !== 1) {
|
||||
@@ -158,6 +166,20 @@ export function determineValidTransactions(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
affectedMember === EVERYONE &&
|
||||
!(
|
||||
change.value === "reader" ||
|
||||
change.value === "writer" ||
|
||||
change.value === "revoked"
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
"Everyone can only be set to reader, writer or revoked"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
@@ -206,7 +228,7 @@ export function determineValidTransactions(
|
||||
|
||||
return validTransactions;
|
||||
} else if (coValue.header.ruleset.type === "ownedByGroup") {
|
||||
const groupContent = expectGroupContent(
|
||||
const groupContent = expectGroup(
|
||||
coValue.node
|
||||
.expectCoValueLoaded(
|
||||
coValue.header.ruleset.group,
|
||||
@@ -224,11 +246,18 @@ export function determineValidTransactions(
|
||||
const transactor = accountOrAgentIDfromSessionID(
|
||||
sessionID as SessionID
|
||||
);
|
||||
|
||||
return sessionLog.transactions
|
||||
.filter((tx) => {
|
||||
const transactorRoleAtTxTime = groupContent
|
||||
.atTime(tx.madeAt)
|
||||
.get(transactor);
|
||||
const groupAtTime = groupContent.atTime(tx.madeAt);
|
||||
const effectiveTransactor =
|
||||
transactor === groupContent.id &&
|
||||
groupAtTime instanceof Account
|
||||
? groupAtTime.getCurrentAgentID()
|
||||
: transactor;
|
||||
const transactorRoleAtTxTime =
|
||||
groupAtTime.get(effectiveTransactor) ||
|
||||
groupAtTime.get(EVERYONE);
|
||||
|
||||
return (
|
||||
transactorRoleAtTxTime === "admin" ||
|
||||
@@ -252,7 +281,8 @@ export function determineValidTransactions(
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Unknown ruleset type " + (coValue.header.ruleset as any).type
|
||||
"Unknown ruleset type " +
|
||||
(coValue.header.ruleset as { type: string }).type
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -267,7 +297,8 @@ export function isKeyForAccountField(
|
||||
field: string
|
||||
): field is `${KeyID}_for_${AccountID | AgentID}` {
|
||||
return (
|
||||
field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))
|
||||
(field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))) ||
|
||||
field.includes("_for_everyone")
|
||||
);
|
||||
}
|
||||
|
||||
40
packages/cojson/src/queriedCoValues/queriedAccount.ts
Normal file
40
packages/cojson/src/queriedCoValues/queriedAccount.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Account } from "../coValues/account.js";
|
||||
import { CoID, CoValue, ControlledAccount, InviteSecret } from "../index.js";
|
||||
import { QueryContext } from "../queries.js";
|
||||
import { QueriedGroup } from "./queriedGroup.js";
|
||||
|
||||
export class QueriedAccount<A extends Account = Account> extends QueriedGroup<A> {
|
||||
id!: CoID<A>;
|
||||
isMe!: boolean;
|
||||
|
||||
constructor(account: A, queryContext: QueryContext) {
|
||||
super(account, queryContext);
|
||||
Object.defineProperties(this, {
|
||||
id: { value: account.id, enumerable: false },
|
||||
isMe: {
|
||||
value: account.core.node.account.id === account.id,
|
||||
enumerable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createGroup() {
|
||||
if (!this.isMe)
|
||||
throw new Error("Only the current user can create a group");
|
||||
return (
|
||||
this.group.core.node.account as ControlledAccount
|
||||
).createGroup();
|
||||
}
|
||||
|
||||
acceptInvite<T extends CoValue>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
) {
|
||||
if (!this.isMe)
|
||||
throw new Error("Only the current user can accept an invite");
|
||||
return (this.group.core.node.account as ControlledAccount).acceptInvite(
|
||||
groupOrOwnedValueID,
|
||||
inviteSecret
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { CoList, MutableCoList } from "../coValues/coList.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { isAccountID } from "../account.js";
|
||||
import { AnyCoList, CoID, CoValue } from "../coValue.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import { CoID, CoValue } from "../coValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { QueriedAccountAndProfile } from "./queriedCoMap.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
import { QueriedAccount } from "./queriedAccount.js";
|
||||
|
||||
export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
export class QueriedCoList<L extends CoList> extends Array<
|
||||
ValueOrSubQueried<L["_item"]>
|
||||
> {
|
||||
coList!: L;
|
||||
@@ -27,40 +26,37 @@ export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
.asArray()
|
||||
.map(
|
||||
(item) =>
|
||||
queryContext.resolveValue(item) as ValueOrSubQueried<
|
||||
queryContext.queryIfCoID(item, [coList.id]) as ValueOrSubQueried<
|
||||
L["_item"]
|
||||
>
|
||||
)
|
||||
);
|
||||
|
||||
Object.defineProperties(this, {
|
||||
coList: { value: coList },
|
||||
coList: { get() {return coList} },
|
||||
id: { value: coList.id },
|
||||
type: { value: "colist" },
|
||||
edits: {
|
||||
value: [...this.keys()].map((i) => {
|
||||
const edit = coList.editAt(i)!;
|
||||
return {
|
||||
by:
|
||||
edit.by && isAccountID(edit.by)
|
||||
? queryContext.resolveAccount(edit.by)
|
||||
: undefined,
|
||||
return queryContext.defineSubqueryPropertiesIn({
|
||||
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(edit.value) as any,
|
||||
};
|
||||
}, {
|
||||
by: {value: edit.by, enumerable: true},
|
||||
value: {value: edit.value, enumerable: true},
|
||||
}, [coList.id]);
|
||||
}),
|
||||
},
|
||||
deletions: {
|
||||
value: coList.deletionEdits().map((deletion) => ({
|
||||
by:
|
||||
deletion.by && isAccountID(deletion.by)
|
||||
? queryContext.resolveAccount(deletion.by)
|
||||
: undefined,
|
||||
value: coList.deletionEdits().map((deletion) => queryContext.defineSubqueryPropertiesIn({
|
||||
|
||||
tx: deletion.tx,
|
||||
at: new Date(deletion.at),
|
||||
})),
|
||||
}, {
|
||||
by: {value: deletion.by, enumerable: true},
|
||||
}, [coList.id])),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -78,9 +74,7 @@ export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
}
|
||||
|
||||
append(
|
||||
item: L["_item"] extends CoValue
|
||||
? L["_item"] | CoID<L["_item"]>
|
||||
: L["_item"],
|
||||
item: L["_item"],
|
||||
after?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
@@ -88,16 +82,14 @@ export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
}
|
||||
|
||||
prepend(
|
||||
item: L["_item"] extends CoValue
|
||||
? L["_item"] | CoID<L["_item"]>
|
||||
: L["_item"],
|
||||
item: L["_item"],
|
||||
before?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
return this.coList.prepend(item, before, privacy);
|
||||
}
|
||||
|
||||
delete(at: number, privacy: "private" | "trusting"): L {
|
||||
delete(at: number, privacy?: "private" | "trusting"): L {
|
||||
return this.coList.delete(at, privacy);
|
||||
}
|
||||
|
||||
@@ -108,7 +100,7 @@ export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
}
|
||||
|
||||
edits!: {
|
||||
by?: QueriedAccountAndProfile;
|
||||
by?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: L["_item"] extends CoValue
|
||||
@@ -117,7 +109,7 @@ export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
}[];
|
||||
|
||||
deletions!: {
|
||||
by?: QueriedAccountAndProfile;
|
||||
by?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
import { MutableCoMap } from "../coValues/coMap.js";
|
||||
import { CoMap, MutableCoMap } from "../coValues/coMap.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { Account, AccountID, Profile, isAccountID } from "../account.js";
|
||||
import { AnyCoMap, CoID, CoValue } from "../coValue.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { TransactionID } from "../ids.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
import { ValueOrSubQueried, QueryContext, QueryExtension } from "../queries.js";
|
||||
import { QueriedAccount } from "./queriedAccount.js";
|
||||
|
||||
export type QueriedCoMap<M extends AnyCoMap> = {
|
||||
export type QueriedCoMap<M extends CoMap> = {
|
||||
[K in keyof M["_shape"] & string]: ValueOrSubQueried<M["_shape"][K]>;
|
||||
} & QueriedCoMapBase<M>;
|
||||
|
||||
export type QueriedCoMapEdit<
|
||||
M extends AnyCoMap,
|
||||
K extends keyof M["_shape"]
|
||||
> = {
|
||||
by?: QueriedAccountAndProfile;
|
||||
export type QueriedCoMapEdit<M extends CoMap, K extends keyof M["_shape"]> = {
|
||||
by?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: M["_shape"][K] extends CoValue
|
||||
? CoID<M["_shape"][K]>
|
||||
: Exclude<M["_shape"][K], CoValue>;
|
||||
value: M["_shape"][K];
|
||||
};
|
||||
|
||||
export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
export class QueriedCoMapBase<M extends CoMap> {
|
||||
coMap!: M;
|
||||
id!: CoID<M>;
|
||||
type!: "comap";
|
||||
|
||||
/** @internal */
|
||||
static newWithKVPairs<M extends AnyCoMap>(
|
||||
static newWithKVPairs<M extends CoMap>(
|
||||
coMap: M,
|
||||
queryContext: QueryContext
|
||||
): QueriedCoMap<M> {
|
||||
@@ -37,27 +32,18 @@ export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
M["_shape"][K]
|
||||
>;
|
||||
};
|
||||
for (const key of coMap.keys()) {
|
||||
const value = coMap.get(key);
|
||||
|
||||
if (coMap.meta?.type === "account") {
|
||||
const profileID = coMap.get("profile");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(kv as any).profile =
|
||||
profileID && queryContext.resolveValue(profileID);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(kv as any).isMe =
|
||||
(coMap as unknown as Account).id ===
|
||||
queryContext.node.account.id;
|
||||
} else {
|
||||
for (const key of coMap.keys()) {
|
||||
const value = coMap.get(key);
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
kv[key as keyof typeof kv] = queryContext.resolveValue(
|
||||
value
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
}
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
kv,
|
||||
{
|
||||
[key]: { value, enumerable: true },
|
||||
},
|
||||
[coMap.id]
|
||||
);
|
||||
}
|
||||
|
||||
return Object.assign(new QueriedCoMapBase(coMap, queryContext), kv);
|
||||
@@ -66,23 +52,33 @@ export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
/** @internal */
|
||||
constructor(coMap: M, queryContext: QueryContext) {
|
||||
Object.defineProperties(this, {
|
||||
coMap: { value: coMap, enumerable: false },
|
||||
coMap: {
|
||||
get() {
|
||||
return coMap;
|
||||
},
|
||||
enumerable: false,
|
||||
},
|
||||
id: { value: coMap.id, enumerable: false },
|
||||
type: { value: "comap", enumerable: false },
|
||||
edits: {
|
||||
value: Object.fromEntries(
|
||||
coMap.keys().flatMap((key) => {
|
||||
const edits = [...coMap.editsAt(key)].map((edit) => ({
|
||||
by:
|
||||
edit.by && isAccountID(edit.by)
|
||||
? queryContext.resolveAccount(edit.by)
|
||||
: undefined,
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
value:
|
||||
edit.value &&
|
||||
queryContext.resolveValue(edit.value),
|
||||
}));
|
||||
const edits = [...coMap.editsAt(key)].map((edit) =>
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
{
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
},
|
||||
{
|
||||
by: { value: edit.by, enumerable: true },
|
||||
value: {
|
||||
value: edit.value,
|
||||
enumerable: true,
|
||||
},
|
||||
},
|
||||
[coMap.id]
|
||||
)
|
||||
);
|
||||
const lastEdit = edits[edits.length - 1];
|
||||
if (!lastEdit) return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -100,6 +96,15 @@ export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
),
|
||||
enumerable: false,
|
||||
},
|
||||
as: {
|
||||
value: <O>(extension: QueryExtension<M, O>) => {
|
||||
return queryContext.getOrCreateExtension(
|
||||
coMap.id,
|
||||
extension
|
||||
);
|
||||
},
|
||||
enumerable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,16 +130,12 @@ export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
|
||||
set<K extends keyof M["_shape"] & string>(
|
||||
key: K,
|
||||
value: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K],
|
||||
value: M["_shape"][K],
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
set(
|
||||
kv: {
|
||||
[K in keyof M["_shape"] & string]?: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
[K in keyof M["_shape"] & string]?: M["_shape"][K];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
@@ -142,20 +143,11 @@ export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof M["_shape"] &
|
||||
string]?: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
[K in keyof M["_shape"] & string]?: M["_shape"][K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [
|
||||
K,
|
||||
M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K],
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [K, M["_shape"][K], ("private" | "trusting")?]
|
||||
): M {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return (this.coMap.set as Function)(...args);
|
||||
@@ -171,10 +163,6 @@ export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
): M {
|
||||
return this.coMap.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export type QueriedAccountAndProfile = {
|
||||
profile?: { name?: string; id: CoID<Profile> };
|
||||
isMe?: boolean;
|
||||
id: AccountID;
|
||||
};
|
||||
as!: <O>(extension: QueryExtension<M, O>) => O | undefined;
|
||||
}
|
||||
|
||||
@@ -1,94 +1,127 @@
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { MutableCoStream } from "../coValues/coStream.js";
|
||||
import { CoStream, MutableCoStream } from "../coValues/coStream.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Group } from "../group.js";
|
||||
import { AccountID, isAccountID } from "../account.js";
|
||||
import { AnyCoStream, CoID, CoValue } from "../coValue.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import { AccountID, isAccountID } from "../coValues/account.js";
|
||||
import { CoID, CoValue } from "../coValue.js";
|
||||
import { SessionID, TransactionID } from "../ids.js";
|
||||
import { QueriedAccountAndProfile } from "./queriedCoMap.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
import { QueriedAccount } from "./queriedAccount.js";
|
||||
|
||||
|
||||
export type QueriedCoStreamItems<Item extends JsonValue | CoValue> = {
|
||||
export type QueriedCoStreamEntry<Item extends JsonValue | CoValue> = {
|
||||
last?: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccountAndProfile;
|
||||
by?: QueriedAccount;
|
||||
tx?: TransactionID;
|
||||
at?: Date;
|
||||
all: {
|
||||
value: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccountAndProfile;
|
||||
by?: QueriedAccount;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class QueriedCoStream<S extends AnyCoStream> {
|
||||
coStream: S;
|
||||
export class QueriedCoStream<S extends CoStream> {
|
||||
coStream!: S;
|
||||
id: CoID<S>;
|
||||
type = "costream" as const;
|
||||
|
||||
/** @internal */
|
||||
constructor(coStream: S, queryContext: QueryContext) {
|
||||
this.coStream = coStream;
|
||||
Object.defineProperty(this, "coStream", {
|
||||
get() {
|
||||
return coStream;
|
||||
},
|
||||
});
|
||||
this.id = coStream.id;
|
||||
|
||||
this.perSession = Object.fromEntries(
|
||||
coStream.sessions().map((sessionID) => {
|
||||
const items = [...coStream.itemsIn(sessionID)].map((item) => ({
|
||||
by: item.by && isAccountID(item.by)
|
||||
? queryContext.resolveAccount(item.by)
|
||||
: undefined,
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(item.value) as any,
|
||||
}));
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
this.perSession = coStream.sessions().map((sessionID) => {
|
||||
const items = [...coStream.itemsIn(sessionID)].map((item) =>
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
{
|
||||
last: lastItem?.value,
|
||||
by: lastItem?.by,
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamItems<S["_item"]>,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
this.perAccount = Object.fromEntries(
|
||||
[...coStream.accounts()].map((accountID) => {
|
||||
const items = [...coStream.itemsBy(accountID)].map((item) => ({
|
||||
by: item.by && isAccountID(item.by)
|
||||
? queryContext.resolveAccount(item.by)
|
||||
: undefined,
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(item.value) as any,
|
||||
}));
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
return [
|
||||
accountID,
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
},
|
||||
{
|
||||
last: lastItem?.value,
|
||||
by: lastItem?.by,
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamItems<S["_item"]>,
|
||||
];
|
||||
})
|
||||
);
|
||||
by: {
|
||||
value: isAccountID(item.by)
|
||||
? item.by
|
||||
: (undefined as never),
|
||||
enumerable: true,
|
||||
},
|
||||
value: {
|
||||
value: item.value as S["_item"],
|
||||
enumerable: true,
|
||||
},
|
||||
},
|
||||
[coStream.id]
|
||||
)
|
||||
);
|
||||
|
||||
this.me = isAccountID(queryContext.node.account.id)
|
||||
? this.perAccount[queryContext.node.account.id]
|
||||
: undefined;
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
return [
|
||||
sessionID,
|
||||
{
|
||||
get last() {
|
||||
return lastItem?.value;
|
||||
},
|
||||
get by() {
|
||||
return lastItem?.by;
|
||||
},
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamEntry<S["_item"]>,
|
||||
];
|
||||
});
|
||||
|
||||
this.perAccount = [...coStream.accounts()].map((accountID) => {
|
||||
const items = [...coStream.itemsBy(accountID)].map((item) =>
|
||||
queryContext.defineSubqueryPropertiesIn(
|
||||
{
|
||||
tx: item.tx,
|
||||
at: new Date(item.at),
|
||||
},
|
||||
{
|
||||
by: {
|
||||
value: isAccountID(item.by)
|
||||
? item.by
|
||||
: (undefined as never),
|
||||
enumerable: true,
|
||||
},
|
||||
value: {
|
||||
value: item.value as S["_item"],
|
||||
enumerable: true,
|
||||
},
|
||||
},
|
||||
[coStream.id]
|
||||
)
|
||||
);
|
||||
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
const entry = {
|
||||
get last() {
|
||||
return lastItem?.value;
|
||||
},
|
||||
get by() {
|
||||
return lastItem?.by;
|
||||
},
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamEntry<S["_item"]>;
|
||||
|
||||
if (accountID === queryContext.node.account.id) {
|
||||
this.me = entry;
|
||||
}
|
||||
|
||||
return [
|
||||
accountID,
|
||||
entry
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
get meta(): S["meta"] {
|
||||
@@ -103,18 +136,11 @@ export class QueriedCoStream<S extends AnyCoStream> {
|
||||
return this.coStream.core;
|
||||
}
|
||||
|
||||
me?: QueriedCoStreamItems<S["_item"]>;
|
||||
perAccount: {
|
||||
[account: AccountID]: QueriedCoStreamItems<S["_item"]>;
|
||||
};
|
||||
perSession: {
|
||||
[session: SessionID]: QueriedCoStreamItems<S["_item"]>;
|
||||
};
|
||||
me?: QueriedCoStreamEntry<S["_item"]>;
|
||||
perAccount: [account: AccountID, items: QueriedCoStreamEntry<S["_item"]>][];
|
||||
perSession: [session: SessionID, items: QueriedCoStreamEntry<S["_item"]>][];
|
||||
|
||||
push(
|
||||
item: S["_item"] extends CoValue ? S["_item"] | CoID<S["_item"]> : S["_item"],
|
||||
privacy?: "private" | "trusting"
|
||||
): S {
|
||||
push(item: S["_item"], privacy?: "private" | "trusting"): S {
|
||||
return this.coStream.push(item, privacy);
|
||||
}
|
||||
mutate(
|
||||
|
||||
90
packages/cojson/src/queriedCoValues/queriedGroup.ts
Normal file
90
packages/cojson/src/queriedCoValues/queriedGroup.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Everyone, Group, InviteSecret } from "../coValues/group.js";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { QueryContext, ValueOrSubQueried } from "../queries.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { AccountID } from "../coValues/account.js";
|
||||
import { CoMap } from "../coValues/coMap.js";
|
||||
import { CoList } from "../coValues/coList.js";
|
||||
import { CoStream } from "../coValues/coStream.js";
|
||||
import { BinaryCoStream } from "../coValues/coStream.js";
|
||||
|
||||
export class QueriedGroup<G extends Group = Group> {
|
||||
group!: G;
|
||||
id!: CoID<G>;
|
||||
type = "group" as const;
|
||||
profile?: ValueOrSubQueried<G["_shape"]["profile"]>;
|
||||
root?: ValueOrSubQueried<G["_shape"]["root"]>;
|
||||
|
||||
constructor(group: G, queryContext: QueryContext) {
|
||||
const profileID = group.get("profile");
|
||||
const rootID = group.get("root");
|
||||
queryContext.defineSubqueryPropertiesIn(Object.defineProperties(this, {
|
||||
group: {
|
||||
get() {
|
||||
return group;
|
||||
},
|
||||
enumerable: false,
|
||||
},
|
||||
id: { value: group.id, enumerable: false },
|
||||
type: { value: "group", enumerable: false },
|
||||
}), {
|
||||
profile: {
|
||||
value: profileID,
|
||||
enumerable: false,
|
||||
},
|
||||
root: {
|
||||
value: rootID,
|
||||
enumerable: false,
|
||||
},
|
||||
}, [group.id]);
|
||||
}
|
||||
|
||||
get meta(): G["meta"] {
|
||||
return this.group.meta;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.group.core;
|
||||
}
|
||||
|
||||
addMember(accountID: AccountID | Everyone, role: Role): G {
|
||||
return this.group.addMember(accountID, role);
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountID): G {
|
||||
return this.group.removeMember(accountID);
|
||||
}
|
||||
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
return this.group.createInvite(role);
|
||||
}
|
||||
|
||||
createMap<M extends CoMap>(
|
||||
init?: {
|
||||
[K in keyof M["_shape"]]: M["_shape"][K];
|
||||
},
|
||||
meta?: M["meta"],
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): M {
|
||||
return this.group.createMap(init, meta, initPrivacy);
|
||||
}
|
||||
|
||||
createList<L extends CoList>(
|
||||
init?: L["_item"][],
|
||||
meta?: L["meta"],
|
||||
initPrivacy: "trusting" | "private" = "private"
|
||||
): L {
|
||||
return this.group.createList(init, meta, initPrivacy);
|
||||
}
|
||||
|
||||
createStream<C extends CoStream>(meta?: C["meta"]): C {
|
||||
return this.group.createStream(meta);
|
||||
}
|
||||
|
||||
createBinaryStream<C extends BinaryCoStream>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
): C {
|
||||
return this.group.createBinaryStream(meta);
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,37 @@ import { JsonValue } from "./jsonValue.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoStream } from "./coValues/coStream.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { AnyCoList, AnyCoMap, AnyCoStream, CoID, CoValue } from "./coValue.js";
|
||||
import { Account, AccountID } from "./coValues/account.js";
|
||||
import { CoID, CoValue } from "./coValue.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import {
|
||||
QueriedAccountAndProfile,
|
||||
QueriedCoMap,
|
||||
QueriedCoMapBase,
|
||||
} from "./queriedCoValues/queriedCoMap.js";
|
||||
import { QueriedCoList } from "./queriedCoValues/queriedCoList.js";
|
||||
import { QueriedCoStream } from "./queriedCoValues/queriedCoStream.js";
|
||||
import { Group } from "./coValues/group.js";
|
||||
import { QueriedAccount } from "./queriedCoValues/queriedAccount.js";
|
||||
import { QueriedGroup } from "./queriedCoValues/queriedGroup.js";
|
||||
|
||||
export type Queried<T extends CoValue> = T extends AnyCoMap
|
||||
? QueriedCoMap<T>
|
||||
: T extends AnyCoList
|
||||
export type Queried<T extends CoValue> = T extends CoMap
|
||||
? T extends Account
|
||||
? QueriedAccount<T>
|
||||
: T extends Group
|
||||
? QueriedGroup<T>
|
||||
: QueriedCoMap<T>
|
||||
: T extends CoList
|
||||
? QueriedCoList<T>
|
||||
: T extends AnyCoStream
|
||||
: T extends CoStream
|
||||
? T["meta"] extends { type: "binary" }
|
||||
? never
|
||||
: QueriedCoStream<T>
|
||||
: never;
|
||||
:
|
||||
| QueriedAccount
|
||||
| QueriedGroup
|
||||
| QueriedCoMap<CoMap>
|
||||
| QueriedCoList<CoList>
|
||||
| QueriedCoStream<CoStream>;
|
||||
|
||||
export type ValueOrSubQueried<
|
||||
V extends JsonValue | CoValue | CoID<CoValue> | undefined
|
||||
@@ -36,10 +47,27 @@ export interface CleanupCallbackAndUsable {
|
||||
[Symbol.dispose]: () => void;
|
||||
}
|
||||
|
||||
export interface QueryExtension<T extends CoValue, O> {
|
||||
id: string;
|
||||
query(
|
||||
base: T,
|
||||
queryContext: QueryContext,
|
||||
onUpdate: (value: O) => void
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export class QueryContext {
|
||||
values: {
|
||||
[id: CoID<CoValue>]: {
|
||||
lastUpdate: CoValue | undefined;
|
||||
lastQueried: Queried<CoValue> | undefined;
|
||||
render: () => void;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
extensions: {
|
||||
[id: `${CoID<CoValue>}_${string}`]: {
|
||||
lastOutput: unknown;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
@@ -51,13 +79,68 @@ export class QueryContext {
|
||||
this.onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
getChildLastQueriedOrSubscribe<T extends CoValue>(valueID: CoID<T>) {
|
||||
query<T extends CoValue>(valueID: CoID<T>, alsoRender: CoID<CoValue>[]) {
|
||||
let value = this.values[valueID];
|
||||
if (!value) {
|
||||
const render = () => {
|
||||
let newQueried;
|
||||
const lastUpdate = value!.lastUpdate;
|
||||
|
||||
if (lastUpdate instanceof CoMap) {
|
||||
if (lastUpdate instanceof Account) {
|
||||
newQueried = new QueriedAccount(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
} else if (lastUpdate instanceof Group) {
|
||||
newQueried = new QueriedGroup(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
} else {
|
||||
newQueried = QueriedCoMapBase.newWithKVPairs(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
}
|
||||
} else if (lastUpdate instanceof CoList) {
|
||||
newQueried = new QueriedCoList(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
} else if (lastUpdate instanceof CoStream) {
|
||||
if (lastUpdate.meta?.type === "binary") {
|
||||
// Querying binary string not yet implemented
|
||||
} else {
|
||||
newQueried = new QueriedCoStream(
|
||||
lastUpdate,
|
||||
this
|
||||
) as Queried<T>;
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(
|
||||
// "Rendered ",
|
||||
// valueID,
|
||||
// lastUpdate?.constructor.name,
|
||||
// newQueried
|
||||
// );
|
||||
|
||||
value!.lastQueried = newQueried;
|
||||
|
||||
for (const alsoRenderID of alsoRender) {
|
||||
// console.log("Also rendering", alsoRenderID);
|
||||
this.values[alsoRenderID]?.render();
|
||||
}
|
||||
};
|
||||
|
||||
value = {
|
||||
lastQueried: undefined,
|
||||
unsubscribe: query(valueID, this.node, (childQueried) => {
|
||||
value!.lastQueried = childQueried as Queried<CoValue>;
|
||||
lastUpdate: undefined,
|
||||
render,
|
||||
unsubscribe: this.node.subscribe(valueID, (valueUpdate) => {
|
||||
value!.lastUpdate = valueUpdate;
|
||||
value!.render();
|
||||
this.onUpdate();
|
||||
}),
|
||||
};
|
||||
@@ -66,25 +149,91 @@ export class QueryContext {
|
||||
return value.lastQueried as Queried<T> | undefined;
|
||||
}
|
||||
|
||||
resolveAccount(accountID: AccountID) {
|
||||
return this.getChildLastQueriedOrSubscribe(
|
||||
accountID
|
||||
) as QueriedAccountAndProfile;
|
||||
queryIfCoID<T extends JsonValue | undefined>(value: T, alsoRender: CoID<CoValue>[]): T extends CoID<infer C> ? Queried<C> | undefined : T {
|
||||
if (typeof value === "string" && value.startsWith("co_")) {
|
||||
return this.query(value as CoID<CoValue>, alsoRender) as T extends CoID<infer C> ? Queried<C> | undefined : never;
|
||||
} else {
|
||||
return value as T extends CoID<infer C> ? Queried<C> | undefined : T;
|
||||
}
|
||||
}
|
||||
|
||||
resolveValue<T extends JsonValue>(
|
||||
value: T
|
||||
): T extends CoID<infer C> ? Queried<C> | undefined : T {
|
||||
return (
|
||||
typeof value === "string" && value.startsWith("co_")
|
||||
? this.getChildLastQueriedOrSubscribe(value as CoID<CoValue>)
|
||||
: value
|
||||
) as T extends CoID<infer C> ? Queried<C> | undefined : T;
|
||||
valueOrSubQueryPropertyDescriptor<T extends JsonValue | undefined>(
|
||||
value: T,
|
||||
alsoRender: CoID<CoValue>[]
|
||||
): T extends CoID<infer C>
|
||||
? { get(): Queried<C> | undefined }
|
||||
: { value: T } {
|
||||
if (typeof value === "string" && value.startsWith("co_")) {
|
||||
// TODO: when we track render dirty status, we can actually return the queried value without a getter if it's up to date
|
||||
return {
|
||||
get: () => this.query(value as CoID<CoValue>, alsoRender),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { value: value } as any;
|
||||
}
|
||||
}
|
||||
|
||||
defineSubqueryPropertiesIn<
|
||||
O extends object,
|
||||
P extends {
|
||||
[key: string]: { value: JsonValue | undefined; enumerable: boolean };
|
||||
}
|
||||
>(
|
||||
obj: O,
|
||||
subqueryProps: P,
|
||||
alsoRender: CoID<CoValue>[]
|
||||
): O & {
|
||||
[Key in keyof P]: ValueOrSubQueried<P[Key]["value"]>;
|
||||
} {
|
||||
for (const [key, descriptor] of Object.entries(subqueryProps)) {
|
||||
Object.defineProperty(
|
||||
obj,
|
||||
key,
|
||||
{
|
||||
...this.valueOrSubQueryPropertyDescriptor(descriptor.value, alsoRender),
|
||||
enumerable: descriptor.enumerable,
|
||||
}
|
||||
);
|
||||
}
|
||||
return obj as O & {
|
||||
[Key in keyof P]: ValueOrSubQueried<P[Key]["value"]>
|
||||
};
|
||||
}
|
||||
|
||||
getOrCreateExtension<T extends CoValue, O>(
|
||||
valueID: CoID<T>,
|
||||
extension: QueryExtension<T, O>
|
||||
): O | undefined {
|
||||
const id = `${valueID}_${extension.id}`;
|
||||
let ext = this.extensions[id as keyof typeof this.extensions];
|
||||
if (!ext) {
|
||||
ext = {
|
||||
lastOutput: undefined,
|
||||
unsubscribe: extension.query(
|
||||
this.node
|
||||
.expectCoValueLoaded(valueID)
|
||||
.getCurrentContent() as T,
|
||||
this,
|
||||
(output) => {
|
||||
ext!.lastOutput = output;
|
||||
this.values[valueID]?.render();
|
||||
this.onUpdate();
|
||||
}
|
||||
),
|
||||
};
|
||||
this.extensions[id as keyof typeof this.extensions] = ext;
|
||||
}
|
||||
return ext.lastOutput as O | undefined;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const child of Object.values(this.values)) {
|
||||
child.unsubscribe();
|
||||
child.unsubscribe?.();
|
||||
}
|
||||
for (const extension of Object.values(this.extensions)) {
|
||||
extension.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,49 +241,21 @@ export class QueryContext {
|
||||
export function query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
node: LocalNode,
|
||||
callback: (queried: Queried<T> | undefined) => void,
|
||||
parentContext?: QueryContext
|
||||
callback: (queried: Queried<T> | undefined) => void
|
||||
): CleanupCallbackAndUsable {
|
||||
console.log("querying", id);
|
||||
// console.log("querying", id);
|
||||
|
||||
const context = parentContext || new QueryContext(node, onUpdate);
|
||||
|
||||
const unsubscribe = node.subscribe(id, (update) => {
|
||||
lastRootValue = update;
|
||||
onUpdate();
|
||||
const context = new QueryContext(node, () => {
|
||||
const rootQueried = context.values[id]?.lastQueried as
|
||||
| Queried<T>
|
||||
| undefined;
|
||||
callback(rootQueried);
|
||||
});
|
||||
|
||||
let lastRootValue: T | undefined;
|
||||
|
||||
function onUpdate() {
|
||||
const rootValue = lastRootValue;
|
||||
|
||||
if (rootValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rootValue instanceof CoMap) {
|
||||
callback(
|
||||
QueriedCoMapBase.newWithKVPairs(
|
||||
rootValue,
|
||||
context
|
||||
) as Queried<T>
|
||||
);
|
||||
} else if (rootValue instanceof CoList) {
|
||||
callback(new QueriedCoList(rootValue, context) as Queried<T>);
|
||||
} else if (rootValue instanceof CoStream) {
|
||||
if (rootValue.meta?.type === "binary") {
|
||||
// Querying binary string not yet implemented
|
||||
return {};
|
||||
} else {
|
||||
callback(new QueriedCoStream(rootValue, context) as Queried<T>);
|
||||
}
|
||||
}
|
||||
}
|
||||
context.query(id, []);
|
||||
|
||||
const cleanup = function cleanup() {
|
||||
context.cleanup();
|
||||
unsubscribe();
|
||||
} as CleanupCallbackAndUsable;
|
||||
cleanup[Symbol.dispose] = cleanup;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
WritableStreamDefaultWriter,
|
||||
} from "isomorphic-streams";
|
||||
import { RawCoID, SessionID } from "./ids.js";
|
||||
import { stableStringify } from "./jsonStringify.js";
|
||||
|
||||
export type CoValueKnownState = {
|
||||
id: RawCoID;
|
||||
@@ -67,6 +66,7 @@ export interface Peer {
|
||||
incoming: ReadableStream<SyncMessage>;
|
||||
outgoing: WritableStream<SyncMessage>;
|
||||
role: "peer" | "server" | "client";
|
||||
delayOnError?: number;
|
||||
}
|
||||
|
||||
export interface PeerState {
|
||||
@@ -76,6 +76,7 @@ export interface PeerState {
|
||||
incoming: ReadableStream<SyncMessage>;
|
||||
outgoing: WritableStreamDefaultWriter<SyncMessage>;
|
||||
role: "peer" | "server" | "client";
|
||||
delayOnError?: number;
|
||||
}
|
||||
|
||||
export function combinedKnownStates(
|
||||
@@ -224,7 +225,7 @@ export class SyncManager {
|
||||
peer.optimisticKnownStates[id] || emptyKnownState(id);
|
||||
|
||||
const sendPieces = async () => {
|
||||
for (const [i, piece] of newContentPieces.entries()) {
|
||||
for (const [_i, piece] of newContentPieces.entries()) {
|
||||
// console.log(
|
||||
// `${id} -> ${peer.id}: Sending content piece ${i + 1}/${newContentPieces.length} header: ${!!piece.header}`,
|
||||
// // Object.values(piece.new).map((s) => s.newTransactions)
|
||||
@@ -254,6 +255,7 @@ export class SyncManager {
|
||||
outgoing: peer.outgoing.getWriter(),
|
||||
toldKnownState: new Set(),
|
||||
role: peer.role,
|
||||
delayOnError: peer.delayOnError,
|
||||
};
|
||||
this.peers[peer.id] = peerState;
|
||||
|
||||
@@ -284,6 +286,7 @@ export class SyncManager {
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
new Date(),
|
||||
`Error reading from peer ${peer.id}, handling msg`,
|
||||
JSON.stringify(msg, (k, v) =>
|
||||
k === "changes" || k === "encryptedChanges"
|
||||
@@ -292,6 +295,11 @@ export class SyncManager {
|
||||
),
|
||||
e
|
||||
);
|
||||
if (peerState.delayOnError) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, peerState.delayOnError);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -9,7 +9,7 @@ beforeEach(async () => {
|
||||
|
||||
test("Can create a node while creating a new account with profile", async () => {
|
||||
const { node, accountID, accountSecret, sessionID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
LocalNode.withNewlyCreatedAccount({ name: "Hermes Puggington" });
|
||||
|
||||
expect(node).not.toBeNull();
|
||||
expect(accountID).not.toBeNull();
|
||||
@@ -22,8 +22,9 @@ test("Can create a node while creating a new account with profile", async () =>
|
||||
});
|
||||
|
||||
test("A node with an account can create groups and and objects within them", async () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node, accountID } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
@@ -41,7 +42,7 @@ test("A node with an account can create groups and and objects within them", asy
|
||||
|
||||
test("Can create account with one node, and then load it on another", async () => {
|
||||
const { node, accountID, accountSecret } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
LocalNode.withNewlyCreatedAccount({ name: "Hermes Puggington" });
|
||||
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
@@ -52,16 +53,20 @@ test("Can create account with one node, and then load it on another", async () =
|
||||
expect(edit.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"});
|
||||
const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {
|
||||
trace: true,
|
||||
peer1role: "server",
|
||||
peer2role: "client",
|
||||
});
|
||||
|
||||
node.syncManager.addPeer(node2asPeer);
|
||||
|
||||
const node2 = await LocalNode.withLoadedAccount(
|
||||
const node2 = await LocalNode.withLoadedAccount({
|
||||
accountID,
|
||||
accountSecret,
|
||||
newRandomSessionID(accountID),
|
||||
[node1asPeer]
|
||||
);
|
||||
sessionID: newRandomSessionID(accountID),
|
||||
peersToLoadFrom: [node1asPeer],
|
||||
});
|
||||
|
||||
const map2 = await node2.load(map.id);
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ test("New transactions in a group correctly update owned values, including subsc
|
||||
])
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = group.underlyingMap.core.expectedNewHashAfter(sessionID, [
|
||||
const { expectedNewHash } = group.core.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
@@ -175,7 +175,7 @@ test("New transactions in a group correctly update owned values, including subsc
|
||||
|
||||
expect(map.core.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = group.underlyingMap.core.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
const manuallyAdddedTxSuccess = group.core.tryAddTransactions(node.currentSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,21 @@
|
||||
import { BinaryCoStream, CoList, CoMap, CoStream, Group, LocalNode, cojsonReady } from "..";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
CoList,
|
||||
CoMap,
|
||||
CoStream,
|
||||
Group,
|
||||
LocalNode,
|
||||
cojsonReady,
|
||||
} from "..";
|
||||
|
||||
beforeEach(async () => {
|
||||
await cojsonReady;
|
||||
});
|
||||
|
||||
test("Queries with maps work", async () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node, accountID } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
@@ -16,7 +25,7 @@ test("Queries with maps work", async () => {
|
||||
subMap: CoMap<{
|
||||
hello: "world" | "moon" | "sun";
|
||||
id: string;
|
||||
}>;
|
||||
}>["id"];
|
||||
}>
|
||||
>();
|
||||
|
||||
@@ -33,18 +42,20 @@ test("Queries with maps work", async () => {
|
||||
expect(queriedMap.hello).toBe("world");
|
||||
expect(Object.keys(queriedMap)).toEqual(["hello", "subMap"]);
|
||||
if (queriedMap.edits.hello?.by?.profile?.name) {
|
||||
expect(queriedMap.edits.hello).toMatchObject({
|
||||
by: {
|
||||
id: accountID,
|
||||
profile: {
|
||||
id: node.expectProfileLoaded(accountID).id,
|
||||
name: "Hermes Puggington",
|
||||
},
|
||||
isMe: true,
|
||||
},
|
||||
tx: map.lastEditAt("hello")!.tx,
|
||||
at: new Date(map.lastEditAt("hello")!.at),
|
||||
});
|
||||
expect(queriedMap.edits.hello.by.id).toEqual(accountID);
|
||||
expect(queriedMap.edits.hello.by.profile.id).toEqual(
|
||||
node.expectProfileLoaded(accountID).id
|
||||
);
|
||||
expect(queriedMap.edits.hello.by.profile.name).toEqual(
|
||||
"Hermes Puggington"
|
||||
);
|
||||
expect(queriedMap.edits.hello.by.isMe).toBe(true);
|
||||
expect(queriedMap.edits.hello.tx).toEqual(
|
||||
map.lastEditAt("hello")!.tx
|
||||
);
|
||||
expect(queriedMap.edits.hello.at).toEqual(
|
||||
new Date(map.lastEditAt("hello")!.at)
|
||||
);
|
||||
if (queriedMap.subMap) {
|
||||
expect(queriedMap.subMap.type).toBe("comap");
|
||||
expect(queriedMap.subMap.id).toEqual("foreignID");
|
||||
@@ -79,7 +90,7 @@ test("Queries with maps work", async () => {
|
||||
}>
|
||||
>();
|
||||
|
||||
map = map.set("subMap", subMap);
|
||||
map = map.set("subMap", subMap.id);
|
||||
|
||||
subMap = subMap.mutate((subMap) => {
|
||||
subMap.set("hello", "world");
|
||||
@@ -92,8 +103,9 @@ test("Queries with maps work", async () => {
|
||||
});
|
||||
|
||||
test("Queries with lists work", () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node, accountID } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
@@ -113,20 +125,17 @@ test("Queries with lists work", () => {
|
||||
expect(queriedList[1]).toBe("world");
|
||||
expect(queriedList[2]).toBe("moon");
|
||||
if (queriedList.edits[2]?.by?.profile?.name) {
|
||||
expect(queriedList.edits[2]).toMatchObject({
|
||||
by: {
|
||||
id: accountID,
|
||||
profile: {
|
||||
id: node.expectProfileLoaded(accountID).id,
|
||||
name: "Hermes Puggington",
|
||||
},
|
||||
isMe: true,
|
||||
},
|
||||
at: expect.any(Date),
|
||||
});
|
||||
expect(queriedList.edits[2].by.id).toEqual(accountID);
|
||||
expect(queriedList.edits[2].by.profile.id).toEqual(
|
||||
node.expectProfileLoaded(accountID).id
|
||||
);
|
||||
expect(queriedList.edits[2].by.profile.name).toEqual(
|
||||
"Hermes Puggington"
|
||||
);
|
||||
expect(queriedList.edits[2].by.isMe).toBe(true);
|
||||
expect(queriedList.edits[2].at).toBeInstanceOf(Date);
|
||||
if (queriedList.length === 3) {
|
||||
queriedList.append("sun");
|
||||
|
||||
} else if (
|
||||
queriedList.length === 4 &&
|
||||
queriedList.edits[3]?.by?.profile?.name ===
|
||||
@@ -152,11 +161,13 @@ test("Queries with lists work", () => {
|
||||
});
|
||||
|
||||
test("List of nested maps works", () => {
|
||||
const { node } = LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let list = group.createList<CoList<CoMap<{ hello: "world" }>>>();
|
||||
let list = group.createList<CoList<CoMap<{ hello: "world" }>["id"]>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(list.id, (queriedList) => {
|
||||
@@ -176,23 +187,23 @@ test("List of nested maps works", () => {
|
||||
list = list.append(
|
||||
group.createMap<CoMap<{ hello: "world" }>>({
|
||||
hello: "world",
|
||||
})
|
||||
}).id
|
||||
);
|
||||
|
||||
return done;
|
||||
});
|
||||
|
||||
test("Can call .map on a quieried coList", async () => {
|
||||
const { node } = LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let list = group.createList<CoList<string>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
|
||||
const unsubQuery = node.query(list.id, (queriedList) => {
|
||||
|
||||
if (queriedList && queriedList[0]) {
|
||||
// console.log("update", queriedList);
|
||||
expect(queriedList.map((item) => item + "!!!")).toEqual([
|
||||
@@ -215,8 +226,9 @@ test("Can call .map on a quieried coList", async () => {
|
||||
});
|
||||
|
||||
test("Queries with streams work", () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node, accountID } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
@@ -233,45 +245,110 @@ test("Queries with streams work", () => {
|
||||
expect(queriedStream.group).toBeInstanceOf(Group);
|
||||
expect(queriedStream.group.id).toBe(group.id);
|
||||
expect(queriedStream.meta).toBe(null);
|
||||
const expectedEntry = {
|
||||
last: "world",
|
||||
by: {
|
||||
id: accountID,
|
||||
isMe: true,
|
||||
profile: {
|
||||
id: node.expectProfileLoaded(accountID).id,
|
||||
name: "Hermes Puggington",
|
||||
},
|
||||
},
|
||||
at: new Date(
|
||||
stream.items[node.currentSessionID][1].madeAt
|
||||
),
|
||||
all: [
|
||||
{
|
||||
value: "hello",
|
||||
at: new Date(
|
||||
stream.items[
|
||||
node.currentSessionID
|
||||
][0].madeAt
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "world",
|
||||
at: new Date(
|
||||
stream.items[
|
||||
node.currentSessionID
|
||||
][1].madeAt
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(queriedStream.perSession).toMatchObject({
|
||||
[node.currentSessionID]: expectedEntry,
|
||||
});
|
||||
expect(queriedStream.perAccount).toMatchObject({
|
||||
[accountID]: expectedEntry,
|
||||
});
|
||||
expect(queriedStream.me).toMatchObject(expectedEntry);
|
||||
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].last
|
||||
).toEqual("world");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].all[0].value
|
||||
).toEqual("hello");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].all[0].at
|
||||
).toEqual(
|
||||
new Date(stream.items[node.currentSessionID][0].madeAt)
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].all[1].value
|
||||
).toEqual("world");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].all[1].at
|
||||
).toEqual(
|
||||
new Date(stream.items[node.currentSessionID][1].madeAt)
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].by?.id
|
||||
).toEqual(accountID);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].by?.profile?.id
|
||||
).toEqual(node.expectProfileLoaded(accountID).id);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].by?.profile?.name
|
||||
).toEqual("Hermes Puggington");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].by?.isMe
|
||||
).toBe(true);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perSession)[
|
||||
node.currentSessionID
|
||||
].at
|
||||
).toBeInstanceOf(Date);
|
||||
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.last
|
||||
).toEqual("world");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.all[0].value
|
||||
).toEqual("hello");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.all[0].at
|
||||
).toEqual(
|
||||
new Date(stream.items[node.currentSessionID][0].madeAt)
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.all[1].value
|
||||
).toEqual("world");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.all[1].at
|
||||
).toEqual(
|
||||
new Date(stream.items[node.currentSessionID][1].madeAt)
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.by?.id
|
||||
).toEqual(accountID);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.by?.profile?.id
|
||||
).toEqual(node.expectProfileLoaded(accountID).id);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.by?.profile?.name
|
||||
).toEqual("Hermes Puggington");
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.by?.isMe
|
||||
).toBe(true);
|
||||
expect(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
.at
|
||||
).toBeInstanceOf(Date);
|
||||
|
||||
expect(queriedStream.me).toEqual(
|
||||
Object.fromEntries(queriedStream.perAccount)[accountID]
|
||||
);
|
||||
// console.log("final update", queriedStream);
|
||||
resolve();
|
||||
unsubQuery();
|
||||
@@ -287,11 +364,14 @@ test("Queries with streams work", () => {
|
||||
});
|
||||
|
||||
test("Streams of nested maps work", () => {
|
||||
const { node } = LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
const { node } = LocalNode.withNewlyCreatedAccount({
|
||||
name: "Hermes Puggington",
|
||||
});
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
let stream = group.createStream<CoStream<CoMap<{ hello: "world" }>>>();
|
||||
let stream =
|
||||
group.createStream<CoStream<CoMap<{ hello: "world" }>["id"]>>();
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const unsubQuery = node.query(stream.id, (queriedStream) => {
|
||||
@@ -312,7 +392,7 @@ test("Streams of nested maps work", () => {
|
||||
hello: "world",
|
||||
});
|
||||
|
||||
stream = stream.push(map);
|
||||
stream = stream.push(map.id);
|
||||
|
||||
return done;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,13 @@ import { LocalNode } from "../localNode.js";
|
||||
import { SyncMessage } from "../sync.js";
|
||||
import { expectMap } from "../coValue.js";
|
||||
import { MapOpPayload } from "../coValues/coMap.js";
|
||||
import { Group } from "../group.js";
|
||||
import { Group } from "../coValues/group.js";
|
||||
import {
|
||||
randomAnonymousAccountAndSessionID,
|
||||
shouldNotResolve,
|
||||
} from "./testUtils.js";
|
||||
import { connectedPeers, newStreamPair } from "../streamUtils.js";
|
||||
import { AccountID } from "../account.js";
|
||||
import { AccountID } from "../coValues/account.js";
|
||||
import { cojsonReady } from "../index.js";
|
||||
import { stableStringify } from "../jsonStringify.js";
|
||||
|
||||
@@ -534,7 +534,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
});
|
||||
|
||||
const mapSubscribeMsg = await reader.read();
|
||||
@@ -606,7 +606,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
});
|
||||
|
||||
const map = group.createMap();
|
||||
@@ -660,7 +660,7 @@ test("When we connect a new server peer, we try to sync all existing coValues to
|
||||
|
||||
expect(groupSubscribeMessage.value).toEqual({
|
||||
action: "load",
|
||||
...group.underlyingMap.core.knownState(),
|
||||
...group.core.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
const secondMessage = await reader.read();
|
||||
@@ -756,7 +756,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
const groupSubscribeMsg = await from1.read();
|
||||
expect(groupSubscribeMsg.value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
});
|
||||
|
||||
await to2.write(adminSubscribeMessage.value!);
|
||||
@@ -770,7 +770,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
|
||||
expect(
|
||||
node2.syncManager.peers["test1"]!.optimisticKnownStates[
|
||||
group.underlyingMap.core.id
|
||||
group.core.id
|
||||
]
|
||||
).toBeDefined();
|
||||
|
||||
@@ -970,7 +970,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
});
|
||||
|
||||
const map = group.createMap();
|
||||
@@ -1024,7 +1024,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
});
|
||||
|
||||
const map = group.createMap();
|
||||
@@ -1098,7 +1098,7 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
function groupContentEx(group: Group) {
|
||||
return {
|
||||
action: "content",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1112,7 +1112,7 @@ function admContEx(adminID: AccountID) {
|
||||
function groupStateEx(group: Group) {
|
||||
return {
|
||||
action: "known",
|
||||
id: group.underlyingMap.core.id,
|
||||
id: group.core.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "../crypto.js";
|
||||
import { newRandomSessionID } from "../coValueCore.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import { expectGroupContent } from "../group.js";
|
||||
import { AnonymousControlledAccount } from "../account.js";
|
||||
import { expectGroup } from "../coValues/group.js";
|
||||
import { AnonymousControlledAccount } from "../coValues/account.js";
|
||||
import { SessionID } from "../ids.js";
|
||||
// @ts-ignore
|
||||
import { expect } from "bun:test";
|
||||
@@ -20,43 +20,41 @@ export function newGroup() {
|
||||
|
||||
const node = new LocalNode(admin, sessionID);
|
||||
|
||||
const group = node.createCoValue({
|
||||
const groupCore = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "group", initialAdmin: admin.id },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const groupContent = expectGroupContent(group.getCurrentContent());
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
groupContent.mutate((editable) => {
|
||||
group.mutate((editable) => {
|
||||
editable.set(admin.id, "admin", "trusting");
|
||||
expect(editable.get(admin.id)).toEqual("admin");
|
||||
});
|
||||
|
||||
return { node, group, admin };
|
||||
return { node, groupCore, admin };
|
||||
}
|
||||
|
||||
export function groupWithTwoAdmins() {
|
||||
const { group, admin, node } = newGroup();
|
||||
const { groupCore, admin, node } = newGroup();
|
||||
|
||||
const otherAdmin = node.createAccount("otherAdmin");
|
||||
|
||||
let content = expectGroupContent(group.getCurrentContent());
|
||||
let group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
content.mutate((editable) => {
|
||||
editable.set(otherAdmin.id, "admin", "trusting");
|
||||
expect(editable.get(otherAdmin.id)).toEqual("admin");
|
||||
group = group.mutate((mutable) => {
|
||||
mutable.set(otherAdmin.id, "admin", "trusting");
|
||||
expect(mutable.get(otherAdmin.id)).toEqual("admin");
|
||||
});
|
||||
|
||||
content = expectGroupContent(group.getCurrentContent());
|
||||
|
||||
if (content.type !== "comap") {
|
||||
if (group.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.get(otherAdmin.id)).toEqual("admin");
|
||||
return { group, admin, otherAdmin, node };
|
||||
expect(group.get(otherAdmin.id)).toEqual("admin");
|
||||
return { groupCore, admin, otherAdmin, node };
|
||||
}
|
||||
|
||||
export function newGroupHighLevel() {
|
||||
@@ -71,11 +69,11 @@ export function newGroupHighLevel() {
|
||||
}
|
||||
|
||||
export function groupWithTwoAdminsHighLevel() {
|
||||
const { admin, node, group } = newGroupHighLevel();
|
||||
let { admin, node, group } = newGroupHighLevel();
|
||||
|
||||
const otherAdmin = node.createAccount("otherAdmin");
|
||||
|
||||
group.addMember(otherAdmin.id, "admin");
|
||||
group = group.addMember(otherAdmin.id, "admin");
|
||||
|
||||
return { admin, node, group, otherAdmin };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.3.3",
|
||||
"jazz-browser": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AccountID,
|
||||
AccountMigration,
|
||||
AgentSecret,
|
||||
cojsonInternals,
|
||||
LocalNode,
|
||||
@@ -41,7 +42,8 @@ export class BrowserLocalAuth implements AuthProvider {
|
||||
|
||||
async createNode(
|
||||
getSessionFor: SessionProvider,
|
||||
initialPeers: Peer[]
|
||||
initialPeers: Peer[],
|
||||
migration?: AccountMigration
|
||||
): Promise<LocalNode> {
|
||||
if (sessionStorage[sessionStorageKey]) {
|
||||
const sessionStorageData = JSON.parse(
|
||||
@@ -50,12 +52,13 @@ export class BrowserLocalAuth implements AuthProvider {
|
||||
|
||||
const sessionID = await getSessionFor(sessionStorageData.accountID);
|
||||
|
||||
const node = await LocalNode.withLoadedAccount(
|
||||
sessionStorageData.accountID,
|
||||
sessionStorageData.accountSecret,
|
||||
const node = await LocalNode.withLoadedAccount({
|
||||
accountID: sessionStorageData.accountID,
|
||||
accountSecret: sessionStorageData.accountSecret,
|
||||
sessionID,
|
||||
initialPeers
|
||||
);
|
||||
peersToLoadFrom: initialPeers,
|
||||
migration,
|
||||
});
|
||||
|
||||
this.driver.onSignedIn({ logOut });
|
||||
|
||||
@@ -69,7 +72,8 @@ export class BrowserLocalAuth implements AuthProvider {
|
||||
username,
|
||||
getSessionFor,
|
||||
this.appName,
|
||||
this.appHostname
|
||||
this.appHostname,
|
||||
migration
|
||||
);
|
||||
for (const peer of initialPeers) {
|
||||
node.syncManager.addPeer(peer);
|
||||
@@ -81,7 +85,8 @@ export class BrowserLocalAuth implements AuthProvider {
|
||||
const node = await logIn(
|
||||
getSessionFor,
|
||||
this.appHostname,
|
||||
initialPeers
|
||||
initialPeers,
|
||||
migration
|
||||
);
|
||||
doneSigningUpOrLoggingIn(node);
|
||||
this.driver.onSignedIn({ logOut });
|
||||
@@ -99,15 +104,17 @@ async function signUp(
|
||||
username: string,
|
||||
getSessionFor: SessionProvider,
|
||||
appName: string,
|
||||
appHostname: string
|
||||
appHostname: string,
|
||||
migration?: AccountMigration
|
||||
): Promise<LocalNode> {
|
||||
const secretSeed = cojsonInternals.newRandomSecretSeed();
|
||||
|
||||
const { node, accountID, accountSecret } =
|
||||
LocalNode.withNewlyCreatedAccount(
|
||||
username,
|
||||
agentSecretFromSecretSeed(secretSeed)
|
||||
);
|
||||
LocalNode.withNewlyCreatedAccount({
|
||||
name: username,
|
||||
initialAgentSecret: agentSecretFromSecretSeed(secretSeed),
|
||||
migration,
|
||||
});
|
||||
|
||||
const webAuthNCredentialPayload = new Uint8Array(
|
||||
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength
|
||||
@@ -155,7 +162,8 @@ async function signUp(
|
||||
async function logIn(
|
||||
getSessionFor: SessionProvider,
|
||||
appHostname: string,
|
||||
initialPeers: Peer[]
|
||||
initialPeers: Peer[],
|
||||
migration?: AccountMigration
|
||||
): Promise<LocalNode> {
|
||||
const webAuthNCredential = (await navigator.credentials.get({
|
||||
publicKey: {
|
||||
@@ -197,12 +205,13 @@ async function logIn(
|
||||
accountSecret,
|
||||
} satisfies SessionStorageData);
|
||||
|
||||
const node = await LocalNode.withLoadedAccount(
|
||||
const node = await LocalNode.withLoadedAccount({
|
||||
accountID,
|
||||
accountSecret,
|
||||
await getSessionFor(accountID),
|
||||
initialPeers
|
||||
);
|
||||
sessionID: await getSessionFor(accountID),
|
||||
peersToLoadFrom: initialPeers,
|
||||
migration,
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "jazz-browser-media-images",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.3",
|
||||
"cojson": "^0.4.1",
|
||||
"image-blob-reduce": "^4.1.0",
|
||||
"jazz-browser": "^0.3.3",
|
||||
"jazz-browser": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CoID, Group, LocalNode, Media } from "cojson";
|
||||
import { CoID, Group, LocalNode, Media, QueryExtension } from "cojson";
|
||||
|
||||
import ImageBlobReduce from "image-blob-reduce";
|
||||
import Pica from "pica";
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
createBinaryStreamFromBlob,
|
||||
readBlobFromBinaryStream,
|
||||
} from "jazz-browser";
|
||||
import { QueryContext } from "cojson/dist/queries";
|
||||
|
||||
const pica = new Pica();
|
||||
|
||||
export async function createImage(
|
||||
imageBlobOrFile: Blob | File,
|
||||
inGroup: Group
|
||||
inGroup: Group,
|
||||
maxSize?: 256 | 1024 | 2048
|
||||
): Promise<Media.ImageDefinition> {
|
||||
let originalWidth!: number;
|
||||
let originalHeight!: number;
|
||||
@@ -63,6 +65,8 @@ export async function createImage(
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
if (maxSize === 256) return;
|
||||
|
||||
const max1024 = await Reducer.toBlob(imageBlobOrFile, { max: 1024 });
|
||||
|
||||
if (originalWidth > 1024 || originalHeight > 1024) {
|
||||
@@ -86,6 +90,8 @@ export async function createImage(
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
if (maxSize === 1024) return;
|
||||
|
||||
const max2048 = await Reducer.toBlob(imageBlobOrFile, { max: 2048 });
|
||||
|
||||
if (originalWidth > 2048 || originalHeight > 2048) {
|
||||
@@ -109,6 +115,8 @@ export async function createImage(
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
if (maxSize === 2048) return;
|
||||
|
||||
const originalBinaryStreamId = (
|
||||
await createBinaryStreamFromBlob(imageBlobOrFile, inGroup)
|
||||
).id;
|
||||
@@ -128,10 +136,14 @@ export type LoadingImageInfo = {
|
||||
originalSize?: [number, number];
|
||||
placeholderDataURL?: string;
|
||||
highestResSrc?: string;
|
||||
highestResSrcOrPlaceholder?: string;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
imageDef: CoID<Media.ImageDefinition> | Media.ImageDefinition | {id: CoID<Media.ImageDefinition>},
|
||||
imageDef:
|
||||
| CoID<Media.ImageDefinition>
|
||||
| Media.ImageDefinition
|
||||
| { id: CoID<Media.ImageDefinition> },
|
||||
localNode: LocalNode,
|
||||
progressiveCallback: (update: LoadingImageInfo) => void
|
||||
): () => void {
|
||||
@@ -155,7 +167,9 @@ export function loadImage(
|
||||
if (entry?.state === "loaded") {
|
||||
resState[res as `${number}x${number}`] = { state: "revoked" };
|
||||
// prevent flashing from immediate revocation
|
||||
setTimeout(() => {URL.revokeObjectURL(entry.blobURL)}, 3000);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(entry.blobURL);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
unsubscribe?.();
|
||||
@@ -285,6 +299,7 @@ export function loadImage(
|
||||
originalSize,
|
||||
placeholderDataURL,
|
||||
highestResSrc: blobURL,
|
||||
highestResSrcOrPlaceholder: blobURL
|
||||
});
|
||||
|
||||
unsubFromStream();
|
||||
@@ -316,6 +331,7 @@ export function loadImage(
|
||||
progressiveCallback({
|
||||
originalSize,
|
||||
placeholderDataURL,
|
||||
highestResSrcOrPlaceholder: placeholderDataURL!,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -332,3 +348,18 @@ export function loadImage(
|
||||
|
||||
return cleanUp;
|
||||
}
|
||||
|
||||
export const BrowserImage: QueryExtension<
|
||||
Media.ImageDefinition,
|
||||
LoadingImageInfo
|
||||
> = {
|
||||
id: "BrowserImage",
|
||||
|
||||
query(
|
||||
imageDef: Media.ImageDefinition,
|
||||
queryContext: QueryContext,
|
||||
callback: (update: LoadingImageInfo) => void
|
||||
): () => void {
|
||||
return loadImage(imageDef, queryContext.node, callback);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.3",
|
||||
"cojson-storage-indexeddb": "^0.3.3",
|
||||
"cojson": "^0.4.1",
|
||||
"cojson-storage-indexeddb": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BinaryCoStream, CoValue, CoValueCore, InviteSecret } from "cojson";
|
||||
import {
|
||||
AccountMigration,
|
||||
BinaryCoStream,
|
||||
CoValue,
|
||||
CoValueCore,
|
||||
InviteSecret,
|
||||
} from "cojson";
|
||||
import { BinaryCoStreamMeta } from "cojson";
|
||||
import { MAX_RECOMMENDED_TX_SIZE } from "cojson";
|
||||
import { cojsonReady } from "cojson";
|
||||
@@ -25,11 +31,13 @@ export type BrowserNodeHandle = {
|
||||
export async function createBrowserNode({
|
||||
auth,
|
||||
syncAddress = "wss://sync.jazz.tools",
|
||||
reconnectionTimeout = 300,
|
||||
reconnectionTimeout: initialReconnectionTimeout = 500,
|
||||
migration,
|
||||
}: {
|
||||
auth: AuthProvider;
|
||||
syncAddress?: string;
|
||||
reconnectionTimeout?: number;
|
||||
migration?: AccountMigration;
|
||||
}): Promise<BrowserNodeHandle> {
|
||||
await cojsonReady;
|
||||
let sessionDone: () => void;
|
||||
@@ -37,13 +45,23 @@ export async function createBrowserNode({
|
||||
const firstWsPeer = createWebSocketPeer(syncAddress);
|
||||
let shouldTryToReconnect = true;
|
||||
|
||||
let currentReconnectionTimeout = initialReconnectionTimeout;
|
||||
|
||||
function onOnline() {
|
||||
console.log("Online, resetting reconnection timeout");
|
||||
currentReconnectionTimeout = initialReconnectionTimeout;
|
||||
}
|
||||
|
||||
window.addEventListener("online", onOnline);
|
||||
|
||||
const node = await auth.createNode(
|
||||
(accountID) => {
|
||||
const sessionHandle = getSessionHandleFor(accountID);
|
||||
sessionDone = sessionHandle.done;
|
||||
return sessionHandle.session;
|
||||
},
|
||||
[await IDBStorage.asPeer(), firstWsPeer]
|
||||
[await IDBStorage.asPeer(), firstWsPeer],
|
||||
migration
|
||||
);
|
||||
|
||||
async function websocketReconnectLoop() {
|
||||
@@ -53,15 +71,33 @@ export async function createBrowserNode({
|
||||
peerId.includes(syncAddress)
|
||||
)
|
||||
) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, reconnectionTimeout)
|
||||
);
|
||||
// TODO: this might drain battery, use listeners instead
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} else {
|
||||
console.log("Websocket disconnected, trying to reconnect");
|
||||
node.syncManager.addPeer(createWebSocketPeer(syncAddress));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, reconnectionTimeout)
|
||||
console.log(
|
||||
"Websocket disconnected, trying to reconnect in " +
|
||||
currentReconnectionTimeout +
|
||||
"ms"
|
||||
);
|
||||
currentReconnectionTimeout = Math.min(
|
||||
currentReconnectionTimeout * 2,
|
||||
30000
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, currentReconnectionTimeout);
|
||||
window.addEventListener(
|
||||
"online",
|
||||
() => {
|
||||
console.log(
|
||||
"Online, trying to reconnect immediately"
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
node.syncManager.addPeer(createWebSocketPeer(syncAddress));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +108,7 @@ export async function createBrowserNode({
|
||||
node,
|
||||
done: () => {
|
||||
shouldTryToReconnect = false;
|
||||
window.removeEventListener("online", onOnline);
|
||||
console.log("Cleaning up node");
|
||||
for (const peer of Object.values(node.syncManager.peers)) {
|
||||
peer.outgoing
|
||||
@@ -86,7 +123,8 @@ export async function createBrowserNode({
|
||||
export interface AuthProvider {
|
||||
createNode(
|
||||
getSessionFor: SessionProvider,
|
||||
initialPeers: Peer[]
|
||||
initialPeers: Peer[],
|
||||
migration?: AccountMigration
|
||||
): Promise<LocalNode>;
|
||||
}
|
||||
|
||||
@@ -291,35 +329,34 @@ function websocketWritableStream<T>(ws: WebSocket) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createInviteLink<T extends CoValue>(
|
||||
value: T | {id: CoID<T>, core: CoValueCore},
|
||||
export function createInviteLink(
|
||||
value: CoValue | { id: CoID<CoValue>; core: CoValueCore },
|
||||
role: "reader" | "writer" | "admin",
|
||||
// default to same address as window.location, but without hash
|
||||
{
|
||||
baseURL = window.location.href.replace(/#.*$/, ""),
|
||||
valueHint
|
||||
}: { baseURL?: string, valueHint?: string } = {}
|
||||
valueHint,
|
||||
}: { baseURL?: string; valueHint?: string } = {}
|
||||
): string {
|
||||
const coValueCore = value.core;
|
||||
const node = coValueCore.node;
|
||||
let currentCoValue = coValueCore;
|
||||
|
||||
while (currentCoValue.header.ruleset.type === "ownedByGroup") {
|
||||
currentCoValue = currentCoValue.getGroup().underlyingMap.core;
|
||||
currentCoValue = currentCoValue.getGroup().core;
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "group") {
|
||||
throw new Error("Can't create invite link for object without group");
|
||||
}
|
||||
|
||||
const group = new Group(
|
||||
cojsonInternals.expectGroupContent(currentCoValue.getCurrentContent()),
|
||||
node
|
||||
const group = cojsonInternals.expectGroup(
|
||||
currentCoValue.getCurrentContent()
|
||||
);
|
||||
|
||||
const inviteSecret = group.createInvite(role);
|
||||
|
||||
return `${baseURL}#/invite/${valueHint ? valueHint + "/" : ""}${value.id}/${inviteSecret}`;
|
||||
return `${baseURL}#/invite/${valueHint ? valueHint + "/" : ""}${
|
||||
value.id
|
||||
}/${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink<C extends CoValue>(
|
||||
@@ -353,7 +390,6 @@ export function parseInviteLink<C extends CoValue>(
|
||||
}
|
||||
return { valueID, inviteSecret, valueHint };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation<C extends CoValue>(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.3.3",
|
||||
"jazz-react": "^0.3.3",
|
||||
"jazz-browser-auth-local": "^0.4.1",
|
||||
"jazz-react": "^0.4.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LocalAuth({
|
||||
},
|
||||
},
|
||||
appName,
|
||||
appHostname
|
||||
appHostname,
|
||||
);
|
||||
}, [appName, appHostname, logOutCounter]);
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
};
|
||||
171
packages/jazz-react-media-images/.gitignore
vendored
171
packages/jazz-react-media-images/.gitignore
vendored
@@ -1,171 +0,0 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
@@ -1,2 +0,0 @@
|
||||
coverage
|
||||
node_modules
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "jazz-react-media-images",
|
||||
"version": "0.3.3",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.3",
|
||||
"jazz-browser": "^0.3.3",
|
||||
"jazz-browser-media-images": "^0.3.3",
|
||||
"jazz-react": "^0.3.3",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "17 - 18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { CoID, Media } from "cojson";
|
||||
import { loadImage, LoadingImageInfo } from "jazz-browser-media-images";
|
||||
import { useJazz } from "jazz-react";
|
||||
import { useEffect, useState } from "react";
|
||||
export { createImage, LoadingImageInfo } from "jazz-browser-media-images";
|
||||
|
||||
export function useLoadImage(
|
||||
imageID?: CoID<Media.ImageDefinition> | Media.ImageDefinition | {id: CoID<Media.ImageDefinition>},
|
||||
): LoadingImageInfo | undefined {
|
||||
const { localNode } = useJazz();
|
||||
|
||||
const [imageInfo, setImageInfo] = useState<LoadingImageInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageID) return;
|
||||
const unsubscribe = loadImage(imageID, localNode, (imageInfo) => {
|
||||
setImageInfo(imageInfo);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [imageID, localNode]);
|
||||
|
||||
return imageInfo;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user