Compare commits
6 Commits
jazz-react
...
inbox-bris
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b3d0730f | ||
|
|
04f8eb30b8 | ||
|
|
51f4910da0 | ||
|
|
d141d30c91 | ||
|
|
e182a12e1e | ||
|
|
364de1505a |
21
examples/briscola/.gitignore
vendored
Normal file
21
examples/briscola/.gitignore
vendored
Normal 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
|
||||
21
examples/briscola/components.json
Normal file
21
examples/briscola/components.json
Normal 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"
|
||||
}
|
||||
14
examples/briscola/index.html
Normal file
14
examples/briscola/index.html
Normal 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>
|
||||
44
examples/briscola/package.json
Normal file
44
examples/briscola/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
examples/briscola/postcss.config.js
Normal file
6
examples/briscola/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
2
examples/briscola/src/.env.example
Normal file
2
examples/briscola/src/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
JAZZ_WORKER_ACCOUNT=
|
||||
JAZZ_WORKER_SECRET=
|
||||
7
examples/briscola/src/bastoni.svg
Normal file
7
examples/briscola/src/bastoni.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 66 KiB |
56
examples/briscola/src/components/ui/button.tsx
Normal file
56
examples/briscola/src/components/ui/button.tsx
Normal 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 };
|
||||
86
examples/briscola/src/components/ui/card.tsx
Normal file
86
examples/briscola/src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
22
examples/briscola/src/components/ui/input.tsx
Normal file
22
examples/briscola/src/components/ui/input.tsx
Normal 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 };
|
||||
24
examples/briscola/src/components/ui/label.tsx
Normal file
24
examples/briscola/src/components/ui/label.tsx
Normal 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 };
|
||||
29
examples/briscola/src/components/ui/separator.tsx
Normal file
29
examples/briscola/src/components/ui/separator.tsx
Normal 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 };
|
||||
144
examples/briscola/src/coppe.svg
Normal file
144
examples/briscola/src/coppe.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 109 KiB |
8
examples/briscola/src/credentials.ts
Normal file
8
examples/briscola/src/credentials.ts
Normal 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",
|
||||
};
|
||||
95
examples/briscola/src/denari.svg
Normal file
95
examples/briscola/src/denari.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
66
examples/briscola/src/index.css
Normal file
66
examples/briscola/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
6
examples/briscola/src/lib/utils.ts
Normal file
6
examples/briscola/src/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));
|
||||
}
|
||||
46
examples/briscola/src/main.tsx
Normal file
46
examples/briscola/src/main.tsx
Normal 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} />);
|
||||
}
|
||||
111
examples/briscola/src/routeTree.gen.ts
Normal file
111
examples/briscola/src/routeTree.gen.ts
Normal 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 */
|
||||
15
examples/briscola/src/routes/__root.tsx
Normal file
15
examples/briscola/src/routes/__root.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
examples/briscola/src/routes/game/$gameId.tsx
Normal file
191
examples/briscola/src/routes/game/$gameId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
examples/briscola/src/routes/index.tsx
Normal file
143
examples/briscola/src/routes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
examples/briscola/src/schema.ts
Normal file
89
examples/briscola/src/schema.ts
Normal 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);
|
||||
}
|
||||
86
examples/briscola/src/spade.svg
Normal file
86
examples/briscola/src/spade.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 69 KiB |
7
examples/briscola/src/types.ts
Normal file
7
examples/briscola/src/types.ts
Normal 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
1
examples/briscola/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
145
examples/briscola/src/worker.ts
Normal file
145
examples/briscola/src/worker.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
61
examples/briscola/tailwind.config.js
Normal file
61
examples/briscola/tailwind.config.js
Normal 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")],
|
||||
};
|
||||
14
examples/briscola/tsconfig.json
Normal file
14
examples/briscola/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
examples/briscola/vite.config.ts
Normal file
14
examples/briscola/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
11
examples/briscola/vite.server.ts
Normal file
11
examples/briscola/vite.server.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"./createWorkerAccount": {
|
||||
"import": "./dist/createWorkerAccount.js",
|
||||
"types": "./src/createWorkerAccount.ts"
|
||||
},
|
||||
"./createWorkerInbox": {
|
||||
"import": "./dist/createWorkerInbox.js",
|
||||
"types": "./src/createWorkerInbox.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
252
packages/jazz-tools/src/coValues/inbox.ts
Normal file
252
packages/jazz-tools/src/coValues/inbox.ts
Normal 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;
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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
3214
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user