Compare commits

...

6 Commits

Author SHA1 Message Date
Guido D'Orsi
84b3d0730f feat: integrate the Inbox improvements 2024-12-17 15:44:12 +01:00
Guido D'Orsi
04f8eb30b8 feat: integrate the inbox with briscola 2024-12-17 15:17:07 +01:00
Guido D'Orsi
51f4910da0 feat: improve the UX and add React hooks 2024-12-17 15:15:01 +01:00
Guido D'Orsi
d141d30c91 fix: make it work 2024-12-17 13:05:05 +01:00
Guido D'Orsi
e182a12e1e feat: incorporate feedback 2024-12-16 17:14:36 +01:00
Guido D'Orsi
364de1505a feat: add a new API to manage communication with workers 2024-12-13 13:20:24 +01:00
45 changed files with 4735 additions and 555 deletions

21
examples/briscola/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Local
.DS_Store
*.local
*.log*
# Dist
node_modules
dist/
.vinxi
.output
.vercel
.netlify
.wrangler
# IDE
.vscode/*
!.vscode/extensions.json
.idea
.env

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" class="bg-green-800">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TanStack Router</title>
</head>
<body class="">
<div id="app" class="h-screen overflow-hidden"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
{
"name": "jazz-example-briscola",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"dev": "vite --port=3001",
"dev:worker": "vite-node --config vite.server.ts ./src/worker.ts",
"build": "vite build",
"serve": "vite preview",
"start": "vite"
},
"devDependencies": {
"@tanstack/router-plugin": "^1.87.13",
"@types/node": "^22.10.2",
"@types/react": "^18.3.16",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"vite": "^6.0.3",
"vite-node": "^2.1.8"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-router": "^1.87.12",
"@tanstack/router-devtools": "^1.87.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jazz-nodejs": "workspace:*",
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.468.0",
"motion": "^11.14.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
}
}

View File

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

View File

@@ -0,0 +1,2 @@
JAZZ_WORKER_ACCOUNT=
JAZZ_WORKER_SECRET=

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/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<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ 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-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground 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 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,24 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,29 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -0,0 +1,8 @@
import { Account } from "jazz-tools";
import { ID } from "jazz-tools";
export const workerCredentials = {
accountID: "co_zftnYbkfXZKmSVQHLBjFojVSkah" as ID<Account>,
agentSecret:
"sealerSecret_zBqjaGpQWUffMSSuJTzj3qBuBgnZyB7HpL2wsnqUkapSL/signerSecret_zE6wD4rJFe4LLAbn6MPDV9ie7wSGfixnzugc8yRBXXyc1",
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,66 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

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

View File

@@ -0,0 +1,46 @@
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { DemoAuthBasicUI, createJazzReactApp, useDemoAuth } from "jazz-react";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import "./index.css";
const Jazz = createJazzReactApp();
export const { useAccount, useCoState, useAcceptInvite, useAccountInbox } =
Jazz;
function JazzAndAuth({ children }: { children: React.ReactNode }) {
const [auth, authState] = useDemoAuth();
return (
<>
<Jazz.Provider
auth={auth}
// replace `you@example.com` with your email as a temporary API key
peer="ws://localhost:4200"
>
{children}
</Jazz.Provider>
<DemoAuthBasicUI appName="Planning Poker" state={authState} />
</>
);
}
// Set up a Router instance
const router = createRouter({
routeTree,
defaultPreload: "intent",
Wrap: JazzAndAuth,
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app")!;
if (!rootElement.innerHTML) {
const root = createRoot(rootElement);
root.render(<RouterProvider router={router} />);
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from "./routes/__root";
import { Route as GameGameIdImport } from "./routes/game/$gameId";
import { Route as IndexImport } from "./routes/index";
// Create/Update Routes
const IndexRoute = IndexImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRoute,
} as any);
const GameGameIdRoute = GameGameIdImport.update({
id: "/game/$gameId",
path: "/game/$gameId",
getParentRoute: () => rootRoute,
} as any);
// Populate the FileRoutesByPath interface
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexImport;
parentRoute: typeof rootRoute;
};
"/game/$gameId": {
id: "/game/$gameId";
path: "/game/$gameId";
fullPath: "/game/$gameId";
preLoaderRoute: typeof GameGameIdImport;
parentRoute: typeof rootRoute;
};
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/game/$gameId": typeof GameGameIdRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/game/$gameId": typeof GameGameIdRoute;
}
export interface FileRoutesById {
__root__: typeof rootRoute;
"/": typeof IndexRoute;
"/game/$gameId": typeof GameGameIdRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/game/$gameId";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/game/$gameId";
id: "__root__" | "/" | "/game/$gameId";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
GameGameIdRoute: typeof GameGameIdRoute;
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
GameGameIdRoute: GameGameIdRoute,
};
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/game/$gameId"
]
},
"/": {
"filePath": "index.tsx"
},
"/game/$gameId": {
"filePath": "game/$gameId.tsx"
}
}
}
ROUTE_MANIFEST_END */

View File

@@ -0,0 +1,15 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
export const Route = createRootRoute({
component: RootComponent,
});
function RootComponent() {
return (
<>
<Outlet />
<TanStackRouterDevtools position="bottom-right" />
</>
);
}

View File

@@ -0,0 +1,191 @@
import { cn } from "@/lib/utils";
import { useAccount, useCoState } from "@/main";
import { type Card, CardList, type CardValue, Game, Player } from "@/schema";
import { createFileRoute } from "@tanstack/react-router";
import type { ID, co } from "jazz-tools";
import { AnimatePresence, Reorder, motion } from "motion/react";
import { type ReactNode, useState } from "react";
import bastoni from "../../bastoni.svg?url";
import coppe from "../../coppe.svg?url";
import denari from "../../denari.svg?url";
import spade from "../../spade.svg?url";
export const Route = createFileRoute("/game/$gameId")({
component: RouteComponent,
});
function RouteComponent() {
const { gameId } = Route.useParams();
const game = useCoState(Game, gameId as ID<Game>, {
deck: [{}],
player1: {
account: {},
},
player2: {
account: {},
},
activePlayer: {},
});
const { me } = useAccount();
// TODO: loading
const currentPlayerId =
game?.player1.account.id === me.id ? game.player1.id : game?.player2.id;
const currentPlayer = useCoState(Player, currentPlayerId, {
hand: [{}],
scoredCards: [{}],
giocata: {},
});
if (!game || !currentPlayer) return null;
return (
<div className="flex flex-col h-full p-2 bg-green-800">
<PlayerArea player={currentPlayer}>
<Reorder.Group
axis="x"
values={currentPlayer.hand}
onReorder={(cards) => {
currentPlayer.hand = CardList.create(cards, {
owner: currentPlayer.hand._owner,
});
}}
>
<div className="flex place-content-center gap-2">
{currentPlayer.hand.map((card) => (
<Reorder.Item key={card.value} value={card}>
<PlayingCard card={card} />
</Reorder.Item>
))}
</div>
</Reorder.Group>
</PlayerArea>
<div className="grow items-center justify-center flex ">
<>
{game.deck[0] && (
<PlayingCard
className="rotate-[88deg] left-1/2 absolute"
card={game.deck[0]}
/>
)}
<CardStack cards={game.deck} className="" />
</>
</div>
<PlayerArea player={game.player1}>
<Reorder.Group
axis="x"
values={game.player1.hand}
onReorder={(cards) => {
// TODO: this is weird AF
// @ts-expect-error
game.player1.hand = CardList.create(cards, {
owner: game.player1.hand._owner,
});
}}
>
<div className="flex place-content-center gap-2">
{game.player1.hand.map((card) => (
<Reorder.Item key={card.value} value={card}>
<PlayingCard card={card} />
</Reorder.Item>
))}
</div>
</Reorder.Group>
</PlayerArea>
</div>
);
}
interface CardStackProps {
cards: CardList;
className?: string;
}
function CardStack({ cards, className }: CardStackProps) {
return (
<div className={cn("relative p-4 w-[200px] h-[280px]", className)}>
<AnimatePresence>
{cards.map((card, i) => (
<motion.div
initial={{ left: -1000 }}
animate={{ left: 0 }}
transition={{ delay: i * 0.05 }}
key={i}
className="w-[150px] aspect-card absolute border border-gray-200/10 rounded-lg bg-white drop-shadow-sm"
style={{
rotate: `${(i % 3) * (i % 5) * 3}deg`,
backgroundImage: `url(https://placecats.com/150/243)`,
backgroundSize: "cover",
}}
>
{card?.value}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
interface Props {
card: co<Card>;
className?: string;
}
function PlayingCard({ card, className }: Props) {
const cardImage = getCardImage(card.value);
const value = getValue(card.value);
return (
<motion.div
className={cn(
"border aspect-card w-[150px] bg-white touch-none rounded-lg shadow-lg p-2",
className,
)}
>
<div className="border-zinc-400 border rounded-lg h-full px-1 flex flex-col ">
<div className="text-4xl font-bold text-black self-start">{value}</div>
<div className="grow flex justify-center items-center">
<img src={cardImage} className="pointer-events-none max-h-[140px]" />
</div>
<div className="text-4xl font-bold text-black rotate-180 transform self-end">
{value}
</div>
</div>
</motion.div>
);
}
function getCardImage(cardValue: typeof CardValue) {
switch (cardValue.charAt(0)) {
case "C":
return coppe;
case "D":
return denari;
case "S":
return spade;
case "B":
return bastoni;
}
}
function getValue(card: typeof CardValue) {
return card.charAt(1);
}
interface PlayerAreaProps {
player: Player;
children: ReactNode;
}
function PlayerArea({ children, player }: PlayerAreaProps) {
return (
<div className="grid grid-cols-3">
<div></div>
{children}
<div className="flex justify-center">
<CardStack cards={player.scoredCards} className="rotate-90" />
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { workerCredentials } from "@/credentials";
import {
useAcceptInvite,
useAccount,
useAccountInbox,
useCoState,
} from "@/main";
import { WaitingRoom } from "@/schema";
import { StartGameMessage } from "@/types";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { createInviteLink } from "jazz-react";
import { Group, ID } from "jazz-tools";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/")({
component: HomeComponent,
});
function HomeComponent() {
const { me } = useAccount();
const inbox = useAccountInbox<StartGameMessage>(workerCredentials.accountID);
const navigate = useNavigate({ from: "/" });
const [waitingRoomId, setWaitingRoomId] = useState<
ID<WaitingRoom> | undefined
>(undefined);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const waitingRoom = useCoState(WaitingRoom, waitingRoomId, {
player1Account: {},
player2Account: {},
});
useAcceptInvite({
invitedObjectSchema: WaitingRoom,
onAccept: (waitingRoom) => {
setWaitingRoomId(waitingRoom);
},
});
const onNewGameClick = () => {
const group = Group.create({ owner: me });
const waitingRoom = WaitingRoom.create(
{
player1Account: me,
player2Account: null,
},
{ owner: group },
);
setWaitingRoomId(waitingRoom.id);
setInviteLink(createInviteLink(waitingRoom, "writer"));
const unsubscribe = waitingRoom.subscribe({}, (waitingRoom) => {
if (waitingRoom._refs.player2Account) {
console.log("sendMessage", waitingRoom.id);
inbox.sendMessage({ type: "startGame", value: waitingRoom.id });
unsubscribe();
}
});
};
const isPlayer1 = waitingRoom?.player1Account?.id === me.id;
const hasPlayer2 = !!waitingRoom?.player2Account;
useEffect(() => {
if (waitingRoom?.id && !isPlayer1 && !hasPlayer2) {
waitingRoom.player2Account = me;
}
}, [waitingRoom?.id, isPlayer1, hasPlayer2]);
useEffect(() => {
if (waitingRoom?.game) {
navigate({ to: `/game/${waitingRoom.game.id}` });
}
}, [waitingRoom]);
if (waitingRoom) {
if (waitingRoom.player2Account) {
return (
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
<Card className="w-[500px]">
<CardHeader>
<CardTitle>Waiting for game to start</CardTitle>
</CardHeader>
</Card>
</div>
);
}
return (
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
<Card className="w-[500px]">
<CardHeader>
<CardTitle>Waiting for player 2</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="flex items-center space-x-4">
<div className="w-1/2 flex flex-col p-4">
Invite link: <p>{inviteLink}</p>
</div>
</div>
<Separator />
<div className="p-4">
<Button variant="link">How to play?</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
<Card className="w-[500px]">
<CardHeader>
<CardTitle>Welcome to Jazz Briscola</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="flex items-center space-x-4">
<div className="w-1/2 flex flex-col p-4">
<Button onClick={onNewGameClick}>New Game</Button>
</div>
<Separator orientation="vertical" className="h-40" />
<div className="w-1/2 flex flex-col space-y-4 p-4">
<div className="flex flex-col space-y-2">
<Label htmlFor="picture">Game ID</Label>
<Input id="picture" placeholder="co_XXXXXXXXXXX" />
</div>
<Button>Join</Button>
</div>
</div>
<Separator />
<div className="p-4">
<Button variant="link">How to play?</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import {
Account,
CoFeed,
CoList,
CoMap,
ID,
SchemaUnion,
co,
} from "jazz-tools";
export const CardValues = [
"S1",
"S2",
"S3",
"S4",
"S5",
"S6",
"S7",
"S8",
"S9",
"S10",
"C1",
"C2",
"C3",
"C4",
"C5",
"C6",
"C7",
"C8",
"C9",
"C10",
"D1",
"D2",
"D3",
"D4",
"D5",
"D6",
"D7",
"D8",
"D9",
"D10",
"B1",
"B2",
"B3",
"B4",
"B5",
"B6",
"B7",
"B8",
"B9",
"B10",
] as const;
export const CardValue = co.literal(...CardValues);
export class Card extends CoMap {
value = CardValue;
}
export class CardContainer extends CoMap {
value = co.optional.literal(...CardValues);
}
export class CardList extends CoList.Of(co.ref(Card)) {}
export class Player extends CoMap {
account = co.ref(Account);
giocata = co.ref(CardContainer); // write Tavolo - write me - quando un giocatore gioca una carta la scrive qui, il Game la legge, la valida e la mette sul tavolo
hand = co.ref(CardList); // write Tavolo - read me - quando il Game mi da le carte le scrive qui, quando valida la giocata la toglie da qui
scoredCards = co.ref(CardList); // write Tavolo - read everyone -
}
export class Game extends CoMap {
deck = co.ref(CardList);
// briscola? = co.literal("A", "B", "C", "D");
//
// tavolo? = co.ref(Card);
activePlayer = co.ref(Player);
player1 = co.ref(Player);
player2 = co.ref(Player);
}
export class WaitingRoom extends CoMap {
player1Account = co.ref(Account);
player2Account = co.optional.ref(Account);
game = co.optional.ref(Game);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,7 @@
import { ID } from "jazz-tools";
import { WaitingRoom } from "./schema";
export type StartGameMessage = {
type: "startGame";
value: ID<WaitingRoom>;
};

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

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

View File

@@ -0,0 +1,145 @@
import {
Card,
CardContainer,
CardList,
CardValues,
Game,
Player,
WaitingRoom,
} from "@/schema";
import { startWorker } from "jazz-nodejs";
import { Account, Group, ID } from "jazz-tools";
import { workerCredentials } from "./credentials";
import { StartGameMessage } from "./types";
const { worker } = await startWorker({
accountID: workerCredentials.accountID,
accountSecret: workerCredentials.agentSecret,
onInboxMessage,
syncServer: "ws://localhost:4200",
});
console.log("Listening for new games on inbox", workerCredentials.accountID);
async function onInboxMessage({ value: id }: StartGameMessage) {
const waitingRoom = await WaitingRoom.load(id, worker, {});
if (!waitingRoom?._refs.player1Account) {
throw new Error("Player 1 account not found");
}
if (!waitingRoom?._refs.player2Account) {
throw new Error("Player 2 account not found");
}
await waitingRoom?.ensureLoaded({
player1Account: {},
player2Account: {},
});
if (!waitingRoom) {
throw new Error("Failed to load the waiting room");
}
if (!waitingRoom.player1Account) {
throw new Error("Player 1 account not found");
}
if (!waitingRoom.player2Account) {
throw new Error("Player 2 account not found");
}
const player1Account = waitingRoom.player1Account;
const player2Account = waitingRoom.player2Account;
const readOnlyGroup = Group.create({ owner: worker });
readOnlyGroup.addMember(player1Account, "reader");
readOnlyGroup.addMember(player2Account, "reader");
const p1WriteGroup = Group.create({ owner: worker });
p1WriteGroup.addMember(player1Account, "writer");
const p1ReadOnlyGroup = Group.create({ owner: worker });
p1ReadOnlyGroup.addMember(player1Account, "reader");
const readOnlyOwnership = { owner: readOnlyGroup };
const p1WriteOwnership = { owner: p1WriteGroup };
const p1ReadOnlyOwnership = { owner: p1ReadOnlyGroup };
const p2WriteGroup = Group.create({ owner: worker });
p2WriteGroup.addMember(player2Account, "writer");
const p2ReadOnlyGroup = Group.create({ owner: worker });
p2ReadOnlyGroup.addMember(player2Account, "reader");
const p2WriteOwnership = { owner: p2WriteGroup };
const p2ReadOnlyOwnership = { owner: p2ReadOnlyGroup };
const deck = CardValues.map((value) =>
Card.create({ value }, { owner: Group.create({ owner: worker }) }),
);
const player1 = Player.create(
{
account: player1Account,
hand: CardList.create([], p1ReadOnlyOwnership),
scoredCards: CardList.create([], p1ReadOnlyOwnership),
giocata: CardContainer.create({}, p1WriteOwnership),
},
p1ReadOnlyOwnership,
);
const player2 = Player.create(
{
account: player2Account,
hand: CardList.create([], p2ReadOnlyOwnership),
scoredCards: CardList.create([], p2ReadOnlyOwnership),
giocata: CardContainer.create({}, p2WriteOwnership),
},
p2ReadOnlyOwnership,
);
const game = Game.create(
{
deck: CardList.create(deck, readOnlyOwnership),
player1,
player2,
activePlayer: Math.random() < 0.5 ? player1 : player2,
},
readOnlyOwnership,
);
waitingRoom.game = game;
console.log("Starting game", game.id);
await startGame(game as FullGame);
}
type FullGame = {
player1: {
account: Account;
hand: CardList;
scoredCards: CardList;
giocata: CardContainer;
} & Player;
player2: {
account: Account;
hand: CardList;
scoredCards: CardList;
giocata: CardContainer;
} & Player;
deck: CardList;
} & Game;
async function startGame(game: FullGame) {
drawCards(game, "player1");
drawCards(game, "player2");
}
function drawCards(game: FullGame, playerKey: "player1" | "player2") {
const player = game[playerKey];
while (player.hand.length < 3) {
const card = game.deck.shift();
if (!card) {
return;
}
const cardGroup = card._owner.castAs(Group);
cardGroup.addMember(player.account, "reader");
player.hand.push(card);
}
}

View File

@@ -0,0 +1,61 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
aspectRatio: {
card: "2584/4181",
},
},
},
plugins: [require("tailwindcss-animate")],
};

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,14 @@
import { resolve } from "path";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [TanStackRouterVite({}), react()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,11 @@
import { resolve } from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
});

View File

@@ -77,6 +77,14 @@ export class RawGroup<
return this.roleOfInternal(accountID)?.role;
}
getRoleFromInviteSecret(inviteSecret: InviteSecret): Role | undefined {
const inviteAgentSecret = this.core.node.crypto.agentSecretFromSecretSeed(
secretSeedFromInviteSecret(inviteSecret),
);
const agentID = this.core.node.crypto.getAgentID(inviteAgentSecret);
return this.roleOfInternal(agentID)?.role;
}
/** @internal */
roleOfInternal(
accountID: RawAccountID | AgentID | typeof EVERYONE,

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { CoValueState } from "../coValueState.js";
import { RawCoList } from "../coValues/coList.js";
import { RawCoMap } from "../coValues/coMap.js";
import { RawCoStream } from "../coValues/coStream.js";
@@ -492,4 +493,39 @@ describe("writeOnly", () => {
// The writer role should be able to see the edits from the admin
expect(mapOnNode2.get("test")).toEqual("Written from the admin");
});
test("upgrade to writer roles should work correctly", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
const map = groupOnNode2.createMap();
map.set("test", "Written from the writeOnly member");
await map.core.waitForSync();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writer",
);
group.core.waitForSync();
node2.node.coValuesStore.coValues.delete(map.id);
expect(node2.node.coValuesStore.get(map.id)).toEqual(
CoValueState.Unknown(map.id),
);
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
// The writer role should be able to see the edits from the admin
expect(mapOnNode2.get("test")).toEqual("Written from the writeOnly member");
});
});

View File

@@ -2,26 +2,39 @@ import { AgentSecret, LocalNode, WasmCrypto } from "cojson";
import {
Account,
AccountClass,
CoValue,
ID,
Inbox,
InboxMessage,
Profile,
createJazzContext,
fixedCredentialsAuth,
randomSessionProvider,
} from "jazz-tools";
import { WebSocket } from "ws";
import { webSocketWithReconnection } from "./webSocketWithReconnection.js";
/** @category Context Creation */
export async function startWorker<Acc extends Account>({
accountID = process.env.JAZZ_WORKER_ACCOUNT,
accountSecret = process.env.JAZZ_WORKER_SECRET,
syncServer = "wss://cloud.jazz.tools",
AccountSchema = Account as unknown as AccountClass<Acc>,
}: {
type WorkerOptions<Acc extends Account, M extends InboxMessage<string, any>> = {
accountID?: string;
accountSecret?: string;
syncServer?: string;
AccountSchema?: AccountClass<Acc>;
}): Promise<{ worker: Acc; done: () => Promise<void> }> {
onInboxMessage?: (message: M) => Promise<void>;
};
/** @category Context Creation */
export async function startWorker<
Acc extends Account,
M extends InboxMessage<string, any>,
>(
options: WorkerOptions<Acc, M>,
): Promise<{ worker: Acc; done: () => Promise<void> }> {
const {
accountID = process.env.JAZZ_WORKER_ACCOUNT,
accountSecret = process.env.JAZZ_WORKER_SECRET,
syncServer = "wss://cloud.jazz.tools",
AccountSchema = Account as unknown as AccountClass<Acc>,
} = options;
let node: LocalNode | undefined = undefined;
const wsPeer = webSocketWithReconnection(syncServer, (peer) => {
node?.syncManager.addPeer(peer);
@@ -54,9 +67,24 @@ export async function startWorker<Acc extends Account>({
node = context.account._raw.core.node;
const account = context.account as Acc;
if (!account._refs.profile?.id) {
throw new Error("Account has no profile");
}
let unsubscribe = () => {};
if (options.onInboxMessage) {
const inbox = await Inbox.load(account);
unsubscribe = inbox.subscribe(options.onInboxMessage);
}
async function done() {
await context.account.waitForAllCoValuesSync();
unsubscribe();
wsPeer.done();
context.done();
}

View File

@@ -1,10 +1,7 @@
import { Peer } from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import { createWorkerAccount } from "jazz-run/createWorkerAccount";
import { startSyncServer } from "jazz-run/startSyncServer";
import { CoMap, Group, co } from "jazz-tools";
import { describe, expect, onTestFinished, test, vi } from "vitest";
import { WebSocket } from "ws";
import { startWorker } from "../index";
async function setup() {
@@ -34,13 +31,13 @@ async function setupSyncServer(defaultPort = "0") {
}
async function setupWorker(syncServer: string) {
const { accountId, agentSecret } = await createWorkerAccount({
const { accountID, agentSecret } = await createWorkerAccount({
name: "test-worker",
peer: syncServer,
});
return startWorker({
accountID: accountId,
accountID: accountID,
accountSecret: agentSecret,
syncServer,
});

View File

@@ -5,7 +5,7 @@ import {
consumeInviteLinkFromWindowLocation,
createJazzBrowserContext,
} from "jazz-browser";
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Account,
@@ -17,6 +17,9 @@ import {
DeeplyLoaded,
DepthsIn,
ID,
Inbox,
InboxConsumer,
InboxMessage,
createCoValueObservable,
} from "jazz-tools";
@@ -262,12 +265,77 @@ export function createJazzReactApp<Acc extends Account>({
}, [onAccept]);
}
function useAccountInbox<M extends InboxMessage<string, any>>(
inboxOwnerID: ID<Acc>,
) {
const me = useAccount().me;
const [inbox, setInbox] = useState<InboxConsumer<M> | undefined>();
const [messages, setMessages] = useState<M[]>([]);
useEffect(() => {
async function load() {
const inbox = await InboxConsumer.load(inboxOwnerID, me);
setInbox(inbox);
for await (const message of messages) {
inbox.sendMessage(message);
}
}
load();
}, [inboxOwnerID]);
const sendMessage = useCallback(
(message: M) => {
if (!inbox) {
setMessages((messages) => [...messages, message]);
} else {
inbox.sendMessage(message);
}
},
[inbox],
);
return { sendMessage };
}
function useInboxMessagesListener<M extends InboxMessage<string, any>>(
onMessage: (message: M) => Promise<void>,
) {
const me = useAccount().me;
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
let unsubscribe = () => {};
let unsubscribed = false;
async function load() {
const inbox = await Inbox.load(me);
if (unsubscribed) return;
unsubscribe = inbox.subscribe((message: M) => {
return onMessageRef.current(message);
});
}
load();
return () => {
unsubscribed = true;
unsubscribe();
};
}, []);
}
return {
Provider,
useAccount,
useAccountOrGuest,
useCoState,
useAcceptInvite,
useAccountInbox,
useInboxMessagesListener,
};
}
@@ -321,6 +389,16 @@ export interface JazzReactApp<Acc extends Account> {
onAccept: (projectID: ID<V>) => void;
forValueHint?: string;
}): void;
useAccountInbox<M extends InboxMessage<string, any>>(
inboxOwnerID: ID<Acc>,
): {
sendMessage: (message: M) => void;
};
useInboxMessagesListener<M extends InboxMessage<string, any>>(
onMessage: (message: M) => Promise<void>,
): void;
}
export { createInviteLink, parseInviteLink } from "jazz-browser";

View File

@@ -12,6 +12,10 @@
"./createWorkerAccount": {
"import": "./dist/createWorkerAccount.js",
"types": "./src/createWorkerAccount.ts"
},
"./createWorkerInbox": {
"import": "./dist/createWorkerInbox.js",
"types": "./src/createWorkerInbox.ts"
}
},
"scripts": {

View File

@@ -1,5 +1,5 @@
import { createWebSocketPeer } from "cojson-transport-ws";
import { Account, WasmCrypto, isControlledAccount } from "jazz-tools";
import { Account, Inbox, WasmCrypto, isControlledAccount } from "jazz-tools";
import { WebSocket } from "ws";
export const createWorkerAccount = async ({
@@ -27,19 +27,13 @@ export const createWorkerAccount = async ({
throw new Error("account is not a controlled account");
}
const accountCoValue = account._raw.core;
const accountProfileCoValue = account.profile!._raw.core;
const syncManager = account._raw.core.node.syncManager;
await Promise.all([
syncManager.syncCoValue(accountCoValue),
syncManager.syncCoValue(accountProfileCoValue),
]);
// Create the inbox for the worker account
Inbox.createIfMissing(account);
await account.waitForAllCoValuesSync({ timeout: 4_000 });
return {
accountId: account.id,
accountID: account.id,
agentSecret: account._raw.agentSecret,
};
};

View File

@@ -19,15 +19,15 @@ const createAccountCommand = Command.make(
{ name: nameOption, peer: peerOption, json: jsonOption },
({ name, peer, json }) => {
return Effect.gen(function* () {
const { accountId, agentSecret } = yield* Effect.promise(() =>
const { accountID, agentSecret } = yield* Effect.promise(() =>
createWorkerAccount({ name, peer }),
);
if (json) {
Console.log(JSON.stringify({ accountId, agentSecret }));
yield* Console.log(JSON.stringify({ accountID, agentSecret }));
} else {
yield* Console.log(`# Credentials for Jazz account "${name}":
JAZZ_WORKER_ACCOUNT=${accountId}
JAZZ_WORKER_ACCOUNT=${accountID}
JAZZ_WORKER_SECRET=${agentSecret}
`);
}

View File

@@ -25,12 +25,12 @@ describe("createWorkerAccount - integration tests", () => {
throw new Error("Server address is not an object");
}
const { accountId, agentSecret } = await createWorkerAccount({
const { accountID, agentSecret } = await createWorkerAccount({
name: "test",
peer: `ws://localhost:${address.port}`,
});
expect(accountId).toBeDefined();
expect(accountID).toBeDefined();
expect(agentSecret).toBeDefined();
const peer = createWebSocketPeer({
@@ -46,16 +46,16 @@ describe("createWorkerAccount - integration tests", () => {
crypto,
});
expect(await node.load(accountId as any)).not.toBe("unavailable");
expect(await node.load(accountID as any)).not.toBe("unavailable");
});
it("should create a worker account using the Jazz cloud", async () => {
const { accountId, agentSecret } = await createWorkerAccount({
const { accountID, agentSecret } = await createWorkerAccount({
name: "test",
peer: `wss://cloud.jazz.tools`,
});
expect(accountId).toBeDefined();
expect(accountID).toBeDefined();
expect(agentSecret).toBeDefined();
const peer = createWebSocketPeer({
@@ -71,6 +71,6 @@ describe("createWorkerAccount - integration tests", () => {
crypto,
});
expect(await node.load(accountId as any)).not.toBe("unavailable");
expect(await node.load(accountID as any)).not.toBe("unavailable");
});
});

View File

@@ -1,4 +1,11 @@
import type { Everyone, RawAccountID, RawGroup, Role } from "cojson";
import type {
CoID,
Everyone,
RawAccountID,
RawCoMap,
RawGroup,
Role,
} from "cojson";
import type {
CoValue,
CoValueClass,
@@ -23,9 +30,19 @@ import {
subscribeToExistingCoValue,
} from "../internal.js";
export function resolveAccount(owner: Account | Group): Account {
if (owner._type === "Account") {
return owner;
}
return resolveAccount(owner._owner);
}
/** @category Identity & Permissions */
export class Profile extends CoMap {
name = co.string;
inbox = co.optional.json<CoID<RawCoMap>>();
inboxInvite = co.optional.string;
}
/** @category Identity & Permissions */

View File

@@ -0,0 +1,252 @@
import { CoID, InviteSecret, RawAccount, RawCoMap, SessionID } from "cojson";
import { CoStreamItem, RawCoStream } from "cojson/src/coValues/coStream.js";
import { JsonValue } from "fast-check";
import { Account, Group, isControlledAccount } from "../internal.js";
import { CoValue, ID } from "./interfaces.js";
type InboxInvite = `${CoID<MessagesStream>}/${InviteSecret}`;
type TxKey = `${SessionID}/${number}`;
export type InboxMessage<T extends string, I extends ID<any>> = {
type: T;
value: I;
};
type MessagesStream = RawCoStream<InboxMessage<string, any>>;
type TxKeyStream = RawCoStream<TxKey>;
type InboxRoot = RawCoMap<{
messages: CoID<MessagesStream>;
processed: CoID<TxKeyStream>;
failed: CoID<MessagesStream>;
inviteLink: InboxInvite;
}>;
function createInboxRoot(account: Account) {
if (!isControlledAccount(account)) {
throw new Error("Account is not controlled");
}
const rawAccount = account._raw;
const group = rawAccount.createGroup();
const messagesFeed = group.createStream<MessagesStream>();
const inboxRoot = rawAccount.createMap<InboxRoot>();
const processedFeed = rawAccount.createStream<TxKeyStream>();
const failedFeed = rawAccount.createStream<MessagesStream>();
const inviteLink =
`${messagesFeed.id}/${group.createInvite("writeOnly")}` as const;
inboxRoot.set("messages", messagesFeed.id);
inboxRoot.set("processed", processedFeed.id);
inboxRoot.set("failed", failedFeed.id);
return {
root: inboxRoot,
inviteLink,
};
}
export class Inbox {
messages: MessagesStream;
processed: TxKeyStream;
failed: MessagesStream;
root: InboxRoot;
private constructor(
root: InboxRoot,
messages: MessagesStream,
processed: TxKeyStream,
failed: MessagesStream,
) {
this.root = root;
this.messages = messages;
this.processed = processed;
this.failed = failed;
}
subscribe<M extends InboxMessage<string, any>>(
callback: (message: M) => Promise<void>,
) {
// TODO: Register the subscription to get a % of the new messages
const processed = new Set<`${SessionID}/${number}`>();
const processing = new Set<`${SessionID}/${number}`>();
const failed = new Map<`${SessionID}/${number}`, number>();
// TODO: We don't take into account a possible concurrency between multiple Workers
for (const items of Object.values(this.processed.items)) {
for (const item of items) {
processed.add(item.value as TxKey);
}
}
return this.messages.core.subscribe((value) => {
const messages = value as MessagesStream;
for (const [sessionID, items] of Object.entries(messages.items) as [
SessionID,
CoStreamItem<M>[],
][]) {
for (const item of items) {
const txKey = `${sessionID}/${item.tx.txIndex}` as const;
if (!processed.has(txKey) && !processing.has(txKey)) {
const failures = failed.get(txKey);
if (failures && failures > 3) {
processed.add(txKey);
this.processed.push(txKey);
this.failed.push(item.value);
continue;
}
processing.add(txKey);
callback(item.value)
.then(() => {
// hack: we add a transaction without triggering an update on processedFeed
this.processed.push(txKey);
processing.delete(txKey);
processed.add(txKey);
})
.catch((error) => {
console.error("Error processing inbox message", error);
processing.delete(txKey);
});
}
}
}
});
}
static createIfMissing(account: Account) {
const profile = account.profile;
if (!profile) {
throw new Error("Account profile should already be loaded");
}
if (profile.inbox) {
return;
}
const { root, inviteLink } = createInboxRoot(account);
profile.inbox = root.id;
profile.inboxInvite = inviteLink;
}
static async load(account: Account) {
const profile = account.profile;
if (!profile) {
throw new Error("Account profile should already be loaded");
}
if (!profile.inbox) {
this.createIfMissing(account);
}
const node = account._raw.core.node;
const root = await node.load(profile.inbox as CoID<InboxRoot>);
if (root === "unavailable") {
throw new Error("Inbox not found");
}
const [messages, processed, failed] = await Promise.all([
node.load(root.get("messages")!),
node.load(root.get("processed")!),
node.load(root.get("failed")!),
]);
if (
messages === "unavailable" ||
processed === "unavailable" ||
failed === "unavailable"
) {
throw new Error("Inbox not found");
}
return new Inbox(root, messages, processed, failed);
}
}
export class InboxConsumer<M extends InboxMessage<string, any>> {
currentAccount: Account;
owner: Account;
messages: MessagesStream;
private constructor(
currentAccount: Account,
owner: Account,
messages: MessagesStream,
) {
this.currentAccount = currentAccount;
this.owner = owner;
this.messages = messages;
}
getOwnerAccount() {
return this.owner;
}
sendMessage(message: M) {
const node = this.currentAccount._raw.core.node;
const value = node.expectCoValueLoaded(message.value);
const content = value.getCurrentContent();
const group = content.group;
if (group instanceof RawAccount) {
throw new Error("Inbox messages should be owned by a group");
}
if (!group.roleOf(this.owner._raw.id)) {
group.addMember(this.owner._raw, "writer");
}
this.messages.push(message);
}
static async load(fromAccountID: ID<Account>, currentAccount: Account) {
const fromAccount = await Account.load(fromAccountID, currentAccount, {
profile: {},
});
if (!fromAccount?.profile?.inboxInvite) {
throw new Error("Inbox invite not found");
}
const invite = fromAccount.profile.inboxInvite;
const id = await acceptInvite(invite, currentAccount);
const node = currentAccount._raw.core.node;
const messages = await node.load(id);
if (messages === "unavailable") {
throw new Error("Inbox not found");
}
return new InboxConsumer(currentAccount, fromAccount, messages);
}
}
async function acceptInvite(invite: string, account: Account) {
const id = invite.slice(0, invite.indexOf("/")) as CoID<MessagesStream>;
const inviteSecret = invite.slice(invite.indexOf("/") + 1) as InviteSecret;
if (!id?.startsWith("co_z") || !inviteSecret.startsWith("inviteSecret_")) {
throw new Error("Invalid inbox ticket");
}
if (!isControlledAccount(account)) {
throw new Error("Account is not controlled");
}
await account._raw.acceptInvite(id, inviteSecret);
return id;
}

View File

@@ -208,6 +208,7 @@ export function subscribeToCoValue<V extends CoValue, Depth>(
value,
cls as CoValueClass<V> & CoValueFromRaw<V>,
(update) => {
if (unsubscribed) return;
if (fulfillsDepth(depth, update)) {
listener(update as DeeplyLoaded<V, Depth>);
}

View File

@@ -12,6 +12,12 @@ export type { CoValue, ID } from "./internal.js";
export { Encoders, co } from "./internal.js";
export {
Inbox,
InboxConsumer,
type InboxMessage,
} from "./coValues/inbox.js";
export {
Account,
FileStream,

3214
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff