Compare commits
50 Commits
cojson-sto
...
cojson-sim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fce45b2b | ||
|
|
1f49d7fda6 | ||
|
|
eec8ee7027 | ||
|
|
188eb2e1e3 | ||
|
|
62867b32d9 | ||
|
|
ccebd2447d | ||
|
|
08dca75789 | ||
|
|
f1cd639a09 | ||
|
|
be18e4de14 | ||
|
|
7e62c91d44 | ||
|
|
b2d5a103b5 | ||
|
|
4ee2cad39e | ||
|
|
b7c8a0038b | ||
|
|
8c27e8c379 | ||
|
|
0133aa47ff | ||
|
|
5659c925a2 | ||
|
|
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
|
||||
|
||||
22
README.md
22
README.md
@@ -56,19 +56,19 @@ Jazz is open source and you can run your own sync & storage server, but to reall
|
||||
|
||||
### Building a new, entirely sync-based React app
|
||||
|
||||
1. Define your data model with [cojson Collaborative Values (CoValues)](./DOCS.md/#covalue).
|
||||
2. Implement permission logic using [cojson Groups](./DOCS.md/#group).
|
||||
3. Build a user interface with [jazz-react](./DOCS.md/#jazz-react)'s reactive [synced queries](./DOCS.md/#usesyncedqueryid).
|
||||
1. Define your data model with [cojson Collaborative Values (CoValues)](./DOCS.md#covalue).
|
||||
2. Implement permission logic using [cojson Groups](./DOCS.md#group).
|
||||
3. Build a user interface with [jazz-react](./DOCS.md#jazz-react) and [auto-sub](./DOCS.md#useautosubid).
|
||||
|
||||
### Gradually adding sync to an existing React app
|
||||
|
||||
Gradually migrate app features to use sync:
|
||||
|
||||
1. Define data model for small aspect of your app with [cojson Collaborative Values (CoValues)](./DOCS.md/#covalue).
|
||||
1. Define data model for small aspect of your app with [cojson Collaborative Values (CoValues)](./DOCS.md#covalue).
|
||||
- Schema adapters/importers for Prisma/Drizzle/PostgreSQL introspection coming soon.
|
||||
2. Map existing permission logic with [cojson Groups](./DOCS.md/#group) & integrate existing auth.
|
||||
2. Map existing permission logic with [cojson Groups](./DOCS.md#group) & integrate existing auth.
|
||||
- Auth integrations coming soon.
|
||||
3. Replace some of the React state and API requests in your UI with [jazz-react](./DOCS.md/#jazz-react)'s reactive [synced queries](./DOCS.md/#usesyncedqueryid).
|
||||
3. Replace some of the React state and API requests in your UI with [jazz-react](./DOCS.md#jazz-react) and [auto-sub](./DOCS.md#useautosubid).
|
||||
|
||||
# Example Apps
|
||||
|
||||
@@ -103,14 +103,14 @@ Demonstrates:
|
||||
|
||||
For now, docs are hosted in a single well-structured markdown file: [`./DOCS.md`](./DOCS.md).
|
||||
|
||||
- [Package Overview](./DOCS.md/#overview)
|
||||
- [`jazz-react` API](./DOCS.md/#jazz-react)
|
||||
- [`cojson` API](./DOCS.md/#cojson)
|
||||
- [`jazz-react-media-images` API](./DOCS.md/#jazz-react-media-images)
|
||||
- [Package Overview](./DOCS.md#overview)
|
||||
- [`jazz-react` API](./DOCS.md#jazz-react)
|
||||
- [`cojson` API](./DOCS.md#cojson)
|
||||
- [`jazz-browser-media-images` API](./DOCS.md#jazz-browser-media-images)
|
||||
|
||||
|
||||
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.21",
|
||||
"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.9",
|
||||
"jazz-react": "^0.4.8",
|
||||
"jazz-react-auth-local": "^0.4.8",
|
||||
"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,6 @@
|
||||
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 +14,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 +33,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 +57,10 @@ export default function App() {
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <PostOverview />,
|
||||
},
|
||||
{
|
||||
path: "/new",
|
||||
element: <NewPetPostForm />,
|
||||
},
|
||||
{
|
||||
@@ -65,22 +76,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 (
|
||||
<>
|
||||
{myPosts?.length ? (
|
||||
<>
|
||||
<h1>My posts</h1>
|
||||
{myPosts.map(
|
||||
(post) =>
|
||||
post && (
|
||||
<Link key={post.id} to={"/pet/" + post.id}>
|
||||
{post.name}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
<Link to="/new">New post</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,48 @@
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { CoID, CoMap, Media } from "cojson";
|
||||
import { useJazz, useSyncedQuery } from "jazz-react";
|
||||
import { createImage } from "jazz-browser-media-images";
|
||||
import { CoID, CoMap, Media, Profile } from "cojson";
|
||||
import { useAutoSub, useJazz } from "jazz-react";
|
||||
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<
|
||||
CoID<PartialPetPost> | undefined
|
||||
>(undefined);
|
||||
|
||||
const newPetPost = useSyncedQuery(newPostId);
|
||||
const newPetPost = useAutoSub(newPostId);
|
||||
|
||||
const onChangeName = useCallback(
|
||||
(name: string) => {
|
||||
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(
|
||||
@@ -52,15 +51,26 @@ export function NewPetPostForm() {
|
||||
|
||||
const image = await createImage(
|
||||
event.target.files[0],
|
||||
newPetPost.group
|
||||
newPetPost.meta.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>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useParams } from "react-router";
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useSyncedQuery } from "jazz-react";
|
||||
import { CoID } from "cojson";
|
||||
|
||||
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";
|
||||
import { Resolved, useAutoSub } from "jazz-react";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
@@ -24,8 +24,7 @@ const reactionEmojiMap: { [reaction in ReactionType]: string } = {
|
||||
export function RatePetPostUI() {
|
||||
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
|
||||
|
||||
const petPost = useSyncedQuery(petPostID);
|
||||
const petImage = useLoadImage(petPost?.image);
|
||||
const petPost = useAutoSub(petPostID);
|
||||
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -61,7 +63,7 @@ export function RatePetPostUI() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{petPost?.group.myRole() === "admin" && petPost.reactions && (
|
||||
{petPost?.meta.group.myRole() === "admin" && petPost.reactions && (
|
||||
<ReactionOverview petReactions={petPost.reactions} />
|
||||
)}
|
||||
</div>
|
||||
@@ -71,16 +73,16 @@ export function RatePetPostUI() {
|
||||
function ReactionOverview({
|
||||
petReactions,
|
||||
}: {
|
||||
petReactions: Queried<PetReactions>;
|
||||
petReactions: Resolved<PetReactions>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Reactions</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{REACTION_TYPES.map((reactionType) => {
|
||||
const reactionsOfThisType = Object.values(
|
||||
petReactions.perAccount
|
||||
).filter(({ last }) => last === reactionType);
|
||||
const reactionsOfThisType = petReactions.perAccount
|
||||
.map(([, reaction]) => reaction)
|
||||
.filter(({ last }) => last === reactionType);
|
||||
|
||||
if (reactionsOfThisType.length === 0) return null;
|
||||
|
||||
|
||||
@@ -2,18 +2,17 @@ import { useState } from "react";
|
||||
|
||||
import { PetPost } from "../1_types";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { Resolved, createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { Queried } from "cojson";
|
||||
|
||||
export function ShareButton({ petPost }: { petPost?: Queried<PetPost> }) {
|
||||
export function ShareButton({ petPost }: { petPost?: Resolved<PetPost> }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
petPost?.group.myRole() === "admin" && (
|
||||
petPost?.meta.group.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.37",
|
||||
"version": "0.0.45",
|
||||
"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.8",
|
||||
"jazz-react-auth-local": "^0.4.8",
|
||||
"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 */
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import {
|
||||
RouterProvider,
|
||||
createHashRouter,
|
||||
useNavigate,
|
||||
} 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 { NewProjectForm } from "./3_NewProjectForm.tsx";
|
||||
import { ProjectTodoTable } from "./4_ProjectTodoTable.tsx";
|
||||
import { TodoAccountRoot, migration } from "./1_types.ts";
|
||||
import { AccountMigration, Profile } from "cojson";
|
||||
|
||||
/**
|
||||
* Walkthrough: The top-level provider `<WithJazz/>`
|
||||
@@ -33,9 +39,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>
|
||||
);
|
||||
|
||||
@@ -54,7 +65,7 @@ function App() {
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <NewProjectForm />,
|
||||
element: <HomeScreen />,
|
||||
},
|
||||
{
|
||||
path: "/project/:projectId",
|
||||
@@ -62,8 +73,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 +82,39 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
export function HomeScreen() {
|
||||
const { me } = useJazz<Profile, TodoAccountRoot>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
{me.root?.projects?.length ? <h1>My Projects</h1> : null}
|
||||
{me.root?.projects?.map((project) => {
|
||||
return (
|
||||
<Button
|
||||
key={project?.id}
|
||||
onClick={() => navigate("/project/" + project?.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
{project?.title}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<NewProjectForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,17 @@ import { useCallback } from "react";
|
||||
|
||||
import { useJazz } from "jazz-react";
|
||||
|
||||
import { Task, TodoProject } from "./1_types";
|
||||
import { ListOfTasks, TodoAccountRoot, TodoProject } from "./1_types";
|
||||
|
||||
import { SubmittableInput } from "./basicComponents";
|
||||
|
||||
import { CoList } from "cojson";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Profile } from "cojson";
|
||||
|
||||
export function NewProjectForm() {
|
||||
// A `LocalNode` represents a local view of loaded & created CoValues.
|
||||
// It is associated with a current user account, which will determine
|
||||
// `me` represents the current user account, which will determine
|
||||
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
|
||||
const { localNode } = useJazz();
|
||||
const { me } = useJazz<Profile, TodoAccountRoot>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createProject = useCallback(
|
||||
@@ -23,17 +22,19 @@ export function NewProjectForm() {
|
||||
// To create a new todo project, we first create a `Group`,
|
||||
// which is a scope for defining access rights (reader/writer/admin)
|
||||
// of its members, which will apply to all CoValues owned by that group.
|
||||
const projectGroup = localNode.createGroup();
|
||||
const projectGroup = me.createGroup();
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
me.root?.projects?.append(project.id);
|
||||
|
||||
navigate("/project/" + project.id);
|
||||
},
|
||||
[localNode, navigate]
|
||||
[me, navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { CoID, Queried } from "cojson";
|
||||
import { useSyncedQuery } from "jazz-react";
|
||||
import { CoID } from "cojson";
|
||||
|
||||
import { TodoProject, Task } from "./1_types";
|
||||
|
||||
@@ -20,11 +19,12 @@ import {
|
||||
import { InviteButton } from "./components/InviteButton";
|
||||
import uniqolor from "uniqolor";
|
||||
import { useParams } from "react-router";
|
||||
import { Resolved, useAutoSub } from "jazz-react";
|
||||
|
||||
/** Walkthrough: Reactively rendering a todo project as a table,
|
||||
* adding and editing tasks
|
||||
*
|
||||
* Here in `<TodoTable/>`, we use `useSyncedQuery()` for the first time,
|
||||
* Here in `<TodoTable/>`, we use `useAutoSub()` for the first time,
|
||||
* in this case to load the CoValue for our `TodoProject` as well as
|
||||
* the `ListOfTasks` referenced in it.
|
||||
*/
|
||||
@@ -32,11 +32,11 @@ import { useParams } from "react-router";
|
||||
export function ProjectTodoTable() {
|
||||
const projectId = useParams<{ projectId: CoID<TodoProject> }>().projectId;
|
||||
|
||||
// `useSyncedQuery()` reactively subscribes to updates to a CoValue's
|
||||
// `useAutoSub()` reactively subscribes to updates to a CoValue's
|
||||
// content - whether we create edits locally, load persisted data, or receive
|
||||
// sync updates from other devices or participants!
|
||||
// It also recursively resolves and subsribes to all referenced CoValues.
|
||||
const project = useSyncedQuery(projectId);
|
||||
const project = useAutoSub(projectId);
|
||||
|
||||
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
|
||||
// for a new task (in the same group as the project), and then
|
||||
@@ -44,17 +44,17 @@ export function ProjectTodoTable() {
|
||||
const createTask = useCallback(
|
||||
(text: string) => {
|
||||
if (!project?.tasks || !text) return;
|
||||
const task = project.group.createMap<Task>({
|
||||
const task = project.meta.group.createMap<Task>({
|
||||
done: false,
|
||||
text,
|
||||
});
|
||||
|
||||
// project.tasks is immutable, but `append` will create an edit
|
||||
// that will cause useSyncedQuery to rerender this component
|
||||
// that will cause useAutoSub to rerender this component
|
||||
// - here and on other devices!
|
||||
project.tasks.append(task);
|
||||
project.tasks.append(task.id);
|
||||
},
|
||||
[project?.tasks, project?.group]
|
||||
[project?.tasks, project?.meta.group]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -97,7 +97,7 @@ export function ProjectTodoTable() {
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
||||
export function TaskRow({ task }: { task: Resolved<Task> | undefined }) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
@@ -124,12 +124,12 @@ export function TaskRow({ task }: { task: Queried<Task> | undefined }) {
|
||||
{
|
||||
// Here we see for the first time how we can access edit history
|
||||
// for a CoValue, and use it to display who created the task.
|
||||
task?.edits.text?.by?.profile?.name ? (
|
||||
task?.meta.edits.text?.by?.profile?.name ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={uniqueColoring(task.edits.text.by.id)}
|
||||
style={uniqueColoring(task.meta.edits.text.by.id)}
|
||||
>
|
||||
{task.edits.text.by.profile.name}
|
||||
{task.meta.edits.text.by.profile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { CoValue, Queried } from "cojson";
|
||||
import { CoValue } from "cojson";
|
||||
import { Resolved, createInviteLink } from "jazz-react";
|
||||
|
||||
export function InviteButton<T extends CoValue>({ value }: { value: T | Queried<T> | undefined }) {
|
||||
export function InviteButton<T extends CoValue>({ value }: { value?: Resolved<T> }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
value?.group?.myRole() === "admin" && (
|
||||
value?.meta.group?.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!value.group || !value.id}
|
||||
disabled={!value.meta.group || !value.id}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (value.group && value.id && !inviteLink) {
|
||||
if (value.meta.group && value.id && !inviteLink) {
|
||||
inviteLink = createInviteLink(value, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
|
||||
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>Twit</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.8",
|
||||
"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.9",
|
||||
"jazz-react": "^0.4.8",
|
||||
"jazz-react-auth-local": "^0.4.8",
|
||||
"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.meta.edits.text?.at?.getTime() || 0) - (a.meta.edits.text?.at?.getTime() || 0)),
|
||||
[myTwits, twitsFromFollows]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch">
|
||||
<CreateTwitForm className="mb-10" />
|
||||
<MainH1>From people you follow</MainH1>
|
||||
{allTwitsSorted?.map(twit => (
|
||||
<TwitComponent twit={twit} key={twit.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 { BrowserImage } from 'jazz-browser-media-images';
|
||||
import { HeartIcon, MessagesSquareIcon } from 'lucide-react';
|
||||
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
|
||||
import { Resolved } from 'jazz-react';
|
||||
|
||||
export function TwitComponent({
|
||||
twit,
|
||||
alreadyInReplies: alreadyInReplies
|
||||
}: {
|
||||
twit?: Resolved<Twit>;
|
||||
alreadyInReplies?: boolean;
|
||||
}) {
|
||||
const [showReplyForm, setShowReplyForm] = React.useState(false);
|
||||
|
||||
const posterProfile = twit?.meta.edits.text?.by?.profile as Resolved<TwitProfile> | undefined;
|
||||
const isTopLevel = !twit?.isReplyTo || alreadyInReplies;
|
||||
|
||||
return (
|
||||
<TwitWithRepliesContainer isTopLevel={isTopLevel}>
|
||||
<TwitContainer>
|
||||
<ProfilePicImg
|
||||
src={posterProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
linkTo={'/' + posterProfile?.id}
|
||||
initial={posterProfile?.name[0]}
|
||||
size={twit?.isReplyTo && "sm"}
|
||||
/>
|
||||
|
||||
<TwitBody>
|
||||
<TwitHeader>
|
||||
<Link to={'/' + posterProfile?.id} className="font-bold hover:underline">
|
||||
{posterProfile?.name}
|
||||
</Link>
|
||||
<SubtleRelativeTimeAgo dateTime={twit?.meta.edits.text?.at} />
|
||||
</TwitHeader>
|
||||
|
||||
<TwitText style={posterProfile?.twitStyle}>
|
||||
{/* This is where the tweet text goes */}
|
||||
{twit?.text}
|
||||
</TwitText>
|
||||
|
||||
{twit?.images && (
|
||||
<TwitImgGallery>
|
||||
{twit.images.map(image => (
|
||||
<TwitImg src={image?.as(BrowserImage)?.highestResSrcOrPlaceholder} key={image?.id} />
|
||||
))}
|
||||
</TwitImgGallery>
|
||||
)}
|
||||
|
||||
<ReactionsContainer>
|
||||
<ButtonWithCount
|
||||
active={twit?.likes?.me?.last === '❤️'}
|
||||
onClick={() => twit?.likes?.push(twit?.likes?.me?.last ? null : '❤️')}
|
||||
count={twit?.likes?.perAccount.filter(([, liked]) => liked.last === '❤️').length || 0}
|
||||
icon={<HeartIcon size="18" />}
|
||||
activeIcon={<HeartIcon color="red" size="18" fill="red" />}
|
||||
/>
|
||||
<ButtonWithCount
|
||||
onClick={() => setShowReplyForm(s => !s)}
|
||||
count={twit?.replies?.perAccount.flatMap(([, byAccount]) => byAccount.all).length || 0}
|
||||
icon={<MessagesSquareIcon size="18" />}
|
||||
/>
|
||||
</ReactionsContainer>
|
||||
</TwitBody>
|
||||
</TwitContainer>
|
||||
|
||||
<RepliesContainer>
|
||||
{showReplyForm && (
|
||||
<CreateTwitForm
|
||||
inReplyTo={twit}
|
||||
onSubmit={() => setShowReplyForm(false)}
|
||||
className={'mt-5 ' + (isTopLevel ? 'ml-14' : 'ml-12')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{twit?.replies?.perAccount
|
||||
.flatMap(([, byAccount]) => byAccount.all)
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime())
|
||||
.map(replyEntry => (
|
||||
<TwitComponent twit={replyEntry.value} key={replyEntry.value?.id} alreadyInReplies={!!twit?.isReplyTo} />
|
||||
))}
|
||||
</RepliesContainer>
|
||||
</TwitWithRepliesContainer>
|
||||
);
|
||||
}
|
||||
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, useAutoSub } from 'jazz-react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
BioInput,
|
||||
ChooseProfilePicInput,
|
||||
FollowerStatsContainer,
|
||||
Popover,
|
||||
ProfileName,
|
||||
ProfilePicImg,
|
||||
ProfileTitleContainer,
|
||||
SmallInlineButton
|
||||
} from './basicComponents/index.tsx';
|
||||
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { CoID } from 'cojson';
|
||||
import { BrowserImage, createImage } from 'jazz-browser-media-images';
|
||||
import { FollowButton, FollowerList, FollowingList } from './7_FollowStuff.tsx';
|
||||
import { CreateTwitForm } from './6_CreateTwitForm.tsx';
|
||||
import { TwitComponent } from './4_TwitComponent.tsx';
|
||||
import { PopoverContent, PopoverTrigger } from '@radix-ui/react-popover';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { profileId } = useParams<{ profileId: CoID<TwitProfile> }>();
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const profile = useAutoSub(profileId);
|
||||
const isMe = profile?.id == me.profile?.id;
|
||||
|
||||
const profileTwitsAndRepliedToTwits = useMemo(() => {
|
||||
return profile?.twits?.map((twit, _, allTwits) =>
|
||||
twit?.isReplyTo
|
||||
? allTwits.some(
|
||||
tw =>
|
||||
tw?.id === twit?.isReplyTo?.id ||
|
||||
tw?.id === twit?.isReplyTo?.isReplyTo?.id ||
|
||||
tw?.id === twit?.isReplyTo?.isReplyTo?.isReplyTo?.id
|
||||
)
|
||||
? null
|
||||
: twit?.isReplyTo
|
||||
: twit
|
||||
);
|
||||
}, [profile?.twits]);
|
||||
|
||||
const [qr, setQr] = useState<string>('');
|
||||
useEffect(() => {
|
||||
QRCode.toDataURL(
|
||||
window.location.protocol + '//' + window.location.host + window.location.pathname + '#/' + profile?.id,
|
||||
{
|
||||
errorCorrectionLevel: 'L'
|
||||
}
|
||||
).then(setQr);
|
||||
}, [profile?.id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="py-2 mb-5 flex gap-4">
|
||||
<div className="flex flex-col items-stretch">
|
||||
<ProfilePicImg
|
||||
src={profile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
initial={profile?.name[0]}
|
||||
size="xxl"
|
||||
/>
|
||||
{isMe && (
|
||||
<ChooseProfilePicInput
|
||||
onChange={(file: File) =>
|
||||
me.root?.peopleWhoCanSeeMyTwits &&
|
||||
createImage(file, me.root.peopleWhoCanSeeMyTwits, 256).then(image => {
|
||||
me.profile?.set({ avatar: image.id }, 'trusting');
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<ProfileTitleContainer>
|
||||
<ProfileName>{profile?.name}</ProfileName>
|
||||
{!isMe && <FollowButton profile={profile} />}
|
||||
</ProfileTitleContainer>
|
||||
|
||||
<div>
|
||||
{isMe ? (
|
||||
<BioInput
|
||||
value={profile?.bio}
|
||||
onChange={newBio => {
|
||||
profile?.set({ bio: newBio }, 'trusting');
|
||||
// prettier-ignore
|
||||
if (newBio.startsWith('{')) { profile?.set('twitStyle', JSON.parse(newBio), 'trusting'); } else { profile?.set('twitStyle', undefined, 'trusting'); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
profile?.bio || '(No bio)'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FollowerStatsContainer>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<SmallInlineButton>
|
||||
{profile?.followers?.perAccount?.filter(([, status]) => status.last).length} Followers
|
||||
</SmallInlineButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<FollowerList profile={profile} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="hidden md:block">—</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>
|
||||
);
|
||||
}
|
||||
73
examples/twit/src/6_CreateTwitForm.tsx
Normal file
73
examples/twit/src/6_CreateTwitForm.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Resolved, useJazz } from 'jazz-react';
|
||||
import { AddTwitPicsInput, TwitImg, TwitTextInput } from './basicComponents/index.tsx';
|
||||
import { LikeStream, ListOfImages, ReplyStream, Twit, TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { createImage } from 'jazz-browser-media-images';
|
||||
|
||||
export function CreateTwitForm(
|
||||
props: {
|
||||
inReplyTo?: Resolved<Twit>;
|
||||
onSubmit?: () => void;
|
||||
className?: string;
|
||||
} = {}
|
||||
) {
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const [pics, setPics] = React.useState<File[]>([]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(twitText: string) => {
|
||||
const audience = me.root?.peopleWhoCanSeeMyTwits;
|
||||
const interactors = me.root?.peopleWhoCanInteractWithMe;
|
||||
if (!audience || !interactors) return;
|
||||
|
||||
const twit = audience.createMap<Twit>({
|
||||
text: twitText,
|
||||
likes: interactors.createStream<LikeStream>().id,
|
||||
replies: interactors.createStream<ReplyStream>().id
|
||||
});
|
||||
|
||||
me.profile?.twits?.prepend(twit?.id as Twit['id']);
|
||||
|
||||
if (props.inReplyTo) {
|
||||
props.inReplyTo.replies?.push(twit.id);
|
||||
twit.set({ isReplyTo: props.inReplyTo.id });
|
||||
}
|
||||
|
||||
Promise.all(pics.map(pic => createImage(pic, twit.group, 1024))).then(createdPics => {
|
||||
twit.set({ images: audience.createList<ListOfImages>(createdPics.map(pic => pic.id)).id });
|
||||
});
|
||||
|
||||
setPics([]);
|
||||
props.onSubmit?.();
|
||||
},
|
||||
[me.profile?.twits, me.root?.peopleWhoCanSeeMyTwits, me.root?.peopleWhoCanInteractWithMe, props, pics]
|
||||
);
|
||||
|
||||
const [picPreviews, setPicPreviews] = React.useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const previews = pics.map(pic => URL.createObjectURL(pic));
|
||||
setPicPreviews(previews);
|
||||
return () => previews.forEach(preview => URL.revokeObjectURL(preview));
|
||||
}, [pics]);
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<TwitTextInput onSubmit={onSubmit} submitButtonLabel={props.inReplyTo ? 'Reply!' : 'Twit!'} />
|
||||
|
||||
{picPreviews.length ? (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{picPreviews.map(preview => (
|
||||
<TwitImg src={preview} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AddTwitPicsInput
|
||||
onChange={(newPics: File[]) => {
|
||||
setPics(newPics);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
examples/twit/src/7_FollowStuff.tsx
Normal file
80
examples/twit/src/7_FollowStuff.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Resolved, useJazz } from 'jazz-react';
|
||||
import { Button, ProfilePicImg } from './basicComponents/index.tsx';
|
||||
import { TwitAccountRoot, TwitProfile } from './1_dataModel.ts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BrowserImage } from 'jazz-browser-media-images';
|
||||
|
||||
export function FollowButton({ profile }: { profile?: Resolved<TwitProfile> }) {
|
||||
const { me } = useJazz<TwitProfile, TwitAccountRoot>();
|
||||
|
||||
const alreadyFollowing = profile?.followers?.perAccount?.some(([acc, status]) => acc === me.id && !!status.last);
|
||||
const theyFollowMe = profile?.following?.some(f => f?.id === me.profile?.id);
|
||||
|
||||
const followOrUnfollow = useCallback(() => {
|
||||
if (!profile?.followers || !me.profile?.following) return;
|
||||
if (alreadyFollowing) {
|
||||
me.profile.following.delete(me.profile.following.findIndex(f => f?.id === profile.id));
|
||||
profile.followers.push(null);
|
||||
} else {
|
||||
me.profile.following.append(profile.id);
|
||||
profile.followers.push(me.profile.id);
|
||||
}
|
||||
}, [alreadyFollowing, me.profile, profile]);
|
||||
|
||||
return profile?.id === me.profile?.id ? (
|
||||
<div className="ml-auto text-neutral-500">That's you!</div>
|
||||
) : (
|
||||
<Button onClick={followOrUnfollow} className="ml-auto" variant={alreadyFollowing ? 'ghost' : 'default'}>
|
||||
{alreadyFollowing ? 'Unfollow' : theyFollowMe ? 'Follow Back' : 'Follow'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowerList({ profile }: { profile?: Resolved<TwitProfile> }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 bg-background rounded-lg border shadow-lg w-96 max-w-full m-2">
|
||||
{profile?.followers?.perAccount.map(([, followEntry]) => {
|
||||
const followerProfile = followEntry.last;
|
||||
// not following anymore?
|
||||
if (!followerProfile) return null;
|
||||
|
||||
return (
|
||||
<div key={followerProfile.id} className="flex items-center">
|
||||
<ProfilePicImg
|
||||
src={followerProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
linkTo={'/' + followerProfile?.id}
|
||||
initial={followerProfile?.name[0]}
|
||||
/>
|
||||
<Link to={'/' + followerProfile?.id} className="font-bold hover:underline">
|
||||
{followerProfile?.name}
|
||||
</Link>
|
||||
<FollowButton profile={followerProfile} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowingList({ profile }: { profile?: Resolved<TwitProfile> }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 bg-background rounded-lg border shadow-lg w-96 max-w-full m-2">
|
||||
{[...new Set(profile?.following || [])].map(followingProfile => {
|
||||
return (
|
||||
<div key={followingProfile?.id} className="flex items-center">
|
||||
<ProfilePicImg
|
||||
src={followingProfile?.avatar?.as(BrowserImage)?.highestResSrcOrPlaceholder}
|
||||
linkTo={'/' + followingProfile?.id}
|
||||
initial={followingProfile?.name[0]}
|
||||
/>
|
||||
<Link to={'/' + followingProfile?.id} className="font-bold hover:underline">
|
||||
{followingProfile?.name}
|
||||
</Link>
|
||||
<FollowButton profile={followingProfile} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,9 +10,9 @@ 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",
|
||||
"jazz-autosub": "index.ts",
|
||||
}).map(async ([packageName, entryPoint]) => {
|
||||
const app = await Application.bootstrapWithPlugins({
|
||||
entryPoints: [`packages/${packageName}/src/${entryPoint}`],
|
||||
|
||||
3
homepage/homepage-jazz/.eslintrc.json
Normal file
3
homepage/homepage-jazz/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
35
homepage/homepage-jazz/.gitignore
vendored
Normal file
35
homepage/homepage-jazz/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
homepage/homepage-jazz/README.md
Normal file
36
homepage/homepage-jazz/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
BIN
homepage/homepage-jazz/app/favicon.ico
Normal file
BIN
homepage/homepage-jazz/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
27
homepage/homepage-jazz/app/globals.css
Normal file
27
homepage/homepage-jazz/app/globals.css
Normal file
@@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
22
homepage/homepage-jazz/app/layout.tsx
Normal file
22
homepage/homepage-jazz/app/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import './globals.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
113
homepage/homepage-jazz/app/page.tsx
Normal file
113
homepage/homepage-jazz/app/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
||||
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
|
||||
Get started by editing
|
||||
<code className="font-mono font-bold">app/page.tsx</code>
|
||||
</p>
|
||||
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
|
||||
<a
|
||||
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
By{' '}
|
||||
<Image
|
||||
src="/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
className="dark:invert"
|
||||
width={100}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
|
||||
<Image
|
||||
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js Logo"
|
||||
width={180}
|
||||
height={37}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Docs{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Find in-depth information about Next.js features and API.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Learn{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Learn about Next.js in an interactive course with quizzes!
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Templates{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Explore the Next.js 13 playground.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Deploy{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Instantly deploy your Next.js site to a shareable URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
4
homepage/homepage-jazz/next.config.js
Normal file
4
homepage/homepage-jazz/next.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
27
homepage/homepage-jazz/package.json
Normal file
27
homepage/homepage-jazz/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "homepage-jazz",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"next": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"autoprefixer": "latest",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"eslint": "latest",
|
||||
"eslint-config-next": "latest"
|
||||
}
|
||||
}
|
||||
6
homepage/homepage-jazz/postcss.config.js
Normal file
6
homepage/homepage-jazz/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
homepage/homepage-jazz/public/next.svg
Normal file
1
homepage/homepage-jazz/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
homepage/homepage-jazz/public/vercel.svg
Normal file
1
homepage/homepage-jazz/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
20
homepage/homepage-jazz/tailwind.config.ts
Normal file
20
homepage/homepage-jazz/tailwind.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
27
homepage/homepage-jazz/tsconfig.json
Normal file
27
homepage/homepage-jazz/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.8",
|
||||
"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.8",
|
||||
"cojson-storage-sqlite": "^0.4.8",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.3.3",
|
||||
"cojson": "^0.4.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"scripts": {
|
||||
"test": "vitest --browser chrome",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
|
||||
|
||||
@@ -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,18 +1,18 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.8",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^8.5.2",
|
||||
"cojson": "^0.3.3",
|
||||
"cojson": "^0.4.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "cojson",
|
||||
"module": "dist/index.js",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.8",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
@@ -26,7 +26,7 @@
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { RawCoID } from "./ids.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import {
|
||||
BinaryCoStream,
|
||||
BinaryCoStreamMeta,
|
||||
CoStream,
|
||||
} from "./coValues/coStream.js";
|
||||
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
|
||||
import { CoList } from "./coValues/coList.js";
|
||||
import { CoValueCore } from "./coValueCore.js";
|
||||
import { Group } from "./group.js";
|
||||
import { Group } from "./coValues/group.js";
|
||||
import { Account, Profile } from "./index.js";
|
||||
|
||||
export type CoID<T extends CoValue> = RawCoID & {
|
||||
readonly __type: T;
|
||||
@@ -21,7 +18,7 @@ export interface CoValue {
|
||||
/** Specifies which kind of `CoValue` this is */
|
||||
type: string;
|
||||
/** The `CoValue`'s (precisely typed) static metadata */
|
||||
meta: JsonObject | null;
|
||||
headerMeta: JsonObject | null;
|
||||
/** The `Group` this `CoValue` belongs to (determining permissions) */
|
||||
group: Group;
|
||||
/** Returns an immutable JSON presentation of this `CoValue` */
|
||||
@@ -37,59 +34,35 @@ export interface CoValue {
|
||||
subscribe(listener: (coValue: this) => void): () => void;
|
||||
}
|
||||
|
||||
export type AnyCoMap = CoMap<
|
||||
{ [key: string]: JsonValue | CoValue | undefined },
|
||||
JsonObject | null
|
||||
>;
|
||||
|
||||
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
|
||||
| CoMap
|
||||
| Group
|
||||
| Account
|
||||
| Profile
|
||||
| CoList
|
||||
| CoStream
|
||||
| BinaryCoStream;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function isCoValue(value: JsonValue | CoValue | undefined) : value is CoValue {
|
||||
return (
|
||||
value instanceof CoMap ||
|
||||
value instanceof CoList ||
|
||||
value instanceof CoStream ||
|
||||
value instanceof BinaryCoStream
|
||||
);
|
||||
}
|
||||
return content as CoStream;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import { AnyCoValue, CoValue } from "./coValue.js";
|
||||
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import {
|
||||
Encrypted,
|
||||
Hash,
|
||||
@@ -26,13 +24,19 @@ import {
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { Group } 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 {
|
||||
AccountID,
|
||||
GeneralizedControlledAccount,
|
||||
} from "./coValues/account.js";
|
||||
import { Stringified, stableStringify } from "./jsonStringify.js";
|
||||
import { coreToCoValue } from "./coreToCoValue.js";
|
||||
import { expectGroup } from "./typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "./typeUtils/isAccountID.js";
|
||||
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
|
||||
export const MAX_RECOMMENDED_TX_SIZE = 100 * 1024;
|
||||
|
||||
@@ -49,12 +53,6 @@ export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
}
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountID | AgentID {
|
||||
return sessionID.split("_session")[0] as AccountID | AgentID;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
@@ -163,7 +161,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 +473,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 +505,27 @@ export class CoValueCore {
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): CoValue {
|
||||
if (this._cachedContent) {
|
||||
getCurrentContent(options?: { ignorePrivateTransactions: true }): CoValue {
|
||||
if (!options?.ignorePrivateTransactions && this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this._cachedContent = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this._cachedContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
if (this.header.meta && this.header.meta.type === "binary") {
|
||||
this._cachedContent = new BinaryCoStream(this);
|
||||
} else {
|
||||
this._cachedContent = new CoStream(this);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
const newContent = coreToCoValue(this, options);
|
||||
|
||||
if (!options?.ignorePrivateTransactions) {
|
||||
this._cachedContent = newContent;
|
||||
}
|
||||
|
||||
return this._cachedContent;
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
getValidSortedTransactions(options?: {
|
||||
ignorePrivateTransactions: true;
|
||||
}): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
.flatMap(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
@@ -526,6 +533,9 @@ export class CoValueCore {
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
if (options?.ignorePrivateTransactions) {
|
||||
return undefined;
|
||||
}
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
@@ -574,7 +584,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 +610,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 +662,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 +690,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 +716,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 +823,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,18 @@ 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;
|
||||
@@ -1,22 +1,24 @@
|
||||
import { JsonObject, JsonValue } from "../jsonValue.js";
|
||||
import { CoID, CoValue, isCoValue } from "../coValue.js";
|
||||
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
|
||||
import { CoID, CoValue } from "../coValue.js";
|
||||
import { isCoValue } from "../typeUtils/isCoValue.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.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 +27,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 +43,7 @@ type DeletionEntry = {
|
||||
} & DeletionOpPayload;
|
||||
|
||||
export class CoListView<
|
||||
Item extends JsonValue | CoValue,
|
||||
Item extends JsonValue = JsonValue,
|
||||
Meta extends JsonObject | null = null
|
||||
> implements CoValue
|
||||
{
|
||||
@@ -197,7 +199,7 @@ export class CoListView<
|
||||
}
|
||||
|
||||
/** @category 6. Meta */
|
||||
get meta(): Meta {
|
||||
get headerMeta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
@@ -220,11 +222,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 +235,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 +263,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 +297,7 @@ export class CoListView<
|
||||
*
|
||||
* @category 1. Reading
|
||||
*/
|
||||
toJSON(): (Item extends CoValue ? CoID<Item> : Exclude<Item, CoValue>)[] {
|
||||
toJSON(): Item[] {
|
||||
return this.asArray();
|
||||
}
|
||||
|
||||
@@ -309,7 +307,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 +375,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 +390,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 +438,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 +516,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 +531,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 +556,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 {
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
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 { CoID, CoValue } from "../coValue.js";
|
||||
import { isCoValue } from "../typeUtils/isCoValue.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { AccountID } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
import type { 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 +27,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 +49,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()) {
|
||||
@@ -84,7 +86,7 @@ export class CoMapView<
|
||||
}
|
||||
|
||||
/** @category 6. Meta */
|
||||
get meta(): Meta {
|
||||
get headerMeta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
@@ -121,13 +123,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 +138,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 +158,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 +172,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 +192,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 +222,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 +254,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 +272,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 +285,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 +359,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 +371,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 +389,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,13 @@
|
||||
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 { CoValue, CoID } from "../coValue.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import { isCoValue } from "../typeUtils/isCoValue.js";
|
||||
import { CoValueCore } from "../coValueCore.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.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 } from "./account.js";
|
||||
import { parseJSON } from "../jsonStringify.js";
|
||||
|
||||
export type BinaryStreamInfo = {
|
||||
@@ -33,15 +36,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>;
|
||||
@@ -59,7 +62,7 @@ export class CoStreamView<
|
||||
this.fillFromCoValue();
|
||||
}
|
||||
|
||||
get meta(): Meta {
|
||||
get headerMeta(): Meta {
|
||||
return this.core.header.meta as Meta;
|
||||
}
|
||||
|
||||
@@ -82,9 +85,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 +97,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 +114,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 +126,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 +148,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 +164,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 +174,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 +182,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 +225,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 +243,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 +270,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);
|
||||
@@ -296,8 +293,6 @@ export class BinaryCoStreamView<
|
||||
extends CoStreamView<BinaryStreamItem, Meta>
|
||||
implements CoValue
|
||||
{
|
||||
id!: CoID<this>;
|
||||
|
||||
getBinaryChunks(
|
||||
allowUnfinished?: boolean
|
||||
):
|
||||
|
||||
@@ -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 } 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,39 +16,29 @@ 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");
|
||||
}
|
||||
|
||||
return content as CoMap<GroupContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
*
|
||||
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
|
||||
@@ -68,30 +60,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 +76,7 @@ export class Group {
|
||||
|
||||
/** @internal */
|
||||
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
|
||||
return this.underlyingMap.get(accountID);
|
||||
return this.get(accountID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +85,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 +94,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 +183,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 +214,7 @@ export class Group {
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
mutable.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,17 +225,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 +262,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];
|
||||
},
|
||||
meta?: M["meta"],
|
||||
initPrivacy: "trusting" | "private" = "trusting"
|
||||
createMap<M extends CoMap>(
|
||||
init?: M["_shape"],
|
||||
meta?: M["headerMeta"],
|
||||
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 +294,17 @@ export class Group {
|
||||
*
|
||||
* @category 3. Value creation
|
||||
*/
|
||||
createList<L extends AnyCoList>(
|
||||
init?: (L["_item"] extends CoValue
|
||||
? CoID<L["_item"]> | L["_item"]
|
||||
: L["_item"])[],
|
||||
meta?: L["meta"],
|
||||
initPrivacy: "trusting" | "private" = "trusting"
|
||||
createList<L extends CoList>(
|
||||
init?: L["_item"][],
|
||||
meta?: L["headerMeta"],
|
||||
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 +321,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["headerMeta"]): C {
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.underlyingMap.id,
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
@@ -354,36 +336,21 @@ export class Group {
|
||||
}
|
||||
|
||||
/** @category 3. Value creation */
|
||||
createBinaryStream<C extends BinaryCoStream<BinaryCoStreamMeta>>(
|
||||
meta: C["meta"] = { type: "binary" }
|
||||
createBinaryStream<C extends BinaryCoStream>(
|
||||
meta: C["headerMeta"] = { 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}`;
|
||||
37
packages/cojson/src/coreToCoValue.ts
Normal file
37
packages/cojson/src/coreToCoValue.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CoValueCore } from "./coValueCore.js";
|
||||
import { Account } from "./coValues/account.js";
|
||||
import { Group } from "./coValues/group.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 function coreToCoValue(
|
||||
core: CoValueCore,
|
||||
options?: { ignorePrivateTransactions: true }
|
||||
) {
|
||||
if (core.header.type === "comap") {
|
||||
if (core.header.ruleset.type === "group") {
|
||||
if (
|
||||
core.header.meta?.type === "account" &&
|
||||
!options?.ignorePrivateTransactions
|
||||
) {
|
||||
return new Account(core);
|
||||
} else {
|
||||
return new Group(core, options);
|
||||
}
|
||||
} else {
|
||||
return new CoMap(core);
|
||||
}
|
||||
} else if (core.header.type === "colist") {
|
||||
return new CoList(core);
|
||||
} else if (core.header.type === "costream") {
|
||||
if (core.header.meta && core.header.meta.type === "binary") {
|
||||
return new BinaryCoStream(core);
|
||||
} else {
|
||||
return new CoStream(core);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${core.header.type}`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
newRandomSessionID,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
} from "./coValueCore.js";
|
||||
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { LocalNode } from "./localNode.js";
|
||||
import type { CoValue } from "./coValue.js";
|
||||
import { CoMap, MutableCoMap } from "./coValues/coMap.js";
|
||||
@@ -25,18 +26,22 @@ import {
|
||||
cryptoReady,
|
||||
} from "./crypto.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import {
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
} from "./coValues/account.js";
|
||||
import type { Role } from "./permissions.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { Group, EVERYONE } from "./coValues/group.js";
|
||||
import type { Everyone } from "./coValues/group.js";
|
||||
import { base64URLtoBytes, bytesToBase64url } from "./base64url.js";
|
||||
import { parseJSON } from "./jsonStringify.js";
|
||||
import { Account, Profile } from "./coValues/account.js";
|
||||
import { expectGroup } from "./typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "./typeUtils/isAccountID.js";
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, AnyCoValue } from "./coValue.js";
|
||||
import type { Queried } 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 type {
|
||||
BinaryStreamInfo,
|
||||
BinaryCoStreamMeta,
|
||||
@@ -44,8 +49,13 @@ 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 +74,20 @@ export const cojsonInternals = {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectGroupContent,
|
||||
expectGroup,
|
||||
base64URLtoBytes,
|
||||
bytesToBase64url,
|
||||
parseJSON,
|
||||
accountOrAgentIDfromSessionID,
|
||||
isAccountID,
|
||||
};
|
||||
|
||||
export {
|
||||
LocalNode,
|
||||
Group,
|
||||
Role,
|
||||
EVERYONE,
|
||||
Everyone,
|
||||
CoMap,
|
||||
MutableCoMap,
|
||||
CoList,
|
||||
@@ -84,13 +99,12 @@ export {
|
||||
CoValue,
|
||||
CoID,
|
||||
AnyCoValue,
|
||||
Queried,
|
||||
QueriedCoMap,
|
||||
QueriedCoList,
|
||||
QueriedCoStream,
|
||||
AccountID,
|
||||
Account,
|
||||
AccountID,
|
||||
AccountMeta,
|
||||
AccountMigration,
|
||||
Profile,
|
||||
ProfileMeta,
|
||||
SessionID,
|
||||
Media,
|
||||
CoValueCore,
|
||||
@@ -98,7 +112,6 @@ export {
|
||||
ControlledAccount,
|
||||
cryptoReady as cojsonReady,
|
||||
MAX_RECOMMENDED_TX_SIZE,
|
||||
Value,
|
||||
JsonValue,
|
||||
Peer,
|
||||
BinaryStreamInfo,
|
||||
@@ -109,6 +122,10 @@ export {
|
||||
SyncMessage,
|
||||
};
|
||||
|
||||
export type {
|
||||
Value,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace CojsonInternalTypes {
|
||||
export type CoValueKnownState = import("./sync.js").CoValueKnownState;
|
||||
@@ -118,11 +135,11 @@ export namespace CojsonInternalTypes {
|
||||
export type NewContentMessage = import("./sync.js").NewContentMessage;
|
||||
export type CoValueHeader = import("./coValueCore.js").CoValueHeader;
|
||||
export type Transaction = import("./coValueCore.js").Transaction;
|
||||
export type TransactionID = import("./ids.js").TransactionID;
|
||||
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,13 @@ import {
|
||||
import {
|
||||
InviteSecret,
|
||||
Group,
|
||||
GroupContent,
|
||||
expectGroupContent,
|
||||
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 +31,11 @@ import {
|
||||
AnonymousControlledAccount,
|
||||
AccountID,
|
||||
Profile,
|
||||
AccountContent,
|
||||
} from "./account.js";
|
||||
AccountMigration,
|
||||
} from "./coValues/account.js";
|
||||
import { CoMap } from "./coValues/coMap.js";
|
||||
import { CoValue } from "./index.js";
|
||||
import { expectGroup } from "./typeUtils/expectGroup.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 +68,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 +94,53 @@ 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 +153,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;
|
||||
}
|
||||
|
||||
@@ -196,15 +253,7 @@ export class LocalNode {
|
||||
};
|
||||
}
|
||||
|
||||
/** @category 1. High-level */
|
||||
query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
callback: (update: Queried<T> | undefined) => void
|
||||
): () => void {
|
||||
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 +262,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 +277,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 +293,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 +307,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 +325,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 +354,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 +376,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 +428,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 +444,7 @@ export class LocalNode {
|
||||
};
|
||||
profileOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
return new ControlledAccount(accountOnThisNode, agentSecret);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -418,15 +472,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 +486,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 +510,7 @@ export class LocalNode {
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
return new Group(groupContent, this);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -505,6 +555,18 @@ 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,13 @@ import { KeyID } from "./crypto.js";
|
||||
import {
|
||||
CoValueCore,
|
||||
Transaction,
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValueCore.js";
|
||||
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.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 } from "./coValues/group.js";
|
||||
import { expectGroup } from "./typeUtils/expectGroup.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "group"; initialAdmin: AccountID | AgentID }
|
||||
@@ -31,26 +31,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 +55,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 +102,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 +167,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 +229,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 +247,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 +282,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 +298,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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
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 { TransactionID } from "../ids.js";
|
||||
import { QueriedAccountAndProfile } from "./queriedCoMap.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
|
||||
export class QueriedCoList<L extends AnyCoList> extends Array<
|
||||
ValueOrSubQueried<L["_item"]>
|
||||
> {
|
||||
coList!: L;
|
||||
id!: CoID<L>;
|
||||
type!: "colist";
|
||||
|
||||
/** @internal */
|
||||
constructor(coList: L, queryContext: QueryContext) {
|
||||
if (!(coList instanceof CoList)) {
|
||||
// this might be called from an intrinsic, like map, trying to create an empty array
|
||||
// passing `0` as the only parameter
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Array(coList) as any;
|
||||
}
|
||||
super(
|
||||
...coList
|
||||
.asArray()
|
||||
.map(
|
||||
(item) =>
|
||||
queryContext.resolveValue(item) as ValueOrSubQueried<
|
||||
L["_item"]
|
||||
>
|
||||
)
|
||||
);
|
||||
|
||||
Object.defineProperties(this, {
|
||||
coList: { value: 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,
|
||||
tx: edit.tx,
|
||||
at: new Date(edit.at),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: queryContext.resolveValue(edit.value) as any,
|
||||
};
|
||||
}),
|
||||
},
|
||||
deletions: {
|
||||
value: coList.deletionEdits().map((deletion) => ({
|
||||
by:
|
||||
deletion.by && isAccountID(deletion.by)
|
||||
? queryContext.resolveAccount(deletion.by)
|
||||
: undefined,
|
||||
tx: deletion.tx,
|
||||
at: new Date(deletion.at),
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get meta(): L["meta"] {
|
||||
return this.coList.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coList.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coList.core;
|
||||
}
|
||||
|
||||
append(
|
||||
item: L["_item"] extends CoValue
|
||||
? L["_item"] | CoID<L["_item"]>
|
||||
: L["_item"],
|
||||
after?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
return this.coList.append(item, after, privacy);
|
||||
}
|
||||
|
||||
prepend(
|
||||
item: L["_item"] extends CoValue
|
||||
? L["_item"] | CoID<L["_item"]>
|
||||
: L["_item"],
|
||||
before?: number,
|
||||
privacy?: "private" | "trusting"
|
||||
): L {
|
||||
return this.coList.prepend(item, before, privacy);
|
||||
}
|
||||
|
||||
delete(at: number, privacy: "private" | "trusting"): L {
|
||||
return this.coList.delete(at, privacy);
|
||||
}
|
||||
|
||||
mutate(
|
||||
mutator: (mutable: MutableCoList<L["_item"], L["meta"]>) => void
|
||||
): L {
|
||||
return this.coList.mutate(mutator);
|
||||
}
|
||||
|
||||
edits!: {
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: L["_item"] extends CoValue
|
||||
? CoID<L["_item"]>
|
||||
: Exclude<L["_item"], CoValue>;
|
||||
}[];
|
||||
|
||||
deletions!: {
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
|
||||
/** @internal */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static isArray(arg: any): arg is any[] {
|
||||
return Array.isArray(arg);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static from<T>(arrayLike: ArrayLike<T>): T[];
|
||||
/** @internal */
|
||||
static from<T, U>(
|
||||
arrayLike: ArrayLike<T>,
|
||||
mapfn: (v: T, k: number) => U,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thisArg?: any
|
||||
): U[];
|
||||
/** @internal */
|
||||
static from<T>(iterable: Iterable<T> | ArrayLike<T>): T[];
|
||||
/** @internal */
|
||||
static from<T, U>(
|
||||
iterable: Iterable<T> | ArrayLike<T>,
|
||||
mapfn: (v: T, k: number) => U,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
thisArg?: any
|
||||
): U[];
|
||||
/** @internal */
|
||||
static from<T, U>(
|
||||
_iterable: unknown,
|
||||
_mapfn?: unknown,
|
||||
_thisArg?: unknown
|
||||
): T[] | U[] | T[] | U[] {
|
||||
throw new Error("Array method 'from' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
static of<T>(..._items: T[]): T[] {
|
||||
throw new Error("Array method 'of' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
pop(): ValueOrSubQueried<L["_item"]> | undefined {
|
||||
throw new Error("Array method 'pop' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
push(..._items: ValueOrSubQueried<L["_item"]>[]): number {
|
||||
throw new Error("Array method 'push' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
concat(
|
||||
..._items: ConcatArray<ValueOrSubQueried<L["_item"]>>[]
|
||||
): ValueOrSubQueried<L["_item"]>[];
|
||||
/** @internal */
|
||||
concat(
|
||||
..._items: (
|
||||
| ValueOrSubQueried<L["_item"]>
|
||||
| ConcatArray<ValueOrSubQueried<L["_item"]>>
|
||||
)[]
|
||||
): ValueOrSubQueried<L["_item"]>[];
|
||||
/** @internal */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
concat(..._items: any[]): ValueOrSubQueried<L["_item"]>[] {
|
||||
throw new Error("Array method 'concat' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
reverse(): ValueOrSubQueried<L["_item"]>[] {
|
||||
throw new Error(
|
||||
"Array method 'reverse' not supported on QueriedCoList"
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
shift(): ValueOrSubQueried<L["_item"]> | undefined {
|
||||
throw new Error("Array method 'shift' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
sort(
|
||||
_compareFn?:
|
||||
| ((
|
||||
a: ValueOrSubQueried<L["_item"]>,
|
||||
b: ValueOrSubQueried<L["_item"]>
|
||||
) => number)
|
||||
| undefined
|
||||
): this {
|
||||
throw new Error("Array method 'sort' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
splice(
|
||||
_start: number,
|
||||
_deleteCount?: number | undefined
|
||||
): ValueOrSubQueried<L["_item"]>[] {
|
||||
throw new Error("Array method 'splice' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
unshift(..._items: ValueOrSubQueried<L["_item"]>[]): number {
|
||||
throw new Error(
|
||||
"Array method 'unshift' not supported on QueriedCoList"
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
fill(
|
||||
_value: ValueOrSubQueried<L["_item"]>,
|
||||
_start?: number | undefined,
|
||||
_end?: number | undefined
|
||||
): this {
|
||||
throw new Error("Array method 'fill' not supported on QueriedCoList");
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
copyWithin(
|
||||
_target: number,
|
||||
_start: number,
|
||||
_end?: number | undefined
|
||||
): this {
|
||||
throw new Error(
|
||||
"Array method 'copyWithin' not supported on QueriedCoList"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { 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 { TransactionID } from "../ids.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
|
||||
export type QueriedCoMap<M extends AnyCoMap> = {
|
||||
[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;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
value: M["_shape"][K] extends CoValue
|
||||
? CoID<M["_shape"][K]>
|
||||
: Exclude<M["_shape"][K], CoValue>;
|
||||
};
|
||||
|
||||
export class QueriedCoMapBase<M extends AnyCoMap> {
|
||||
coMap!: M;
|
||||
id!: CoID<M>;
|
||||
type!: "comap";
|
||||
|
||||
/** @internal */
|
||||
static newWithKVPairs<M extends AnyCoMap>(
|
||||
coMap: M,
|
||||
queryContext: QueryContext
|
||||
): QueriedCoMap<M> {
|
||||
const kv = {} as {
|
||||
[K in keyof M["_shape"] & string]: ValueOrSubQueried<
|
||||
M["_shape"][K]
|
||||
>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
kv[key as keyof typeof kv] = queryContext.resolveValue(
|
||||
value
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign(new QueriedCoMapBase(coMap, queryContext), kv);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
constructor(coMap: M, queryContext: QueryContext) {
|
||||
Object.defineProperties(this, {
|
||||
coMap: { value: 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 lastEdit = edits[edits.length - 1];
|
||||
if (!lastEdit) return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const editsAtKey = {
|
||||
by: lastEdit.by,
|
||||
tx: lastEdit.tx,
|
||||
at: lastEdit.at,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: lastEdit.value as any,
|
||||
all: edits,
|
||||
};
|
||||
|
||||
return [[key, editsAtKey]];
|
||||
})
|
||||
),
|
||||
enumerable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
edits!: {
|
||||
[K in keyof M["_shape"] & string]:
|
||||
| (QueriedCoMapEdit<M, K> & {
|
||||
all: QueriedCoMapEdit<M, K>[];
|
||||
})
|
||||
| undefined;
|
||||
};
|
||||
|
||||
get meta(): M["meta"] {
|
||||
return this.coMap.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coMap.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coMap.core;
|
||||
}
|
||||
|
||||
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],
|
||||
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];
|
||||
},
|
||||
privacy?: "private" | "trusting"
|
||||
): M;
|
||||
set<K extends keyof M["_shape"] & string>(
|
||||
...args:
|
||||
| [
|
||||
{
|
||||
[K in keyof M["_shape"] &
|
||||
string]?: M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K];
|
||||
},
|
||||
("private" | "trusting")?
|
||||
]
|
||||
| [
|
||||
K,
|
||||
M["_shape"][K] extends CoValue
|
||||
? M["_shape"][K] | CoID<M["_shape"][K]>
|
||||
: M["_shape"][K],
|
||||
("private" | "trusting")?
|
||||
]
|
||||
): M {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return (this.coMap.set as Function)(...args);
|
||||
}
|
||||
delete(
|
||||
key: keyof M["_shape"] & string,
|
||||
privacy?: "private" | "trusting"
|
||||
): M {
|
||||
return this.coMap.delete(key, privacy);
|
||||
}
|
||||
mutate(
|
||||
mutator: (mutable: MutableCoMap<M["_shape"], M["meta"]>) => void
|
||||
): M {
|
||||
return this.coMap.mutate(mutator);
|
||||
}
|
||||
}
|
||||
|
||||
export type QueriedAccountAndProfile = {
|
||||
profile?: { name?: string; id: CoID<Profile> };
|
||||
isMe?: boolean;
|
||||
id: AccountID;
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { 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 { SessionID, TransactionID } from "../ids.js";
|
||||
import { QueriedAccountAndProfile } from "./queriedCoMap.js";
|
||||
import { ValueOrSubQueried, QueryContext } from "../queries.js";
|
||||
|
||||
|
||||
export type QueriedCoStreamItems<Item extends JsonValue | CoValue> = {
|
||||
last?: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx?: TransactionID;
|
||||
at?: Date;
|
||||
all: {
|
||||
value: ValueOrSubQueried<Item>;
|
||||
by?: QueriedAccountAndProfile;
|
||||
tx: TransactionID;
|
||||
at: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class QueriedCoStream<S extends AnyCoStream> {
|
||||
coStream: S;
|
||||
id: CoID<S>;
|
||||
type = "costream" as const;
|
||||
|
||||
/** @internal */
|
||||
constructor(coStream: S, queryContext: QueryContext) {
|
||||
this.coStream = 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,
|
||||
{
|
||||
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,
|
||||
{
|
||||
last: lastItem?.value,
|
||||
by: lastItem?.by,
|
||||
tx: lastItem?.tx,
|
||||
at: lastItem?.at,
|
||||
all: items,
|
||||
} satisfies QueriedCoStreamItems<S["_item"]>,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
this.me = isAccountID(queryContext.node.account.id)
|
||||
? this.perAccount[queryContext.node.account.id]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
get meta(): S["meta"] {
|
||||
return this.coStream.meta;
|
||||
}
|
||||
|
||||
get group(): Group {
|
||||
return this.coStream.group;
|
||||
}
|
||||
|
||||
get core(): CoValueCore {
|
||||
return this.coStream.core;
|
||||
}
|
||||
|
||||
me?: QueriedCoStreamItems<S["_item"]>;
|
||||
perAccount: {
|
||||
[account: AccountID]: QueriedCoStreamItems<S["_item"]>;
|
||||
};
|
||||
perSession: {
|
||||
[session: SessionID]: QueriedCoStreamItems<S["_item"]>;
|
||||
};
|
||||
|
||||
push(
|
||||
item: S["_item"] extends CoValue ? S["_item"] | CoID<S["_item"]> : S["_item"],
|
||||
privacy?: "private" | "trusting"
|
||||
): S {
|
||||
return this.coStream.push(item, privacy);
|
||||
}
|
||||
mutate(
|
||||
mutator: (mutable: MutableCoStream<S["_item"], S["meta"]>) => void
|
||||
): S {
|
||||
return this.coStream.mutate(mutator);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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 { LocalNode } from "./localNode.js";
|
||||
import {
|
||||
QueriedAccountAndProfile,
|
||||
QueriedCoMap,
|
||||
QueriedCoMapBase,
|
||||
} from "./queriedCoValues/queriedCoMap.js";
|
||||
import { QueriedCoList } from "./queriedCoValues/queriedCoList.js";
|
||||
import { QueriedCoStream } from "./queriedCoValues/queriedCoStream.js";
|
||||
|
||||
export type Queried<T extends CoValue> = T extends AnyCoMap
|
||||
? QueriedCoMap<T>
|
||||
: T extends AnyCoList
|
||||
? QueriedCoList<T>
|
||||
: T extends AnyCoStream
|
||||
? T["meta"] extends { type: "binary" }
|
||||
? never
|
||||
: QueriedCoStream<T>
|
||||
: never;
|
||||
|
||||
export type ValueOrSubQueried<
|
||||
V extends JsonValue | CoValue | CoID<CoValue> | undefined
|
||||
> = V extends CoID<infer C>
|
||||
? Queried<C> | undefined
|
||||
: V extends CoValue
|
||||
? Queried<V> | undefined
|
||||
: V;
|
||||
|
||||
export interface CleanupCallbackAndUsable {
|
||||
(): void;
|
||||
[Symbol.dispose]: () => void;
|
||||
}
|
||||
|
||||
export class QueryContext {
|
||||
values: {
|
||||
[id: CoID<CoValue>]: {
|
||||
lastQueried: Queried<CoValue> | undefined;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
} = {};
|
||||
node: LocalNode;
|
||||
onUpdate: () => void;
|
||||
|
||||
constructor(node: LocalNode, onUpdate: () => void) {
|
||||
this.node = node;
|
||||
this.onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
getChildLastQueriedOrSubscribe<T extends CoValue>(valueID: CoID<T>) {
|
||||
let value = this.values[valueID];
|
||||
if (!value) {
|
||||
value = {
|
||||
lastQueried: undefined,
|
||||
unsubscribe: query(valueID, this.node, (childQueried) => {
|
||||
value!.lastQueried = childQueried as Queried<CoValue>;
|
||||
this.onUpdate();
|
||||
}),
|
||||
};
|
||||
this.values[valueID] = value;
|
||||
}
|
||||
return value.lastQueried as Queried<T> | undefined;
|
||||
}
|
||||
|
||||
resolveAccount(accountID: AccountID) {
|
||||
return this.getChildLastQueriedOrSubscribe(
|
||||
accountID
|
||||
) as QueriedAccountAndProfile;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const child of Object.values(this.values)) {
|
||||
child.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function query<T extends CoValue>(
|
||||
id: CoID<T>,
|
||||
node: LocalNode,
|
||||
callback: (queried: Queried<T> | undefined) => void,
|
||||
parentContext?: QueryContext
|
||||
): CleanupCallbackAndUsable {
|
||||
console.log("querying", id);
|
||||
|
||||
const context = parentContext || new QueryContext(node, onUpdate);
|
||||
|
||||
const unsubscribe = node.subscribe(id, (update) => {
|
||||
lastRootValue = update;
|
||||
onUpdate();
|
||||
});
|
||||
|
||||
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>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = function cleanup() {
|
||||
context.cleanup();
|
||||
unsubscribe();
|
||||
} as CleanupCallbackAndUsable;
|
||||
cleanup[Symbol.dispose] = cleanup;
|
||||
|
||||
return 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);
|
||||
|
||||
|
||||
@@ -315,14 +315,14 @@ test("Empty BinaryCoStream works", () => {
|
||||
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
content.headerMeta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("costream");
|
||||
expect(content.meta.type).toEqual("binary");
|
||||
expect(content.headerMeta.type).toEqual("binary");
|
||||
expect(content.toJSON()).toEqual({});
|
||||
expect(content.getBinaryChunks()).toEqual(undefined);
|
||||
});
|
||||
@@ -341,7 +341,7 @@ test("Can push into BinaryCoStream", () => {
|
||||
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
content.headerMeta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
@@ -398,7 +398,7 @@ test("When adding large transactions (small fraction of MAX_RECOMMENDED_TX_SIZE)
|
||||
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
content.headerMeta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
@@ -474,7 +474,7 @@ test("When adding large transactions (bigger than MAX_RECOMMENDED_TX_SIZE), we s
|
||||
|
||||
if (
|
||||
content.type !== "costream" ||
|
||||
content.meta?.type !== "binary" ||
|
||||
content.headerMeta?.type !== "binary" ||
|
||||
!(content instanceof BinaryCoStream)
|
||||
) {
|
||||
throw new Error("Expected binary stream");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -46,6 +46,6 @@ test("Can create a BinaryCoStream in a group", () => {
|
||||
const stream = group.createBinaryStream();
|
||||
|
||||
expect(stream.core.getCurrentContent().type).toEqual("costream");
|
||||
expect(stream.meta.type).toEqual("binary");
|
||||
expect(stream.headerMeta.type).toEqual("binary");
|
||||
expect(stream instanceof BinaryCoStream).toEqual(true);
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user