Compare commits
5 Commits
cojson@0.1
...
add-code-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a3650ab0 | ||
|
|
936db45abf | ||
|
|
6a764b5248 | ||
|
|
84a1b5f893 | ||
|
|
4c3b0d3a27 |
11
.editorconfig
Normal file
11
.editorconfig
Normal file
@@ -0,0 +1,11 @@
|
||||
# EditorConfig is awesome: https://editorconfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
22
.github/workflows/monorepo-linting.yml
vendored
22
.github/workflows/monorepo-linting.yml
vendored
@@ -13,9 +13,29 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run sherif
|
||||
run: npx sherif@1.0.0
|
||||
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
|
||||
1
.prettierrc
Normal file
1
.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
const config = {
|
||||
trailingComma: "all",
|
||||
tabWidth: 4,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -88,7 +88,7 @@ export class MusicaAccount extends Account {
|
||||
profile = co.ref(Profile);
|
||||
root = co.ref(MusicaAccountRoot);
|
||||
|
||||
/**
|
||||
/**
|
||||
* The account migration is run on account creation and on every log-in.
|
||||
* You can use it to set up the account root and any other initial CoValues you need.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createHashRouter, RouterProvider } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useMediaPlayer } from "./5_useMediaPlayer";
|
||||
import { HomePage } from "./3_HomePage";
|
||||
import { InvitePage } from "./6_InvitePage";
|
||||
|
||||
@@ -5,12 +5,16 @@ import { usePlayState } from "./lib/audio/usePlayState";
|
||||
import { SidePanel } from "./components/SidePanel";
|
||||
import { FileUploadButton } from "./components/FileUploadButton";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { createNewPlaylist, updatePlaylistTitle, uploadMusicTracks } from "./4_actions";
|
||||
import {
|
||||
createNewPlaylist,
|
||||
updatePlaylistTitle,
|
||||
uploadMusicTracks,
|
||||
} from "./4_actions";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { ID } from "jazz-tools";
|
||||
import { Playlist } from "./1_schema";
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
/**
|
||||
@@ -27,7 +31,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const navigate = useNavigate();
|
||||
const playState = usePlayState();
|
||||
const isPlaying = playState.value === "play";
|
||||
const { toast } = useToast()
|
||||
const { toast } = useToast();
|
||||
|
||||
async function handleFileLoad(files: FileList) {
|
||||
if (!me) return;
|
||||
@@ -127,10 +131,15 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
}
|
||||
isPlaying={
|
||||
mediaPlayer.activeTrackId ===
|
||||
track.id && isActivePlaylist && isPlaying
|
||||
track.id &&
|
||||
isActivePlaylist &&
|
||||
isPlaying
|
||||
}
|
||||
onClick={() => {
|
||||
mediaPlayer.setActiveTrack(track, playlist);
|
||||
mediaPlayer.setActiveTrack(
|
||||
track,
|
||||
playlist,
|
||||
);
|
||||
}}
|
||||
showAddToPlaylist={isRootPlaylist}
|
||||
/>
|
||||
|
||||
@@ -92,7 +92,8 @@ export async function addTrackToPlaylist(
|
||||
) {
|
||||
if (!account) return;
|
||||
|
||||
if (playlist.tracks?.some((t) => t?._refs.sourceTrack.id === track.id)) return;
|
||||
if (playlist.tracks?.some((t) => t?._refs.sourceTrack.id === track.id))
|
||||
return;
|
||||
|
||||
/**
|
||||
* Since musicTracks are created as private values (see uploadMusicTracks)
|
||||
@@ -137,10 +138,13 @@ export async function updateMusicTrackTitle(track: MusicTrack, title: string) {
|
||||
track.title = title;
|
||||
}
|
||||
|
||||
export async function updateActivePlaylist(playlist: Playlist, me: MusicaAccount) {
|
||||
export async function updateActivePlaylist(
|
||||
playlist: Playlist,
|
||||
me: MusicaAccount,
|
||||
) {
|
||||
me.root!.activePlaylist = playlist ?? me.root!.rootPlaylist;
|
||||
}
|
||||
|
||||
export async function updateActiveTrack(track: MusicTrack, me: MusicaAccount) {
|
||||
me.root!.activeTrack = track;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function useMediaPlayer() {
|
||||
|
||||
const [loading, setLoading] = useState<ID<MusicTrack> | null>(null);
|
||||
|
||||
const activeTrackId = me?.root?._refs.activeTrack?.id
|
||||
const activeTrackId = me?.root?._refs.activeTrack?.id;
|
||||
|
||||
// Reference used to avoid out-of-order track loads
|
||||
const lastLoadedTrackId = useRef<ID<MusicTrack> | null>(null);
|
||||
@@ -71,10 +71,7 @@ export function useMediaPlayer() {
|
||||
async function setActiveTrack(track: MusicTrack, playlist?: Playlist) {
|
||||
if (!me?.root) return;
|
||||
|
||||
if (
|
||||
activeTrackId === track.id &&
|
||||
lastLoadedTrackId.current !== null
|
||||
) {
|
||||
if (activeTrackId === track.id && lastLoadedTrackId.current !== null) {
|
||||
playState.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function MusicTrackRow({
|
||||
|
||||
function handleTrackTitleChange(evt: ChangeEvent<HTMLInputElement>) {
|
||||
if (!track) return;
|
||||
|
||||
|
||||
updateMusicTrackTitle(track, evt.target.value);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
});
|
||||
|
||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
|
||||
waveform: {}
|
||||
waveform: {},
|
||||
});
|
||||
|
||||
if (!activeTrack) return null;
|
||||
@@ -60,14 +60,16 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
|
||||
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
|
||||
<Waveform track={activeTrack} height={30} />
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
|
||||
<h4 className="font-medium text-blue-800">
|
||||
{activeTrackTitle}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">{activePlaylist?.title || "All tracks"}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{activePlaylist?.title || "All tracks"}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,7 @@ export function SidePanel() {
|
||||
},
|
||||
});
|
||||
|
||||
function handleAllTracksClick(
|
||||
evt: React.MouseEvent<HTMLAnchorElement>,
|
||||
) {
|
||||
function handleAllTracksClick(evt: React.MouseEvent<HTMLAnchorElement>) {
|
||||
evt.preventDefault();
|
||||
navigate(`/`);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center 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",
|
||||
{
|
||||
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",
|
||||
},
|
||||
"inline-flex items-center justify-center 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",
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
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"
|
||||
({ 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";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,198 +1,201 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest opacity-60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
||||
@@ -1,127 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>
|
||||
{description}
|
||||
</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,191 +1,189 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) =>
|
||||
dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
export { useToast, toast };
|
||||
|
||||
@@ -2,31 +2,34 @@ import { useLayoutEffect, useState } from "react";
|
||||
import { useAudioManager } from "./AudioManager";
|
||||
|
||||
export function usePlayerCurrentTime() {
|
||||
const audioManager = useAudioManager();
|
||||
const [value, setValue] = useState<number>(0);
|
||||
const audioManager = useAudioManager();
|
||||
const [value, setValue] = useState<number>(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
useLayoutEffect(() => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
};
|
||||
const onTimeUpdate = () => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
};
|
||||
|
||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||
};
|
||||
}, [audioManager]);
|
||||
return () => {
|
||||
audioManager.mediaElement.removeEventListener(
|
||||
"timeupdate",
|
||||
onTimeUpdate,
|
||||
);
|
||||
};
|
||||
}, [audioManager]);
|
||||
|
||||
function setCurrentTime(time: number) {
|
||||
if (audioManager.mediaElement.paused) audioManager.play();
|
||||
function setCurrentTime(time: number) {
|
||||
if (audioManager.mediaElement.paused) audioManager.play();
|
||||
|
||||
audioManager.mediaElement.currentTime = time;
|
||||
}
|
||||
audioManager.mediaElement.currentTime = time;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue: setCurrentTime,
|
||||
};
|
||||
return {
|
||||
value,
|
||||
setValue: setCurrentTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Link, RouterProvider, createHashRouter, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Link,
|
||||
RouterProvider,
|
||||
createHashRouter,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { createJazzReactApp, DemoAuthBasicUI, useDemoAuth } from "jazz-react";
|
||||
@@ -38,10 +43,7 @@ function JazzAndAuth({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
peer={peer}
|
||||
>
|
||||
<Jazz.Provider auth={auth} peer={peer}>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
<DemoAuthBasicUI appName={appName} state={authState} />
|
||||
@@ -96,9 +98,7 @@ export default function App() {
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.navigate("/").then(() => logOut())
|
||||
}
|
||||
onClick={() => router.navigate("/").then(() => logOut())}
|
||||
variant="outline"
|
||||
>
|
||||
Log out
|
||||
@@ -114,7 +114,7 @@ function AcceptInvite() {
|
||||
onAccept: (petPostID) => navigate("/pet/" + petPostID),
|
||||
});
|
||||
|
||||
return <p>Accepting invite...</p>
|
||||
return <p>Accepting invite...</p>;
|
||||
}
|
||||
|
||||
export function PostOverview() {
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
} from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { createJazzReactApp, PasskeyAuthBasicUI, usePasskeyAuth } from "jazz-react";
|
||||
import {
|
||||
createJazzReactApp,
|
||||
PasskeyAuthBasicUI,
|
||||
usePasskeyAuth,
|
||||
} from "jazz-react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -62,8 +66,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<App />
|
||||
</JazzAndAuth>
|
||||
</div>
|
||||
</ThemeProvider>,
|
||||
</React.StrictMode>
|
||||
</ThemeProvider>
|
||||
,
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -105,9 +110,7 @@ export default function App() {
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.navigate("/").then(logOut)
|
||||
}
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log out
|
||||
|
||||
@@ -76,7 +76,9 @@ function RenderPackageChild({
|
||||
<div key={i} id={child.name} className="not-prose mt-4">
|
||||
{
|
||||
<Highlight hide={[0, 2]}>
|
||||
{`function \n${printSimpleSignature(child, signature) + ":"}\n {}`}
|
||||
{`function \n${
|
||||
printSimpleSignature(child, signature) + ":"
|
||||
}\n {}`}
|
||||
</Highlight>
|
||||
}{" "}
|
||||
<span className="opacity-75 text-xs pl-1">
|
||||
@@ -123,9 +125,9 @@ function RenderTypeAlias({
|
||||
<Highlight>{`type ${child.name}`}</Highlight>
|
||||
</h3>
|
||||
<p className="not-prose text-sm ml-4">
|
||||
<Highlight>{`type ${child.name}${typeParameters?.length && `<${typeParameters?.join(", ")}>`} = ${printType(
|
||||
child.type,
|
||||
)}`}</Highlight>
|
||||
<Highlight>{`type ${child.name}${
|
||||
typeParameters?.length && `<${typeParameters?.join(", ")}>`
|
||||
} = ${printType(child.type)}`}</Highlight>
|
||||
</p>
|
||||
<div className="ml-4 mt-2 flex-[3]">
|
||||
<DocComment>
|
||||
|
||||
@@ -94,7 +94,12 @@ export class SQLiteStorage {
|
||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
|
||||
localNodeName,
|
||||
"storage",
|
||||
{ peer1role: "client", peer2role: "server", trace, crashOnClose: true },
|
||||
{
|
||||
peer1role: "client",
|
||||
peer2role: "server",
|
||||
trace,
|
||||
crashOnClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
await SQLiteStorage.open(
|
||||
@@ -427,7 +432,9 @@ export class SQLiteStorage {
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(accountID): accountID is RawAccountID =>
|
||||
(
|
||||
accountID,
|
||||
): accountID is RawAccountID =>
|
||||
cojsonInternals.isAccountID(
|
||||
accountID,
|
||||
) && accountID !== theirKnown.id,
|
||||
|
||||
@@ -4,10 +4,10 @@ import { addMessageToBacklog } from "./serialization.js";
|
||||
export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000;
|
||||
|
||||
export class BatchedOutgoingMessages {
|
||||
private backlog: string = '';
|
||||
private backlog: string = "";
|
||||
private timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private send: (messages: string) => void) { }
|
||||
constructor(private send: (messages: string) => void) {}
|
||||
|
||||
push(msg: SyncMessage) {
|
||||
const payload = addMessageToBacklog(this.backlog, msg);
|
||||
@@ -16,12 +16,13 @@ export class BatchedOutgoingMessages {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
const maxChunkSizeReached = payload.length >= MAX_OUTGOING_MESSAGES_CHUNK_BYTES;
|
||||
const maxChunkSizeReached =
|
||||
payload.length >= MAX_OUTGOING_MESSAGES_CHUNK_BYTES;
|
||||
const backlogExists = this.backlog.length > 0;
|
||||
|
||||
if (maxChunkSizeReached && backlogExists) {
|
||||
this.sendMessagesInBulk();
|
||||
this.backlog = addMessageToBacklog('', msg);
|
||||
this.backlog = addMessageToBacklog("", msg);
|
||||
this.timeout = setTimeout(() => {
|
||||
this.sendMessagesInBulk();
|
||||
}, 0);
|
||||
@@ -38,7 +39,7 @@ export class BatchedOutgoingMessages {
|
||||
|
||||
sendMessagesInBulk() {
|
||||
this.send(this.backlog);
|
||||
this.backlog = '';
|
||||
this.backlog = "";
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@@ -47,7 +47,11 @@ export function createWebSocketPeer({
|
||||
const result = deserializeMessages(event.data as string);
|
||||
|
||||
if (!result.ok) {
|
||||
console.error("Error while deserializing messages", event.data, result.error);
|
||||
console.error(
|
||||
"Error while deserializing messages",
|
||||
event.data,
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,9 +94,7 @@ export function createWebSocketPeer({
|
||||
|
||||
const outgoingMessages = new BatchedOutgoingMessages((messages) => {
|
||||
if (websocket.readyState === 1) {
|
||||
websocket.send(
|
||||
messages,
|
||||
);
|
||||
websocket.send(messages);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ export function addMessageToBacklog(backlog: string, message: SyncMessage) {
|
||||
return `${backlog}\n${JSON.stringify(message)}`;
|
||||
}
|
||||
|
||||
export function deserializeMessages(messages: string) {
|
||||
export function deserializeMessages(messages: string) {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
messages: messages.split("\n").map((msg) => JSON.parse(msg)) as SyncMessage[] | PingMsg[],
|
||||
messages: messages.split("\n").map((msg) => JSON.parse(msg)) as
|
||||
| SyncMessage[]
|
||||
| PingMsg[],
|
||||
} as const;
|
||||
} catch (e) {
|
||||
console.error("Error while deserializing messages", e);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { BatchedOutgoingMessages, MAX_OUTGOING_MESSAGES_CHUNK_BYTES } from "../BatchedOutgoingMessages.js";
|
||||
import {
|
||||
BatchedOutgoingMessages,
|
||||
MAX_OUTGOING_MESSAGES_CHUNK_BYTES,
|
||||
} from "../BatchedOutgoingMessages.js";
|
||||
import { SyncMessage } from "cojson";
|
||||
import { CoValueKnownState } from "cojson/src/sync.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
})
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
})
|
||||
});
|
||||
|
||||
describe("BatchedOutgoingMessages", () => {
|
||||
function setup() {
|
||||
@@ -20,8 +23,18 @@ describe("BatchedOutgoingMessages", () => {
|
||||
|
||||
test("should batch messages and send them after a timeout", () => {
|
||||
const { sendMock, batchedMessages } = setup();
|
||||
const message1: SyncMessage = { action: "known", id: "co_z1", header: false, sessions: {} };
|
||||
const message2: SyncMessage = { action: "known", id: "co_z2", header: false, sessions: {} };
|
||||
const message1: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z1",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
const message2: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z2",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
batchedMessages.push(message1);
|
||||
batchedMessages.push(message2);
|
||||
@@ -32,7 +45,7 @@ describe("BatchedOutgoingMessages", () => {
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
`${JSON.stringify(message1)}\n${JSON.stringify(message2)}`
|
||||
`${JSON.stringify(message1)}\n${JSON.stringify(message2)}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,9 +57,8 @@ describe("BatchedOutgoingMessages", () => {
|
||||
header: false,
|
||||
sessions: {
|
||||
// Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
|
||||
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES)
|
||||
} as CoValueKnownState['sessions'],
|
||||
|
||||
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
|
||||
} as CoValueKnownState["sessions"],
|
||||
};
|
||||
|
||||
batchedMessages.push(largeMessage);
|
||||
@@ -57,15 +69,20 @@ describe("BatchedOutgoingMessages", () => {
|
||||
|
||||
test("should send accumulated messages before a large message", () => {
|
||||
const { sendMock, batchedMessages } = setup();
|
||||
const smallMessage: SyncMessage = { action: "known", id: "co_z_small", header: false, sessions: {} };
|
||||
const smallMessage: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z_small",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
const largeMessage: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z_large",
|
||||
header: false,
|
||||
sessions: {
|
||||
// Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
|
||||
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES)
|
||||
} as CoValueKnownState['sessions'],
|
||||
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
|
||||
} as CoValueKnownState["sessions"],
|
||||
};
|
||||
|
||||
batchedMessages.push(smallMessage);
|
||||
@@ -74,13 +91,24 @@ describe("BatchedOutgoingMessages", () => {
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock).toHaveBeenNthCalledWith(1, JSON.stringify(smallMessage));
|
||||
expect(sendMock).toHaveBeenNthCalledWith(2, JSON.stringify(largeMessage));
|
||||
expect(sendMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
JSON.stringify(smallMessage),
|
||||
);
|
||||
expect(sendMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
JSON.stringify(largeMessage),
|
||||
);
|
||||
});
|
||||
|
||||
test("should send remaining messages on close", () => {
|
||||
const { sendMock, batchedMessages } = setup();
|
||||
const message: SyncMessage = { action: "known", id: "co_z_test", header: false, sessions: {} };
|
||||
const message: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z_test",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
batchedMessages.push(message);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
@@ -93,13 +121,23 @@ describe("BatchedOutgoingMessages", () => {
|
||||
|
||||
test("should clear timeout when pushing new messages", () => {
|
||||
const { sendMock, batchedMessages } = setup();
|
||||
const message1: SyncMessage = { action: "known", id: "co_z1", header: false, sessions: {} };
|
||||
const message2: SyncMessage = { action: "known", id: "co_z2", header: false, sessions: {} };
|
||||
const message1: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z1",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
const message2: SyncMessage = {
|
||||
action: "known",
|
||||
id: "co_z2",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
batchedMessages.push(message1);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
|
||||
batchedMessages.push(message2);
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
@@ -108,7 +146,7 @@ describe("BatchedOutgoingMessages", () => {
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
`${JSON.stringify(message1)}\n${JSON.stringify(message2)}`
|
||||
`${JSON.stringify(message1)}\n${JSON.stringify(message2)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,12 +132,14 @@ describe("createWebSocketPeer", () => {
|
||||
expect(mockWebSocket.send).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message1));
|
||||
expect(mockWebSocket.send).toHaveBeenCalledWith(
|
||||
JSON.stringify(message1),
|
||||
);
|
||||
|
||||
mockWebSocket.send.mockClear();
|
||||
void peer.outgoing.push(message2);
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100))
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockWebSocket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -233,7 +235,10 @@ describe("createWebSocketPeer", () => {
|
||||
|
||||
const stream: SyncMessage[] = [];
|
||||
|
||||
while (serializeMessages(stream.concat(message1)).length < MAX_OUTGOING_MESSAGES_CHUNK_BYTES) {
|
||||
while (
|
||||
serializeMessages(stream.concat(message1)).length <
|
||||
MAX_OUTGOING_MESSAGES_CHUNK_BYTES
|
||||
) {
|
||||
stream.push(message1);
|
||||
void peer.outgoing.push(message1);
|
||||
}
|
||||
@@ -277,7 +282,10 @@ describe("createWebSocketPeer", () => {
|
||||
|
||||
const stream: SyncMessage[] = [];
|
||||
|
||||
while (serializeMessages(stream.concat(message1)).length < MAX_OUTGOING_MESSAGES_CHUNK_BYTES) {
|
||||
while (
|
||||
serializeMessages(stream.concat(message1)).length <
|
||||
MAX_OUTGOING_MESSAGES_CHUNK_BYTES
|
||||
) {
|
||||
stream.push(message1);
|
||||
void peer.outgoing.push(message1);
|
||||
}
|
||||
@@ -356,16 +364,12 @@ describe("createWebSocketPeer", () => {
|
||||
|
||||
messageHandler?.(
|
||||
new MessageEvent("message", {
|
||||
data: Array.from(
|
||||
{ length: 5 },
|
||||
() => message1,
|
||||
)
|
||||
data: Array.from({ length: 5 }, () => message1)
|
||||
.map((msg) => JSON.stringify(msg))
|
||||
.join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
const message2: SyncMessage = {
|
||||
action: "content",
|
||||
id: "co_zlow",
|
||||
@@ -407,7 +411,6 @@ describe("createWebSocketPeer", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
const message2: SyncMessage = {
|
||||
action: "content",
|
||||
id: "co_zlow",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
export interface WebsocketEvents {
|
||||
close: { code: number; reason: string; };
|
||||
message: { data: unknown; };
|
||||
close: { code: number; reason: string };
|
||||
message: { data: unknown };
|
||||
open: void;
|
||||
}
|
||||
|
||||
@@ -15,11 +14,11 @@ export interface AnyWebSocket {
|
||||
addEventListener<K extends keyof WebsocketEvents>(
|
||||
type: K,
|
||||
listener: (event: WebsocketEvents[K]) => void,
|
||||
options?: { once: boolean; }
|
||||
options?: { once: boolean },
|
||||
): void;
|
||||
removeEventListener<K extends keyof WebsocketEvents>(
|
||||
type: K,
|
||||
listener: (event: WebsocketEvents[K]) => void
|
||||
listener: (event: WebsocketEvents[K]) => void,
|
||||
): void;
|
||||
close(): void;
|
||||
send(data: string): void;
|
||||
|
||||
@@ -44,7 +44,6 @@ export class PeerState {
|
||||
|
||||
this.processing = true;
|
||||
|
||||
|
||||
let entry: QueueEntry | undefined;
|
||||
while ((entry = this.queue.pull())) {
|
||||
// Awaiting the push to send one message at a time
|
||||
|
||||
@@ -27,28 +27,21 @@ export type QueueEntry = {
|
||||
/**
|
||||
* Since we have a fixed range of priority values (0-7) we can create a fixed array of queues.
|
||||
*/
|
||||
type Tuple<T, N extends number, A extends unknown[] = []> = A extends { length: N } ? A : Tuple<T, N, [...A, T]>;
|
||||
type Tuple<T, N extends number, A extends unknown[] = []> = A extends {
|
||||
length: N;
|
||||
}
|
||||
? A
|
||||
: Tuple<T, N, [...A, T]>;
|
||||
type QueueTuple = Tuple<QueueEntry[], 8>;
|
||||
|
||||
export class PriorityBasedMessageQueue {
|
||||
private queues: QueueTuple = [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
];
|
||||
private queues: QueueTuple = [[], [], [], [], [], [], [], []];
|
||||
|
||||
private getQueue(priority: CoValuePriority) {
|
||||
return this.queues[priority];
|
||||
}
|
||||
|
||||
constructor(
|
||||
private defaultPriority: CoValuePriority,
|
||||
) {}
|
||||
constructor(private defaultPriority: CoValuePriority) {}
|
||||
|
||||
public push(msg: SyncMessage) {
|
||||
const { promise, resolve, reject } = promiseWithResolvers<void>();
|
||||
|
||||
@@ -13,7 +13,10 @@ function createResolvablePromise<T>() {
|
||||
|
||||
class CoValueUnknownState {
|
||||
type = "unknown" as const;
|
||||
private peers: Map<PeerID, ReturnType<typeof createResolvablePromise<"available" | "unavailable">>>;
|
||||
private peers: Map<
|
||||
PeerID,
|
||||
ReturnType<typeof createResolvablePromise<"available" | "unavailable">>
|
||||
>;
|
||||
private resolve: (value: "available" | "unavailable") => void;
|
||||
|
||||
ready: Promise<"available" | "unavailable">;
|
||||
@@ -22,10 +25,15 @@ class CoValueUnknownState {
|
||||
this.peers = new Map();
|
||||
|
||||
for (const peerId of peersIds) {
|
||||
this.peers.set(peerId, createResolvablePromise<"available" | "unavailable">());
|
||||
this.peers.set(
|
||||
peerId,
|
||||
createResolvablePromise<"available" | "unavailable">(),
|
||||
);
|
||||
}
|
||||
|
||||
const { resolve, promise } = createResolvablePromise<"available" | "unavailable">();
|
||||
const { resolve, promise } = createResolvablePromise<
|
||||
"available" | "unavailable"
|
||||
>();
|
||||
|
||||
this.ready = promise;
|
||||
this.resolve = resolve;
|
||||
@@ -66,20 +74,22 @@ class CoValueUnknownState {
|
||||
class CoValueAvailableState {
|
||||
type = "available" as const;
|
||||
|
||||
constructor(public coValue: CoValueCore) { }
|
||||
constructor(public coValue: CoValueCore) {}
|
||||
}
|
||||
|
||||
type CoValueStateAction = {
|
||||
type: "not-found";
|
||||
peerId: PeerID;
|
||||
} | {
|
||||
type: "found";
|
||||
peerId: PeerID;
|
||||
coValue: CoValueCore;
|
||||
};
|
||||
type CoValueStateAction =
|
||||
| {
|
||||
type: "not-found";
|
||||
peerId: PeerID;
|
||||
}
|
||||
| {
|
||||
type: "found";
|
||||
peerId: PeerID;
|
||||
coValue: CoValueCore;
|
||||
};
|
||||
|
||||
export class CoValueState {
|
||||
constructor(public state: CoValueUnknownState | CoValueAvailableState) { }
|
||||
constructor(public state: CoValueUnknownState | CoValueAvailableState) {}
|
||||
|
||||
static Unknown(peersToWaitFor: Set<PeerID>) {
|
||||
return new CoValueState(new CoValueUnknownState(peersToWaitFor));
|
||||
|
||||
@@ -5,7 +5,11 @@ import { JsonObject } from "../jsonValue.js";
|
||||
import { RawBinaryCoStream, RawCoStream } from "./coStream.js";
|
||||
import { Encrypted, KeyID, KeySecret, Sealed } from "../crypto/crypto.js";
|
||||
import { AgentID, isAgentID } from "../ids.js";
|
||||
import { RawAccount, RawAccountID, ControlledAccountOrAgent } from "./account.js";
|
||||
import {
|
||||
RawAccount,
|
||||
RawAccountID,
|
||||
ControlledAccountOrAgent,
|
||||
} from "./account.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoValueUniqueness } from "../coValueCore.js";
|
||||
@@ -19,7 +23,9 @@ export type GroupShape = {
|
||||
[key: RawAccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
readKey?: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[
|
||||
revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`
|
||||
]: Sealed<KeySecret>;
|
||||
[revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
@@ -175,10 +181,12 @@ export class RawGroup<
|
||||
const newReadKey = this.core.crypto.newRandomKeySecret();
|
||||
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.core.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader",
|
||||
)._unsafeUnwrap({ withStackTrace: true });
|
||||
const reader = this.core.node
|
||||
.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader",
|
||||
)
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
this.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
@@ -256,7 +264,7 @@ export class RawGroup<
|
||||
init?: M["_shape"],
|
||||
meta?: M["headerMeta"],
|
||||
initPrivacy: "trusting" | "private" = "private",
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique()
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
|
||||
): M {
|
||||
const map = this.core.node
|
||||
.createCoValue({
|
||||
@@ -266,7 +274,7 @@ export class RawGroup<
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...uniqueness
|
||||
...uniqueness,
|
||||
})
|
||||
.getCurrentContent() as M;
|
||||
|
||||
@@ -289,7 +297,7 @@ export class RawGroup<
|
||||
init?: L["_item"][],
|
||||
meta?: L["headerMeta"],
|
||||
initPrivacy: "trusting" | "private" = "private",
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique()
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
|
||||
): L {
|
||||
const list = this.core.node
|
||||
.createCoValue({
|
||||
@@ -299,7 +307,7 @@ export class RawGroup<
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...uniqueness
|
||||
...uniqueness,
|
||||
})
|
||||
.getCurrentContent() as L;
|
||||
|
||||
@@ -313,7 +321,10 @@ export class RawGroup<
|
||||
}
|
||||
|
||||
/** @category 3. Value creation */
|
||||
createStream<C extends RawCoStream>(meta?: C["headerMeta"], uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique()): C {
|
||||
createStream<C extends RawCoStream>(
|
||||
meta?: C["headerMeta"],
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
|
||||
): C {
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
type: "costream",
|
||||
@@ -322,7 +333,7 @@ export class RawGroup<
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...uniqueness
|
||||
...uniqueness,
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
@@ -330,7 +341,7 @@ export class RawGroup<
|
||||
/** @category 3. Value creation */
|
||||
createBinaryStream<C extends RawBinaryCoStream>(
|
||||
meta: C["headerMeta"] = { type: "binary" },
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique()
|
||||
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
|
||||
): C {
|
||||
return this.core.node
|
||||
.createCoValue({
|
||||
@@ -340,7 +351,7 @@ export class RawGroup<
|
||||
group: this.id,
|
||||
},
|
||||
meta: meta,
|
||||
...uniqueness
|
||||
...uniqueness,
|
||||
})
|
||||
.getCurrentContent() as C;
|
||||
}
|
||||
|
||||
@@ -5,27 +5,41 @@ export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
|
||||
export type JsonArray = JsonValue[] | readonly JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue | undefined };
|
||||
|
||||
type AtLeastOne<T, U = {[K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
|
||||
type ExcludeEmpty<T> = T extends AtLeastOne<T> ? T : never;
|
||||
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> &
|
||||
U[keyof U];
|
||||
type ExcludeEmpty<T> = T extends AtLeastOne<T> ? T : never;
|
||||
|
||||
export type CoJsonValue<T> = JsonValue | CoJsonObjectWithIndex<T> | CoJsonArray<T>;
|
||||
export type CoJsonValue<T> =
|
||||
| JsonValue
|
||||
| CoJsonObjectWithIndex<T>
|
||||
| CoJsonArray<T>;
|
||||
export type CoJsonArray<T> = CoJsonValue<T>[] | readonly CoJsonValue<T>[];
|
||||
|
||||
/**
|
||||
* Since we are forcing Typescript to elaborate the indexes from the given type passing
|
||||
* non-object values to CoJsonObjectWithIndex will return an empty object
|
||||
* E.g.
|
||||
* E.g.
|
||||
* CoJsonObjectWithIndex<() => void> --> {}
|
||||
* CoJsonObjectWithIndex<RegExp> --> {}
|
||||
*
|
||||
*
|
||||
* Applying the ExcludeEmpty type here to make sure we don't accept functions or non-serializable values
|
||||
*/
|
||||
export type CoJsonObjectWithIndex<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue1L<T[K]> | undefined }>;
|
||||
export type CoJsonObjectWithIndex<T> = ExcludeEmpty<{
|
||||
[K in keyof T & string]: CoJsonValue1L<T[K]> | undefined;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Manually handling the nested interface types to not get into infinite recursion issues.
|
||||
*/
|
||||
export type CoJsonValue1L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue2L<T[K]> | undefined }> | JsonValue;
|
||||
export type CoJsonValue2L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue3L<T[K]> | undefined }> | JsonValue;
|
||||
export type CoJsonValue3L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue4L<T[K]> | undefined }> | JsonValue;
|
||||
export type CoJsonValue4L<T> = ExcludeEmpty<{ [K in keyof T & string]: JsonValue | undefined }> | JsonValue;
|
||||
export type CoJsonValue1L<T> =
|
||||
| ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue2L<T[K]> | undefined }>
|
||||
| JsonValue;
|
||||
export type CoJsonValue2L<T> =
|
||||
| ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue3L<T[K]> | undefined }>
|
||||
| JsonValue;
|
||||
export type CoJsonValue3L<T> =
|
||||
| ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue4L<T[K]> | undefined }>
|
||||
| JsonValue;
|
||||
export type CoJsonValue4L<T> =
|
||||
| ExcludeEmpty<{ [K in keyof T & string]: JsonValue | undefined }>
|
||||
| JsonValue;
|
||||
|
||||
@@ -3,10 +3,10 @@ import { type CoValueHeader } from "./coValueCore.js";
|
||||
/**
|
||||
* The priority of a `CoValue` determines how much priority is given
|
||||
* to its content messages.
|
||||
*
|
||||
*
|
||||
* The priority value is handled as weight in the weighed round robin algorithm
|
||||
* used to determine the order in which messages are sent.
|
||||
*
|
||||
*
|
||||
* Follows the HTTP urgency range and order:
|
||||
* - https://www.rfc-editor.org/rfc/rfc9218.html#name-urgency
|
||||
*/
|
||||
@@ -18,7 +18,9 @@ export const CO_VALUE_PRIORITY = {
|
||||
|
||||
export type CoValuePriority = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
|
||||
export function getPriorityFromHeader(header: CoValueHeader | undefined | boolean): CoValuePriority {
|
||||
export function getPriorityFromHeader(
|
||||
header: CoValueHeader | undefined | boolean,
|
||||
): CoValuePriority {
|
||||
if (typeof header === "boolean" || !header) {
|
||||
return CO_VALUE_PRIORITY.MEDIUM;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ describe("PeerState", () => {
|
||||
|
||||
test("should push outgoing message to peer", async () => {
|
||||
const { mockPeer, peerState } = setup();
|
||||
const message: SyncMessage = { action: "load", id: "co_ztest-id", header: false, sessions: {} };
|
||||
const message: SyncMessage = {
|
||||
action: "load",
|
||||
id: "co_ztest-id",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
await peerState.pushOutgoingMessage(message);
|
||||
expect(mockPeer.outgoing.push).toHaveBeenCalledWith(message);
|
||||
});
|
||||
@@ -54,21 +59,46 @@ describe("PeerState", () => {
|
||||
|
||||
test("should perform graceful shutdown", () => {
|
||||
const { mockPeer, peerState } = setup();
|
||||
const consoleSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "debug")
|
||||
.mockImplementation(() => {});
|
||||
peerState.gracefulShutdown();
|
||||
expect(mockPeer.outgoing.close).toHaveBeenCalled();
|
||||
expect(peerState.closed).toBe(true);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Gracefully closing", "test-peer");
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Gracefully closing",
|
||||
"test-peer",
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should schedule outgoing messages based on their priority", async () => {
|
||||
const { peerState } = setup();
|
||||
|
||||
const loadMessage: SyncMessage = { action: "load", id: "co_zhigh", header: false, sessions: {} };
|
||||
const contentMessageHigh: SyncMessage = { action: "content", id: "co_zhigh", new: {}, priority: CO_VALUE_PRIORITY.HIGH };
|
||||
const contentMessageMid: SyncMessage = { action: "content", id: "co_zmid", new: {}, priority: CO_VALUE_PRIORITY.MEDIUM };
|
||||
const contentMessageLow: SyncMessage = { action: "content", id: "co_zlow", new: {}, priority: CO_VALUE_PRIORITY.LOW };
|
||||
const loadMessage: SyncMessage = {
|
||||
action: "load",
|
||||
id: "co_zhigh",
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
const contentMessageHigh: SyncMessage = {
|
||||
action: "content",
|
||||
id: "co_zhigh",
|
||||
new: {},
|
||||
priority: CO_VALUE_PRIORITY.HIGH,
|
||||
};
|
||||
const contentMessageMid: SyncMessage = {
|
||||
action: "content",
|
||||
id: "co_zmid",
|
||||
new: {},
|
||||
priority: CO_VALUE_PRIORITY.MEDIUM,
|
||||
};
|
||||
const contentMessageLow: SyncMessage = {
|
||||
action: "content",
|
||||
id: "co_zlow",
|
||||
new: {},
|
||||
priority: CO_VALUE_PRIORITY.LOW,
|
||||
};
|
||||
|
||||
const promises = [
|
||||
peerState.pushOutgoingMessage(contentMessageLow),
|
||||
@@ -81,12 +111,24 @@ describe("PeerState", () => {
|
||||
|
||||
// The first message is pushed directly, the other three are queued because are waiting
|
||||
// for the first push to be completed.
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(1, contentMessageLow);
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
contentMessageLow,
|
||||
);
|
||||
|
||||
// Load message are managed as high priority messages and having the same priority as the content message
|
||||
// they follow the push order.
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(2, contentMessageHigh);
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(3, loadMessage);
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(4, contentMessageMid);
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
contentMessageHigh,
|
||||
);
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
loadMessage,
|
||||
);
|
||||
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
contentMessageMid,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ test("Can create account with one node, and then load it on another", async () =
|
||||
trace: true,
|
||||
peer1role: "server",
|
||||
peer2role: "client",
|
||||
})
|
||||
});
|
||||
|
||||
console.log("After connected peers");
|
||||
|
||||
|
||||
@@ -726,7 +726,11 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
crashOnClose: true,
|
||||
});
|
||||
|
||||
const node2 = new LocalNode(admin, Crypto.newRandomSessionID(admin.id), Crypto);
|
||||
const node2 = new LocalNode(
|
||||
admin,
|
||||
Crypto.newRandomSessionID(admin.id),
|
||||
Crypto,
|
||||
);
|
||||
|
||||
const [inRx2, inTx2] = newQueuePair();
|
||||
const [outRx2, outTx2] = newQueuePair();
|
||||
@@ -878,7 +882,11 @@ test("Can sync a coValue through a server to another client", async () => {
|
||||
client1.syncManager.addPeer(serverAsPeerForClient1);
|
||||
server.syncManager.addPeer(client1AsPeer);
|
||||
|
||||
const client2 = new LocalNode(admin, Crypto.newRandomSessionID(admin.id), Crypto);
|
||||
const client2 = new LocalNode(
|
||||
admin,
|
||||
Crypto.newRandomSessionID(admin.id),
|
||||
Crypto,
|
||||
);
|
||||
|
||||
const [serverAsPeerForClient2, client2AsPeer] = connectedPeers(
|
||||
"serverFor2",
|
||||
@@ -926,7 +934,11 @@ test("Can sync a coValue with private transactions through a server to another c
|
||||
client1.syncManager.addPeer(serverAsPeer);
|
||||
server.syncManager.addPeer(client1AsPeer);
|
||||
|
||||
const client2 = new LocalNode(admin, client1.crypto.newRandomSessionID(admin.id), Crypto);
|
||||
const client2 = new LocalNode(
|
||||
admin,
|
||||
client1.crypto.newRandomSessionID(admin.id),
|
||||
Crypto,
|
||||
);
|
||||
|
||||
const [serverAsOtherPeer, client2AsPeer] = connectedPeers(
|
||||
"server",
|
||||
@@ -1074,7 +1086,11 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
const map = group.createMap();
|
||||
map.set("hello", "world", "trusting");
|
||||
|
||||
const node2 = new LocalNode(admin, Crypto.newRandomSessionID(admin.id), Crypto);
|
||||
const node2 = new LocalNode(
|
||||
admin,
|
||||
Crypto.newRandomSessionID(admin.id),
|
||||
Crypto,
|
||||
);
|
||||
|
||||
const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2", {
|
||||
peer1role: "server",
|
||||
|
||||
@@ -2,24 +2,27 @@ import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
|
||||
import { AgentSecret } from "cojson";
|
||||
|
||||
export type MinimalClerkClient = {
|
||||
user: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsafeMetadata: Record<string, any>;
|
||||
fullName: string | null;
|
||||
username: string | null;
|
||||
id: string;
|
||||
update: (args: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsafeMetadata: Record<string, any>;
|
||||
}) => Promise<unknown>;
|
||||
} | null | undefined;
|
||||
user:
|
||||
| {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsafeMetadata: Record<string, any>;
|
||||
fullName: string | null;
|
||||
username: string | null;
|
||||
id: string;
|
||||
update: (args: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
unsafeMetadata: Record<string, any>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
};
|
||||
|
||||
export class BrowserClerkAuth implements AuthMethod {
|
||||
constructor(
|
||||
public driver: BrowserClerkAuth.Driver,
|
||||
private readonly clerkClient: MinimalClerkClient
|
||||
private readonly clerkClient: MinimalClerkClient,
|
||||
) {}
|
||||
|
||||
async start(): Promise<AuthResult> {
|
||||
|
||||
@@ -55,11 +55,11 @@ export class BrowserDemoAuth implements AuthMethod {
|
||||
this.driver.onSignedIn({ logOut });
|
||||
},
|
||||
onError: (error: string | Error) => {
|
||||
this.driver.onError(error)
|
||||
this.driver.onError(error);
|
||||
},
|
||||
logOut: () => {
|
||||
delete localStorage[localStorageKey];
|
||||
}
|
||||
},
|
||||
} satisfies AuthResult;
|
||||
} else {
|
||||
return new Promise<AuthResult>((resolve) => {
|
||||
@@ -96,11 +96,11 @@ export class BrowserDemoAuth implements AuthMethod {
|
||||
this.driver.onSignedIn({ logOut });
|
||||
},
|
||||
onError: (error: string | Error) => {
|
||||
this.driver.onError(error)
|
||||
this.driver.onError(error);
|
||||
},
|
||||
logOut: () => {
|
||||
delete localStorage[localStorageKey];
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
existingUsers:
|
||||
@@ -126,11 +126,11 @@ export class BrowserDemoAuth implements AuthMethod {
|
||||
this.driver.onSignedIn({ logOut });
|
||||
},
|
||||
onError: (error: string | Error) => {
|
||||
this.driver.onError(error)
|
||||
this.driver.onError(error);
|
||||
},
|
||||
logOut: () => {
|
||||
delete localStorage[localStorageKey];
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { AgentSecret, cojsonInternals, CryptoProvider } from "cojson";
|
||||
import {
|
||||
Account,
|
||||
AuthMethod,
|
||||
AuthResult,
|
||||
ID,
|
||||
} from "jazz-tools";
|
||||
import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
|
||||
import * as bip39 from "@scure/bip39";
|
||||
|
||||
type LocalStorageData = {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BrowserClerkAuth, type MinimalClerkClient } from "jazz-browser-auth-clerk";
|
||||
import {
|
||||
BrowserClerkAuth,
|
||||
type MinimalClerkClient,
|
||||
} from "jazz-browser-auth-clerk";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export function useJazzClerkAuth(clerk: MinimalClerkClient & {
|
||||
signOut: () => Promise<unknown>;
|
||||
}) {
|
||||
export function useJazzClerkAuth(
|
||||
clerk: MinimalClerkClient & {
|
||||
signOut: () => Promise<unknown>;
|
||||
},
|
||||
) {
|
||||
const [state, setState] = useState<{ errors: string[] }>({ errors: [] });
|
||||
|
||||
const authMethod = useMemo(() => {
|
||||
|
||||
@@ -99,7 +99,7 @@ export const startSync = Command.make(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
websocket: ws as any, // TODO: fix types
|
||||
expectPings: false,
|
||||
batchingByDefault: false
|
||||
batchingByDefault: false,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ export function createCoValueObservable<V extends CoValue, Depth>() {
|
||||
onUnavailable,
|
||||
);
|
||||
|
||||
return unsubscribe
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
const observable = {
|
||||
|
||||
@@ -21,7 +21,11 @@ export { ImageDefinition } from "./internal.js";
|
||||
export { CoValueBase, type CoValueClass } from "./internal.js";
|
||||
export type { DepthsIn, DeeplyLoaded } from "./internal.js";
|
||||
|
||||
export { loadCoValue, subscribeToCoValue, createCoValueObservable } from "./internal.js";
|
||||
export {
|
||||
loadCoValue,
|
||||
subscribeToCoValue,
|
||||
createCoValueObservable,
|
||||
} from "./internal.js";
|
||||
|
||||
export {
|
||||
type AuthMethod,
|
||||
|
||||
@@ -53,9 +53,7 @@ export class Ref<out V extends CoValue> {
|
||||
"node" in this.controlledAccount
|
||||
? this.controlledAccount.node
|
||||
: this.controlledAccount._raw.core.node;
|
||||
const raw = await node.load(
|
||||
this.id as unknown as CoID<RawCoValue>,
|
||||
);
|
||||
const raw = await node.load(this.id as unknown as CoID<RawCoValue>);
|
||||
if (raw === "unavailable") {
|
||||
return "unavailable";
|
||||
} else {
|
||||
|
||||
@@ -173,7 +173,7 @@ export type SchemaFor<Field> = NonNullable<Field> extends CoValue
|
||||
export type Encoder<V> = {
|
||||
encode: (value: V) => JsonValue;
|
||||
decode: (value: JsonValue) => V;
|
||||
}
|
||||
};
|
||||
export type OptionalEncoder<V> =
|
||||
| Encoder<V>
|
||||
| {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("co.json TypeScript validation", () => {
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
class ValidNestedMap extends CoMap {
|
||||
data = co.json<NestedType>();
|
||||
@@ -41,7 +41,7 @@ describe("co.json TypeScript validation", () => {
|
||||
type TypeWithOptional = {
|
||||
value: string;
|
||||
optional?: string | null;
|
||||
}
|
||||
};
|
||||
|
||||
class ValidMap extends CoMap {
|
||||
data = co.json<TypeWithOptional>();
|
||||
|
||||
@@ -170,18 +170,20 @@ describe("subscribeToCoValue", () => {
|
||||
const { me, meOnSecondPeer } = await setupAccount();
|
||||
|
||||
const chatRoom = createChatRoom(me, "General");
|
||||
const message = createMessage(me, "Hello Luigi, are you ready to save the princess?");
|
||||
const message = createMessage(
|
||||
me,
|
||||
"Hello Luigi, are you ready to save the princess?",
|
||||
);
|
||||
chatRoom.messages?.push(message);
|
||||
|
||||
const updateFn = vi.fn()
|
||||
const updateFn = vi.fn();
|
||||
|
||||
const unsubscribe = subscribeToCoValue(
|
||||
ChatRoom,
|
||||
chatRoom.id,
|
||||
meOnSecondPeer,
|
||||
{
|
||||
messages: [{
|
||||
}],
|
||||
messages: [{}],
|
||||
},
|
||||
updateFn,
|
||||
);
|
||||
@@ -200,30 +202,37 @@ describe("subscribeToCoValue", () => {
|
||||
await waitFor(() => {
|
||||
expect(updateFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
const lastValue = updateFn.mock.lastCall[0];
|
||||
expect(lastValue?.messages?.[0]?.text).toBe("Nevermind, she was gone to the supermarket");
|
||||
expect(lastValue?.messages?.[0]?.text).toBe(
|
||||
"Nevermind, she was gone to the supermarket",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle the updates as immutable changes", async () => {
|
||||
const { me, meOnSecondPeer } = await setupAccount();
|
||||
|
||||
const chatRoom = createChatRoom(me, "General");
|
||||
const message = createMessage(me, "Hello Luigi, are you ready to save the princess?");
|
||||
const message = createMessage(
|
||||
me,
|
||||
"Hello Luigi, are you ready to save the princess?",
|
||||
);
|
||||
const message2 = createMessage(me, "Let's go!");
|
||||
chatRoom.messages?.push(message);
|
||||
chatRoom.messages?.push(message2);
|
||||
|
||||
const updateFn = vi.fn()
|
||||
const updateFn = vi.fn();
|
||||
|
||||
const unsubscribe = subscribeToCoValue(
|
||||
ChatRoom,
|
||||
chatRoom.id,
|
||||
meOnSecondPeer,
|
||||
{
|
||||
messages: [{
|
||||
reactions: [],
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
reactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
updateFn,
|
||||
);
|
||||
@@ -249,12 +258,14 @@ describe("subscribeToCoValue", () => {
|
||||
await waitFor(() => {
|
||||
expect(updateFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
const lastValue = updateFn.mock.lastCall[0];
|
||||
expect(lastValue).not.toBe(initialValue);
|
||||
expect(lastValue.messages).not.toBe(initialMessagesList);
|
||||
expect(lastValue.messages[0]).not.toBe(initialMessage1);
|
||||
expect(lastValue.messages[0].reactions).not.toBe(initialMessageReactions);
|
||||
expect(lastValue.messages[0].reactions).not.toBe(
|
||||
initialMessageReactions,
|
||||
);
|
||||
|
||||
// This shouldn't change
|
||||
expect(lastValue.messages[1]).toBe(initialMessage2);
|
||||
@@ -270,21 +281,26 @@ describe("subscribeToCoValue", () => {
|
||||
const { me, meOnSecondPeer } = await setupAccount();
|
||||
|
||||
const chatRoom = createChatRoom(me, "General");
|
||||
const message = createMessage(me, "Hello Luigi, are you ready to save the princess?");
|
||||
const message = createMessage(
|
||||
me,
|
||||
"Hello Luigi, are you ready to save the princess?",
|
||||
);
|
||||
const message2 = createMessage(me, "Let's go!");
|
||||
chatRoom.messages?.push(message);
|
||||
chatRoom.messages?.push(message2);
|
||||
|
||||
const updateFn = vi.fn()
|
||||
const updateFn = vi.fn();
|
||||
|
||||
const unsubscribe = subscribeToCoValue(
|
||||
ChatRoom,
|
||||
chatRoom.id,
|
||||
meOnSecondPeer,
|
||||
{
|
||||
messages: [{
|
||||
reactions: [],
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
reactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
updateFn,
|
||||
);
|
||||
@@ -306,19 +322,18 @@ describe("subscribeToCoValue", () => {
|
||||
await waitFor(() => {
|
||||
expect(updateFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
const lastValue = updateFn.mock.lastCall[0];
|
||||
expect(lastValue).not.toBe(initialValue);
|
||||
expect(lastValue.name).toBe("Me and Luigi");
|
||||
expect(initialValue.name).toBe("General");
|
||||
|
||||
|
||||
expect(lastValue.messages).toBe(initialValue.messages);
|
||||
expect(lastValue.messages[0]).toBe(initialValue.messages[0]);
|
||||
expect(lastValue.messages[1]).toBe(initialValue.messages[1]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function waitFor(callback: () => boolean | void) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const checkPassed = () => {
|
||||
|
||||
Reference in New Issue
Block a user