Compare commits

...

5 Commits

Author SHA1 Message Date
Benjamin S. Leveritt
18a3650ab0 Add pnpm to lint workflow 2024-10-21 18:04:09 +01:00
Benjamin S. Leveritt
936db45abf Fix run cmd 2024-10-21 17:57:36 +01:00
Benjamin S. Leveritt
6a764b5248 Add prettier format to workspace linting rules 2024-10-21 17:55:59 +01:00
Benjamin S. Leveritt
84a1b5f893 Roll formatter over codebase 2024-10-21 17:45:51 +01:00
Benjamin S. Leveritt
4c3b0d3a27 Add root prettier config 2024-10-21 17:33:09 +01:00
49 changed files with 942 additions and 732 deletions

11
.editorconfig Normal file
View 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

View File

@@ -13,9 +13,29 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version-file: '.node-version' 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 - name: Run sherif
run: npx sherif@1.0.0 run: npx sherif@1.0.0
- name: Run formatter
run: pnpm format

1
.prettierrc Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -1,9 +0,0 @@
/** @type {import("prettier").Config} */
const config = {
trailingComma: "all",
tabWidth: 4,
semi: true,
singleQuote: false,
};
export default config;

View File

@@ -88,7 +88,7 @@ export class MusicaAccount extends Account {
profile = co.ref(Profile); profile = co.ref(Profile);
root = co.ref(MusicaAccountRoot); root = co.ref(MusicaAccountRoot);
/** /**
* The account migration is run on account creation and on every log-in. * 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. * You can use it to set up the account root and any other initial CoValues you need.
*/ */

View File

@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { createHashRouter, RouterProvider } from "react-router-dom"; 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 { useMediaPlayer } from "./5_useMediaPlayer";
import { HomePage } from "./3_HomePage"; import { HomePage } from "./3_HomePage";
import { InvitePage } from "./6_InvitePage"; import { InvitePage } from "./6_InvitePage";

View File

@@ -5,12 +5,16 @@ import { usePlayState } from "./lib/audio/usePlayState";
import { SidePanel } from "./components/SidePanel"; import { SidePanel } from "./components/SidePanel";
import { FileUploadButton } from "./components/FileUploadButton"; import { FileUploadButton } from "./components/FileUploadButton";
import { Button } from "./components/ui/button"; 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 { useNavigate, useParams } from "react-router";
import { ID } from "jazz-tools"; import { ID } from "jazz-tools";
import { Playlist } from "./1_schema"; import { Playlist } from "./1_schema";
import { createInviteLink } from "jazz-react"; import { createInviteLink } from "jazz-react";
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast";
export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) { export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
/** /**
@@ -27,7 +31,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const navigate = useNavigate(); const navigate = useNavigate();
const playState = usePlayState(); const playState = usePlayState();
const isPlaying = playState.value === "play"; const isPlaying = playState.value === "play";
const { toast } = useToast() const { toast } = useToast();
async function handleFileLoad(files: FileList) { async function handleFileLoad(files: FileList) {
if (!me) return; if (!me) return;
@@ -127,10 +131,15 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
} }
isPlaying={ isPlaying={
mediaPlayer.activeTrackId === mediaPlayer.activeTrackId ===
track.id && isActivePlaylist && isPlaying track.id &&
isActivePlaylist &&
isPlaying
} }
onClick={() => { onClick={() => {
mediaPlayer.setActiveTrack(track, playlist); mediaPlayer.setActiveTrack(
track,
playlist,
);
}} }}
showAddToPlaylist={isRootPlaylist} showAddToPlaylist={isRootPlaylist}
/> />

View File

@@ -92,7 +92,8 @@ export async function addTrackToPlaylist(
) { ) {
if (!account) return; 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) * Since musicTracks are created as private values (see uploadMusicTracks)
@@ -137,10 +138,13 @@ export async function updateMusicTrackTitle(track: MusicTrack, title: string) {
track.title = title; 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; me.root!.activePlaylist = playlist ?? me.root!.rootPlaylist;
} }
export async function updateActiveTrack(track: MusicTrack, me: MusicaAccount) { export async function updateActiveTrack(track: MusicTrack, me: MusicaAccount) {
me.root!.activeTrack = track; me.root!.activeTrack = track;
} }

View File

@@ -15,7 +15,7 @@ export function useMediaPlayer() {
const [loading, setLoading] = useState<ID<MusicTrack> | null>(null); 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 // Reference used to avoid out-of-order track loads
const lastLoadedTrackId = useRef<ID<MusicTrack> | null>(null); const lastLoadedTrackId = useRef<ID<MusicTrack> | null>(null);
@@ -71,10 +71,7 @@ export function useMediaPlayer() {
async function setActiveTrack(track: MusicTrack, playlist?: Playlist) { async function setActiveTrack(track: MusicTrack, playlist?: Playlist) {
if (!me?.root) return; if (!me?.root) return;
if ( if (activeTrackId === track.id && lastLoadedTrackId.current !== null) {
activeTrackId === track.id &&
lastLoadedTrackId.current !== null
) {
playState.toggle(); playState.toggle();
return; return;
} }

View File

@@ -30,7 +30,7 @@ export function MusicTrackRow({
function handleTrackTitleChange(evt: ChangeEvent<HTMLInputElement>) { function handleTrackTitleChange(evt: ChangeEvent<HTMLInputElement>) {
if (!track) return; if (!track) return;
updateMusicTrackTitle(track, evt.target.value); updateMusicTrackTitle(track, evt.target.value);
} }

View File

@@ -25,7 +25,7 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
}); });
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, { const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
waveform: {} waveform: {},
}); });
if (!activeTrack) return null; if (!activeTrack) return null;
@@ -60,14 +60,16 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
</button> </button>
</div> </div>
</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} /> <Waveform track={activeTrack} height={30} />
</div> </div>
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]"> <div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
<h4 className="font-medium text-blue-800"> <h4 className="font-medium text-blue-800">
{activeTrackTitle} {activeTrackTitle}
</h4> </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> </div>
</footer> </footer>
); );

View File

@@ -10,9 +10,7 @@ export function SidePanel() {
}, },
}); });
function handleAllTracksClick( function handleAllTracksClick(evt: React.MouseEvent<HTMLAnchorElement>) {
evt: React.MouseEvent<HTMLAnchorElement>,
) {
evt.preventDefault(); evt.preventDefault();
navigate(`/`); navigate(`/`);
} }

View File

@@ -1,57 +1,58 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
destructive: "bg-primary text-primary-foreground hover:bg-primary/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive:
outline: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", outline:
secondary: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary:
ghost: "hover:bg-accent hover:text-accent-foreground", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
link: "text-primary underline-offset-4 hover:underline", ghost: "hover:bg-accent hover:text-accent-foreground",
}, link: "text-primary underline-offset-4 hover:underline",
size: { },
default: "h-10 px-4 py-2", size: {
sm: "h-9 rounded-md px-3", default: "h-10 px-4 py-2",
lg: "h-11 rounded-md px-8", sm: "h-9 rounded-md px-3",
icon: "h-10 w-10", 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 export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Button.displayName = "Button" Button.displayName = "Button";
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,198 +1,201 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react" 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< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( 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", "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", inset && "pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => ( >(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", "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", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => ( >(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( 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", "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 className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn(
{...props} "ml-auto text-xs tracking-widest opacity-60",
/> className,
) )}
} {...props}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };

View File

@@ -1,127 +1,127 @@
import * as React from "react" import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast" import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react" 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< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( 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]", "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 className,
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: "border bg-background text-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
},
defaultVariants: {
variant: "default",
},
}, },
defaultVariants: { );
variant: "default",
},
}
)
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
ref={ref} ref={ref}
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( 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", "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 className,
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( 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", "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 className,
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold", className)} className={cn("text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName 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 { export {
type ToastProps, type ToastProps,
type ToastActionElement, type ToastActionElement,
ToastProvider, ToastProvider,
ToastViewport, ToastViewport,
Toast, Toast,
ToastTitle, ToastTitle,
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };

View File

@@ -1,33 +1,41 @@
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast";
import { import {
Toast, Toast,
ToastClose, ToastClose,
ToastDescription, ToastDescription,
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from "@/components/ui/toast" } from "@/components/ui/toast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({
return ( id,
<Toast key={id} {...props}> title,
<div className="grid gap-1"> description,
{title && <ToastTitle>{title}</ToastTitle>} action,
{description && ( ...props
<ToastDescription>{description}</ToastDescription> }) {
)} return (
</div> <Toast key={id} {...props}>
{action} <div className="grid gap-1">
<ToastClose /> {title && <ToastTitle>{title}</ToastTitle>}
</Toast> {description && (
) <ToastDescription>
})} {description}
<ToastViewport /> </ToastDescription>
</ToastProvider> )}
) </div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
} }

View File

@@ -1,191 +1,189 @@
import * as React from "react" import * as React from "react";
import type { import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} };
interface State { 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) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case "ADD_TOAST":
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t,
), ),
} };
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
open: false, open: false,
} }
: t : t,
), ),
} };
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
} }
} case "REMOVE_TOAST":
return { if (action.toastId === undefined) {
...state, return {
toasts: state.toasts.filter((t) => t.id !== action.toastId), ...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) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { 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({ dispatch({
type: "UPDATE_TOAST", type: "ADD_TOAST",
toast: { ...props, id }, toast: {
}) ...props,
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
dispatch({ return {
type: "ADD_TOAST", id: id,
toast: { dismiss,
...props, update,
id, };
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) =>
} dispatch({ type: "DISMISS_TOAST", toastId }),
};
} }
export { useToast, toast } export { useToast, toast };

View File

@@ -2,31 +2,34 @@ import { useLayoutEffect, useState } from "react";
import { useAudioManager } from "./AudioManager"; import { useAudioManager } from "./AudioManager";
export function usePlayerCurrentTime() { export function usePlayerCurrentTime() {
const audioManager = useAudioManager(); const audioManager = useAudioManager();
const [value, setValue] = useState<number>(0); const [value, setValue] = useState<number>(0);
useLayoutEffect(() => { useLayoutEffect(() => {
setValue(audioManager.mediaElement.currentTime); setValue(audioManager.mediaElement.currentTime);
const onTimeUpdate = () => { const onTimeUpdate = () => {
setValue(audioManager.mediaElement.currentTime); setValue(audioManager.mediaElement.currentTime);
}; };
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate); audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
return () => { return () => {
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate); audioManager.mediaElement.removeEventListener(
}; "timeupdate",
}, [audioManager]); onTimeUpdate,
);
};
}, [audioManager]);
function setCurrentTime(time: number) { function setCurrentTime(time: number) {
if (audioManager.mediaElement.paused) audioManager.play(); if (audioManager.mediaElement.paused) audioManager.play();
audioManager.mediaElement.currentTime = time; audioManager.mediaElement.currentTime = time;
} }
return { return {
value, value,
setValue: setCurrentTime, setValue: setCurrentTime,
}; };
} }

View File

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

View File

@@ -1,6 +1,11 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; 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 "./index.css";
import { createJazzReactApp, DemoAuthBasicUI, useDemoAuth } from "jazz-react"; import { createJazzReactApp, DemoAuthBasicUI, useDemoAuth } from "jazz-react";
@@ -38,10 +43,7 @@ function JazzAndAuth({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
<Jazz.Provider <Jazz.Provider auth={auth} peer={peer}>
auth={auth}
peer={peer}
>
{children} {children}
</Jazz.Provider> </Jazz.Provider>
<DemoAuthBasicUI appName={appName} state={authState} /> <DemoAuthBasicUI appName={appName} state={authState} />
@@ -96,9 +98,7 @@ export default function App() {
<RouterProvider router={router} /> <RouterProvider router={router} />
<Button <Button
onClick={() => onClick={() => router.navigate("/").then(() => logOut())}
router.navigate("/").then(() => logOut())
}
variant="outline" variant="outline"
> >
Log out Log out
@@ -114,7 +114,7 @@ function AcceptInvite() {
onAccept: (petPostID) => navigate("/pet/" + petPostID), onAccept: (petPostID) => navigate("/pet/" + petPostID),
}); });
return <p>Accepting invite...</p> return <p>Accepting invite...</p>;
} }
export function PostOverview() { export function PostOverview() {

View File

@@ -6,7 +6,11 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import "./index.css"; import "./index.css";
import { createJazzReactApp, PasskeyAuthBasicUI, usePasskeyAuth } from "jazz-react"; import {
createJazzReactApp,
PasskeyAuthBasicUI,
usePasskeyAuth,
} from "jazz-react";
import { import {
Button, Button,
@@ -62,8 +66,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<App /> <App />
</JazzAndAuth> </JazzAndAuth>
</div> </div>
</ThemeProvider>, </ThemeProvider>
</React.StrictMode> ,
</React.StrictMode>,
); );
/** /**
@@ -105,9 +110,7 @@ export default function App() {
<RouterProvider router={router} /> <RouterProvider router={router} />
<Button <Button
onClick={() => onClick={() => router.navigate("/").then(logOut)}
router.navigate("/").then(logOut)
}
variant="outline" variant="outline"
> >
Log out Log out

View File

@@ -76,7 +76,9 @@ function RenderPackageChild({
<div key={i} id={child.name} className="not-prose mt-4"> <div key={i} id={child.name} className="not-prose mt-4">
{ {
<Highlight hide={[0, 2]}> <Highlight hide={[0, 2]}>
{`function \n${printSimpleSignature(child, signature) + ":"}\n {}`} {`function \n${
printSimpleSignature(child, signature) + ":"
}\n {}`}
</Highlight> </Highlight>
}{" "} }{" "}
<span className="opacity-75 text-xs pl-1"> <span className="opacity-75 text-xs pl-1">
@@ -123,9 +125,9 @@ function RenderTypeAlias({
<Highlight>{`type ${child.name}`}</Highlight> <Highlight>{`type ${child.name}`}</Highlight>
</h3> </h3>
<p className="not-prose text-sm ml-4"> <p className="not-prose text-sm ml-4">
<Highlight>{`type ${child.name}${typeParameters?.length && `<${typeParameters?.join(", ")}>`} = ${printType( <Highlight>{`type ${child.name}${
child.type, typeParameters?.length && `<${typeParameters?.join(", ")}>`
)}`}</Highlight> } = ${printType(child.type)}`}</Highlight>
</p> </p>
<div className="ml-4 mt-2 flex-[3]"> <div className="ml-4 mt-2 flex-[3]">
<DocComment> <DocComment>

View File

@@ -94,7 +94,12 @@ export class SQLiteStorage {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers( const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName, localNodeName,
"storage", "storage",
{ peer1role: "client", peer2role: "server", trace, crashOnClose: true }, {
peer1role: "client",
peer2role: "server",
trace,
crashOnClose: true,
},
); );
await SQLiteStorage.open( await SQLiteStorage.open(
@@ -427,7 +432,9 @@ export class SQLiteStorage {
), ),
) )
.filter( .filter(
(accountID): accountID is RawAccountID => (
accountID,
): accountID is RawAccountID =>
cojsonInternals.isAccountID( cojsonInternals.isAccountID(
accountID, accountID,
) && accountID !== theirKnown.id, ) && accountID !== theirKnown.id,

View File

@@ -4,10 +4,10 @@ import { addMessageToBacklog } from "./serialization.js";
export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000; export const MAX_OUTGOING_MESSAGES_CHUNK_BYTES = 25_000;
export class BatchedOutgoingMessages { export class BatchedOutgoingMessages {
private backlog: string = ''; private backlog: string = "";
private timeout: ReturnType<typeof setTimeout> | null = null; private timeout: ReturnType<typeof setTimeout> | null = null;
constructor(private send: (messages: string) => void) { } constructor(private send: (messages: string) => void) {}
push(msg: SyncMessage) { push(msg: SyncMessage) {
const payload = addMessageToBacklog(this.backlog, msg); const payload = addMessageToBacklog(this.backlog, msg);
@@ -16,12 +16,13 @@ export class BatchedOutgoingMessages {
clearTimeout(this.timeout); 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; const backlogExists = this.backlog.length > 0;
if (maxChunkSizeReached && backlogExists) { if (maxChunkSizeReached && backlogExists) {
this.sendMessagesInBulk(); this.sendMessagesInBulk();
this.backlog = addMessageToBacklog('', msg); this.backlog = addMessageToBacklog("", msg);
this.timeout = setTimeout(() => { this.timeout = setTimeout(() => {
this.sendMessagesInBulk(); this.sendMessagesInBulk();
}, 0); }, 0);
@@ -38,7 +39,7 @@ export class BatchedOutgoingMessages {
sendMessagesInBulk() { sendMessagesInBulk() {
this.send(this.backlog); this.send(this.backlog);
this.backlog = ''; this.backlog = "";
} }
close() { close() {

View File

@@ -47,7 +47,11 @@ export function createWebSocketPeer({
const result = deserializeMessages(event.data as string); const result = deserializeMessages(event.data as string);
if (!result.ok) { if (!result.ok) {
console.error("Error while deserializing messages", event.data, result.error); console.error(
"Error while deserializing messages",
event.data,
result.error,
);
return; return;
} }
@@ -90,9 +94,7 @@ export function createWebSocketPeer({
const outgoingMessages = new BatchedOutgoingMessages((messages) => { const outgoingMessages = new BatchedOutgoingMessages((messages) => {
if (websocket.readyState === 1) { if (websocket.readyState === 1) {
websocket.send( websocket.send(messages);
messages,
);
} }
}); });

View File

@@ -8,11 +8,13 @@ export function addMessageToBacklog(backlog: string, message: SyncMessage) {
return `${backlog}\n${JSON.stringify(message)}`; return `${backlog}\n${JSON.stringify(message)}`;
} }
export function deserializeMessages(messages: string) { export function deserializeMessages(messages: string) {
try { try {
return { return {
ok: true, 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; } as const;
} catch (e) { } catch (e) {
console.error("Error while deserializing messages", e); console.error("Error while deserializing messages", e);

View File

@@ -1,15 +1,18 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; 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 { SyncMessage } from "cojson";
import { CoValueKnownState } from "cojson/src/sync.js"; import { CoValueKnownState } from "cojson/src/sync.js";
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
}) });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}) });
describe("BatchedOutgoingMessages", () => { describe("BatchedOutgoingMessages", () => {
function setup() { function setup() {
@@ -20,8 +23,18 @@ describe("BatchedOutgoingMessages", () => {
test("should batch messages and send them after a timeout", () => { test("should batch messages and send them after a timeout", () => {
const { sendMock, batchedMessages } = setup(); const { sendMock, batchedMessages } = setup();
const message1: SyncMessage = { action: "known", id: "co_z1", header: false, sessions: {} }; const message1: SyncMessage = {
const message2: SyncMessage = { action: "known", id: "co_z2", header: false, sessions: {} }; action: "known",
id: "co_z1",
header: false,
sessions: {},
};
const message2: SyncMessage = {
action: "known",
id: "co_z2",
header: false,
sessions: {},
};
batchedMessages.push(message1); batchedMessages.push(message1);
batchedMessages.push(message2); batchedMessages.push(message2);
@@ -32,7 +45,7 @@ describe("BatchedOutgoingMessages", () => {
expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith( 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, header: false,
sessions: { sessions: {
// Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES) payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
} as CoValueKnownState['sessions'], } as CoValueKnownState["sessions"],
}; };
batchedMessages.push(largeMessage); batchedMessages.push(largeMessage);
@@ -57,15 +69,20 @@ describe("BatchedOutgoingMessages", () => {
test("should send accumulated messages before a large message", () => { test("should send accumulated messages before a large message", () => {
const { sendMock, batchedMessages } = setup(); 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 = { const largeMessage: SyncMessage = {
action: "known", action: "known",
id: "co_z_large", id: "co_z_large",
header: false, header: false,
sessions: { sessions: {
// Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES // Add a large payload to exceed MAX_OUTGOING_MESSAGES_CHUNK_BYTES
payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES) payload: "x".repeat(MAX_OUTGOING_MESSAGES_CHUNK_BYTES),
} as CoValueKnownState['sessions'], } as CoValueKnownState["sessions"],
}; };
batchedMessages.push(smallMessage); batchedMessages.push(smallMessage);
@@ -74,13 +91,24 @@ describe("BatchedOutgoingMessages", () => {
vi.runAllTimers(); vi.runAllTimers();
expect(sendMock).toHaveBeenCalledTimes(2); expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock).toHaveBeenNthCalledWith(1, JSON.stringify(smallMessage)); expect(sendMock).toHaveBeenNthCalledWith(
expect(sendMock).toHaveBeenNthCalledWith(2, JSON.stringify(largeMessage)); 1,
JSON.stringify(smallMessage),
);
expect(sendMock).toHaveBeenNthCalledWith(
2,
JSON.stringify(largeMessage),
);
}); });
test("should send remaining messages on close", () => { test("should send remaining messages on close", () => {
const { sendMock, batchedMessages } = setup(); 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); batchedMessages.push(message);
expect(sendMock).not.toHaveBeenCalled(); expect(sendMock).not.toHaveBeenCalled();
@@ -93,13 +121,23 @@ describe("BatchedOutgoingMessages", () => {
test("should clear timeout when pushing new messages", () => { test("should clear timeout when pushing new messages", () => {
const { sendMock, batchedMessages } = setup(); const { sendMock, batchedMessages } = setup();
const message1: SyncMessage = { action: "known", id: "co_z1", header: false, sessions: {} }; const message1: SyncMessage = {
const message2: SyncMessage = { action: "known", id: "co_z2", header: false, sessions: {} }; action: "known",
id: "co_z1",
header: false,
sessions: {},
};
const message2: SyncMessage = {
action: "known",
id: "co_z2",
header: false,
sessions: {},
};
batchedMessages.push(message1); batchedMessages.push(message1);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
batchedMessages.push(message2); batchedMessages.push(message2);
expect(clearTimeoutSpy).toHaveBeenCalled(); expect(clearTimeoutSpy).toHaveBeenCalled();
@@ -108,7 +146,7 @@ describe("BatchedOutgoingMessages", () => {
expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
expect(sendMock).toHaveBeenCalledWith( expect(sendMock).toHaveBeenCalledWith(
`${JSON.stringify(message1)}\n${JSON.stringify(message2)}` `${JSON.stringify(message1)}\n${JSON.stringify(message2)}`,
); );
}); });
}); });

View File

@@ -132,12 +132,14 @@ describe("createWebSocketPeer", () => {
expect(mockWebSocket.send).toHaveBeenCalled(); expect(mockWebSocket.send).toHaveBeenCalled();
}); });
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message1)); expect(mockWebSocket.send).toHaveBeenCalledWith(
JSON.stringify(message1),
);
mockWebSocket.send.mockClear(); mockWebSocket.send.mockClear();
void peer.outgoing.push(message2); 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(); expect(mockWebSocket.send).not.toHaveBeenCalled();
}); });
@@ -233,7 +235,10 @@ describe("createWebSocketPeer", () => {
const stream: SyncMessage[] = []; 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); stream.push(message1);
void peer.outgoing.push(message1); void peer.outgoing.push(message1);
} }
@@ -277,7 +282,10 @@ describe("createWebSocketPeer", () => {
const stream: SyncMessage[] = []; 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); stream.push(message1);
void peer.outgoing.push(message1); void peer.outgoing.push(message1);
} }
@@ -356,16 +364,12 @@ describe("createWebSocketPeer", () => {
messageHandler?.( messageHandler?.(
new MessageEvent("message", { new MessageEvent("message", {
data: Array.from( data: Array.from({ length: 5 }, () => message1)
{ length: 5 },
() => message1,
)
.map((msg) => JSON.stringify(msg)) .map((msg) => JSON.stringify(msg))
.join("\n"), .join("\n"),
}), }),
); );
const message2: SyncMessage = { const message2: SyncMessage = {
action: "content", action: "content",
id: "co_zlow", id: "co_zlow",
@@ -407,7 +411,6 @@ describe("createWebSocketPeer", () => {
}), }),
); );
const message2: SyncMessage = { const message2: SyncMessage = {
action: "content", action: "content",
id: "co_zlow", id: "co_zlow",

View File

@@ -1,7 +1,6 @@
export interface WebsocketEvents { export interface WebsocketEvents {
close: { code: number; reason: string; }; close: { code: number; reason: string };
message: { data: unknown; }; message: { data: unknown };
open: void; open: void;
} }
@@ -15,11 +14,11 @@ export interface AnyWebSocket {
addEventListener<K extends keyof WebsocketEvents>( addEventListener<K extends keyof WebsocketEvents>(
type: K, type: K,
listener: (event: WebsocketEvents[K]) => void, listener: (event: WebsocketEvents[K]) => void,
options?: { once: boolean; } options?: { once: boolean },
): void; ): void;
removeEventListener<K extends keyof WebsocketEvents>( removeEventListener<K extends keyof WebsocketEvents>(
type: K, type: K,
listener: (event: WebsocketEvents[K]) => void listener: (event: WebsocketEvents[K]) => void,
): void; ): void;
close(): void; close(): void;
send(data: string): void; send(data: string): void;

View File

@@ -44,7 +44,6 @@ export class PeerState {
this.processing = true; this.processing = true;
let entry: QueueEntry | undefined; let entry: QueueEntry | undefined;
while ((entry = this.queue.pull())) { while ((entry = this.queue.pull())) {
// Awaiting the push to send one message at a time // Awaiting the push to send one message at a time

View File

@@ -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. * 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>; type QueueTuple = Tuple<QueueEntry[], 8>;
export class PriorityBasedMessageQueue { export class PriorityBasedMessageQueue {
private queues: QueueTuple = [ private queues: QueueTuple = [[], [], [], [], [], [], [], []];
[],
[],
[],
[],
[],
[],
[],
[],
];
private getQueue(priority: CoValuePriority) { private getQueue(priority: CoValuePriority) {
return this.queues[priority]; return this.queues[priority];
} }
constructor( constructor(private defaultPriority: CoValuePriority) {}
private defaultPriority: CoValuePriority,
) {}
public push(msg: SyncMessage) { public push(msg: SyncMessage) {
const { promise, resolve, reject } = promiseWithResolvers<void>(); const { promise, resolve, reject } = promiseWithResolvers<void>();

View File

@@ -13,7 +13,10 @@ function createResolvablePromise<T>() {
class CoValueUnknownState { class CoValueUnknownState {
type = "unknown" as const; 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; private resolve: (value: "available" | "unavailable") => void;
ready: Promise<"available" | "unavailable">; ready: Promise<"available" | "unavailable">;
@@ -22,10 +25,15 @@ class CoValueUnknownState {
this.peers = new Map(); this.peers = new Map();
for (const peerId of peersIds) { 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.ready = promise;
this.resolve = resolve; this.resolve = resolve;
@@ -66,20 +74,22 @@ class CoValueUnknownState {
class CoValueAvailableState { class CoValueAvailableState {
type = "available" as const; type = "available" as const;
constructor(public coValue: CoValueCore) { } constructor(public coValue: CoValueCore) {}
} }
type CoValueStateAction = { type CoValueStateAction =
type: "not-found"; | {
peerId: PeerID; type: "not-found";
} | { peerId: PeerID;
type: "found"; }
peerId: PeerID; | {
coValue: CoValueCore; type: "found";
}; peerId: PeerID;
coValue: CoValueCore;
};
export class CoValueState { export class CoValueState {
constructor(public state: CoValueUnknownState | CoValueAvailableState) { } constructor(public state: CoValueUnknownState | CoValueAvailableState) {}
static Unknown(peersToWaitFor: Set<PeerID>) { static Unknown(peersToWaitFor: Set<PeerID>) {
return new CoValueState(new CoValueUnknownState(peersToWaitFor)); return new CoValueState(new CoValueUnknownState(peersToWaitFor));

View File

@@ -5,7 +5,11 @@ import { JsonObject } from "../jsonValue.js";
import { RawBinaryCoStream, RawCoStream } from "./coStream.js"; import { RawBinaryCoStream, RawCoStream } from "./coStream.js";
import { Encrypted, KeyID, KeySecret, Sealed } from "../crypto/crypto.js"; import { Encrypted, KeyID, KeySecret, Sealed } from "../crypto/crypto.js";
import { AgentID, isAgentID } from "../ids.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 { Role } from "../permissions.js";
import { base58 } from "@scure/base"; import { base58 } from "@scure/base";
import { CoValueUniqueness } from "../coValueCore.js"; import { CoValueUniqueness } from "../coValueCore.js";
@@ -19,7 +23,9 @@ export type GroupShape = {
[key: RawAccountID | AgentID]: Role; [key: RawAccountID | AgentID]: Role;
[EVERYONE]?: Role; [EVERYONE]?: Role;
readKey?: KeyID; readKey?: KeyID;
[revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`]: Sealed<KeySecret>; [
revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`
]: Sealed<KeySecret>;
[revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret; [revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted< [oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret, KeySecret,
@@ -175,10 +181,12 @@ export class RawGroup<
const newReadKey = this.core.crypto.newRandomKeySecret(); const newReadKey = this.core.crypto.newRandomKeySecret();
for (const readerID of currentlyPermittedReaders) { for (const readerID of currentlyPermittedReaders) {
const reader = this.core.node.resolveAccountAgent( const reader = this.core.node
readerID, .resolveAccountAgent(
"Expected to know currently permitted reader", readerID,
)._unsafeUnwrap({ withStackTrace: true }); "Expected to know currently permitted reader",
)
._unsafeUnwrap({ withStackTrace: true });
this.set( this.set(
`${newReadKey.id}_for_${readerID}`, `${newReadKey.id}_for_${readerID}`,
@@ -256,7 +264,7 @@ export class RawGroup<
init?: M["_shape"], init?: M["_shape"],
meta?: M["headerMeta"], meta?: M["headerMeta"],
initPrivacy: "trusting" | "private" = "private", initPrivacy: "trusting" | "private" = "private",
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique() uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
): M { ): M {
const map = this.core.node const map = this.core.node
.createCoValue({ .createCoValue({
@@ -266,7 +274,7 @@ export class RawGroup<
group: this.id, group: this.id,
}, },
meta: meta || null, meta: meta || null,
...uniqueness ...uniqueness,
}) })
.getCurrentContent() as M; .getCurrentContent() as M;
@@ -289,7 +297,7 @@ export class RawGroup<
init?: L["_item"][], init?: L["_item"][],
meta?: L["headerMeta"], meta?: L["headerMeta"],
initPrivacy: "trusting" | "private" = "private", initPrivacy: "trusting" | "private" = "private",
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique() uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
): L { ): L {
const list = this.core.node const list = this.core.node
.createCoValue({ .createCoValue({
@@ -299,7 +307,7 @@ export class RawGroup<
group: this.id, group: this.id,
}, },
meta: meta || null, meta: meta || null,
...uniqueness ...uniqueness,
}) })
.getCurrentContent() as L; .getCurrentContent() as L;
@@ -313,7 +321,10 @@ export class RawGroup<
} }
/** @category 3. Value creation */ /** @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 return this.core.node
.createCoValue({ .createCoValue({
type: "costream", type: "costream",
@@ -322,7 +333,7 @@ export class RawGroup<
group: this.id, group: this.id,
}, },
meta: meta || null, meta: meta || null,
...uniqueness ...uniqueness,
}) })
.getCurrentContent() as C; .getCurrentContent() as C;
} }
@@ -330,7 +341,7 @@ export class RawGroup<
/** @category 3. Value creation */ /** @category 3. Value creation */
createBinaryStream<C extends RawBinaryCoStream>( createBinaryStream<C extends RawBinaryCoStream>(
meta: C["headerMeta"] = { type: "binary" }, meta: C["headerMeta"] = { type: "binary" },
uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique() uniqueness: CoValueUniqueness = this.core.crypto.createdNowUnique(),
): C { ): C {
return this.core.node return this.core.node
.createCoValue({ .createCoValue({
@@ -340,7 +351,7 @@ export class RawGroup<
group: this.id, group: this.id,
}, },
meta: meta, meta: meta,
...uniqueness ...uniqueness,
}) })
.getCurrentContent() as C; .getCurrentContent() as C;
} }

View File

@@ -5,27 +5,41 @@ export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
export type JsonArray = JsonValue[] | readonly JsonValue[]; export type JsonArray = JsonValue[] | readonly JsonValue[];
export type JsonObject = { [key: string]: JsonValue | undefined }; export type JsonObject = { [key: string]: JsonValue | undefined };
type AtLeastOne<T, U = {[K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U]; type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> &
type ExcludeEmpty<T> = T extends AtLeastOne<T> ? T : never; 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>[]; export type CoJsonArray<T> = CoJsonValue<T>[] | readonly CoJsonValue<T>[];
/** /**
* Since we are forcing Typescript to elaborate the indexes from the given type passing * Since we are forcing Typescript to elaborate the indexes from the given type passing
* non-object values to CoJsonObjectWithIndex will return an empty object * non-object values to CoJsonObjectWithIndex will return an empty object
* E.g. * E.g.
* CoJsonObjectWithIndex<() => void> --> {} * CoJsonObjectWithIndex<() => void> --> {}
* CoJsonObjectWithIndex<RegExp> --> {} * CoJsonObjectWithIndex<RegExp> --> {}
* *
* Applying the ExcludeEmpty type here to make sure we don't accept functions or non-serializable values * 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. * 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 CoJsonValue1L<T> =
export type CoJsonValue2L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue3L<T[K]> | undefined }> | JsonValue; | ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue2L<T[K]> | undefined }>
export type CoJsonValue3L<T> = ExcludeEmpty<{ [K in keyof T & string]: CoJsonValue4L<T[K]> | undefined }> | JsonValue; | JsonValue;
export type CoJsonValue4L<T> = ExcludeEmpty<{ [K in keyof T & string]: JsonValue | 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;

View File

@@ -3,10 +3,10 @@ import { type CoValueHeader } from "./coValueCore.js";
/** /**
* The priority of a `CoValue` determines how much priority is given * The priority of a `CoValue` determines how much priority is given
* to its content messages. * to its content messages.
* *
* The priority value is handled as weight in the weighed round robin algorithm * The priority value is handled as weight in the weighed round robin algorithm
* used to determine the order in which messages are sent. * used to determine the order in which messages are sent.
* *
* Follows the HTTP urgency range and order: * Follows the HTTP urgency range and order:
* - https://www.rfc-editor.org/rfc/rfc9218.html#name-urgency * - 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 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) { if (typeof header === "boolean" || !header) {
return CO_VALUE_PRIORITY.MEDIUM; return CO_VALUE_PRIORITY.MEDIUM;
} }

View File

@@ -33,7 +33,12 @@ describe("PeerState", () => {
test("should push outgoing message to peer", async () => { test("should push outgoing message to peer", async () => {
const { mockPeer, peerState } = setup(); 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); await peerState.pushOutgoingMessage(message);
expect(mockPeer.outgoing.push).toHaveBeenCalledWith(message); expect(mockPeer.outgoing.push).toHaveBeenCalledWith(message);
}); });
@@ -54,21 +59,46 @@ describe("PeerState", () => {
test("should perform graceful shutdown", () => { test("should perform graceful shutdown", () => {
const { mockPeer, peerState } = setup(); const { mockPeer, peerState } = setup();
const consoleSpy = vi.spyOn(console, "debug").mockImplementation(() => {}); const consoleSpy = vi
.spyOn(console, "debug")
.mockImplementation(() => {});
peerState.gracefulShutdown(); peerState.gracefulShutdown();
expect(mockPeer.outgoing.close).toHaveBeenCalled(); expect(mockPeer.outgoing.close).toHaveBeenCalled();
expect(peerState.closed).toBe(true); expect(peerState.closed).toBe(true);
expect(consoleSpy).toHaveBeenCalledWith("Gracefully closing", "test-peer"); expect(consoleSpy).toHaveBeenCalledWith(
"Gracefully closing",
"test-peer",
);
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
test("should schedule outgoing messages based on their priority", async () => { test("should schedule outgoing messages based on their priority", async () => {
const { peerState } = setup(); const { peerState } = setup();
const loadMessage: SyncMessage = { action: "load", id: "co_zhigh", header: false, sessions: {} }; const loadMessage: SyncMessage = {
const contentMessageHigh: SyncMessage = { action: "content", id: "co_zhigh", new: {}, priority: CO_VALUE_PRIORITY.HIGH }; action: "load",
const contentMessageMid: SyncMessage = { action: "content", id: "co_zmid", new: {}, priority: CO_VALUE_PRIORITY.MEDIUM }; id: "co_zhigh",
const contentMessageLow: SyncMessage = { action: "content", id: "co_zlow", new: {}, priority: CO_VALUE_PRIORITY.LOW }; 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 = [ const promises = [
peerState.pushOutgoingMessage(contentMessageLow), peerState.pushOutgoingMessage(contentMessageLow),
@@ -81,12 +111,24 @@ describe("PeerState", () => {
// The first message is pushed directly, the other three are queued because are waiting // The first message is pushed directly, the other three are queued because are waiting
// for the first push to be completed. // 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 // Load message are managed as high priority messages and having the same priority as the content message
// they follow the push order. // they follow the push order.
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(2, contentMessageHigh); expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(3, loadMessage); 2,
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(4, contentMessageMid); contentMessageHigh,
);
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
3,
loadMessage,
);
expect(peerState["peer"].outgoing.push).toHaveBeenNthCalledWith(
4,
contentMessageMid,
);
}); });
}); });

View File

@@ -55,7 +55,7 @@ test("Can create account with one node, and then load it on another", async () =
trace: true, trace: true,
peer1role: "server", peer1role: "server",
peer2role: "client", peer2role: "client",
}) });
console.log("After connected peers"); console.log("After connected peers");

View File

@@ -726,7 +726,11 @@ test.skip("When replaying creation and transactions of a coValue as new content,
crashOnClose: true, 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 [inRx2, inTx2] = newQueuePair();
const [outRx2, outTx2] = 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); client1.syncManager.addPeer(serverAsPeerForClient1);
server.syncManager.addPeer(client1AsPeer); 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( const [serverAsPeerForClient2, client2AsPeer] = connectedPeers(
"serverFor2", "serverFor2",
@@ -926,7 +934,11 @@ test("Can sync a coValue with private transactions through a server to another c
client1.syncManager.addPeer(serverAsPeer); client1.syncManager.addPeer(serverAsPeer);
server.syncManager.addPeer(client1AsPeer); 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( const [serverAsOtherPeer, client2AsPeer] = connectedPeers(
"server", "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(); const map = group.createMap();
map.set("hello", "world", "trusting"); 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", { const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2", {
peer1role: "server", peer1role: "server",

View File

@@ -2,24 +2,27 @@ import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
import { AgentSecret } from "cojson"; import { AgentSecret } from "cojson";
export type MinimalClerkClient = { export type MinimalClerkClient = {
user: { user:
// eslint-disable-next-line @typescript-eslint/no-explicit-any | {
unsafeMetadata: Record<string, any>; // eslint-disable-next-line @typescript-eslint/no-explicit-any
fullName: string | null; unsafeMetadata: Record<string, any>;
username: string | null; fullName: string | null;
id: string; username: string | null;
update: (args: { id: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any update: (args: {
unsafeMetadata: Record<string, any>; // eslint-disable-next-line @typescript-eslint/no-explicit-any
}) => Promise<unknown>; unsafeMetadata: Record<string, any>;
} | null | undefined; }) => Promise<unknown>;
}
| null
| undefined;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} };
export class BrowserClerkAuth implements AuthMethod { export class BrowserClerkAuth implements AuthMethod {
constructor( constructor(
public driver: BrowserClerkAuth.Driver, public driver: BrowserClerkAuth.Driver,
private readonly clerkClient: MinimalClerkClient private readonly clerkClient: MinimalClerkClient,
) {} ) {}
async start(): Promise<AuthResult> { async start(): Promise<AuthResult> {

View File

@@ -55,11 +55,11 @@ export class BrowserDemoAuth implements AuthMethod {
this.driver.onSignedIn({ logOut }); this.driver.onSignedIn({ logOut });
}, },
onError: (error: string | Error) => { onError: (error: string | Error) => {
this.driver.onError(error) this.driver.onError(error);
}, },
logOut: () => { logOut: () => {
delete localStorage[localStorageKey]; delete localStorage[localStorageKey];
} },
} satisfies AuthResult; } satisfies AuthResult;
} else { } else {
return new Promise<AuthResult>((resolve) => { return new Promise<AuthResult>((resolve) => {
@@ -96,11 +96,11 @@ export class BrowserDemoAuth implements AuthMethod {
this.driver.onSignedIn({ logOut }); this.driver.onSignedIn({ logOut });
}, },
onError: (error: string | Error) => { onError: (error: string | Error) => {
this.driver.onError(error) this.driver.onError(error);
}, },
logOut: () => { logOut: () => {
delete localStorage[localStorageKey]; delete localStorage[localStorageKey];
} },
}); });
}, },
existingUsers: existingUsers:
@@ -126,11 +126,11 @@ export class BrowserDemoAuth implements AuthMethod {
this.driver.onSignedIn({ logOut }); this.driver.onSignedIn({ logOut });
}, },
onError: (error: string | Error) => { onError: (error: string | Error) => {
this.driver.onError(error) this.driver.onError(error);
}, },
logOut: () => { logOut: () => {
delete localStorage[localStorageKey]; delete localStorage[localStorageKey];
} },
}); });
}, },
}); });

View File

@@ -1,10 +1,5 @@
import { AgentSecret, cojsonInternals, CryptoProvider } from "cojson"; import { AgentSecret, cojsonInternals, CryptoProvider } from "cojson";
import { import { Account, AuthMethod, AuthResult, ID } from "jazz-tools";
Account,
AuthMethod,
AuthResult,
ID,
} from "jazz-tools";
import * as bip39 from "@scure/bip39"; import * as bip39 from "@scure/bip39";
type LocalStorageData = { type LocalStorageData = {

View File

@@ -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"; import { useState, useMemo } from "react";
export function useJazzClerkAuth(clerk: MinimalClerkClient & { export function useJazzClerkAuth(
signOut: () => Promise<unknown>; clerk: MinimalClerkClient & {
}) { signOut: () => Promise<unknown>;
},
) {
const [state, setState] = useState<{ errors: string[] }>({ errors: [] }); const [state, setState] = useState<{ errors: string[] }>({ errors: [] });
const authMethod = useMemo(() => { const authMethod = useMemo(() => {

View File

@@ -99,7 +99,7 @@ export const startSync = Command.make(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
websocket: ws as any, // TODO: fix types websocket: ws as any, // TODO: fix types
expectPings: false, expectPings: false,
batchingByDefault: false batchingByDefault: false,
}), }),
); );

View File

@@ -236,7 +236,7 @@ export function createCoValueObservable<V extends CoValue, Depth>() {
onUnavailable, onUnavailable,
); );
return unsubscribe return unsubscribe;
} }
const observable = { const observable = {

View File

@@ -21,7 +21,11 @@ export { ImageDefinition } from "./internal.js";
export { CoValueBase, type CoValueClass } from "./internal.js"; export { CoValueBase, type CoValueClass } from "./internal.js";
export type { DepthsIn, DeeplyLoaded } 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 { export {
type AuthMethod, type AuthMethod,

View File

@@ -53,9 +53,7 @@ export class Ref<out V extends CoValue> {
"node" in this.controlledAccount "node" in this.controlledAccount
? this.controlledAccount.node ? this.controlledAccount.node
: this.controlledAccount._raw.core.node; : this.controlledAccount._raw.core.node;
const raw = await node.load( const raw = await node.load(this.id as unknown as CoID<RawCoValue>);
this.id as unknown as CoID<RawCoValue>,
);
if (raw === "unavailable") { if (raw === "unavailable") {
return "unavailable"; return "unavailable";
} else { } else {

View File

@@ -173,7 +173,7 @@ export type SchemaFor<Field> = NonNullable<Field> extends CoValue
export type Encoder<V> = { export type Encoder<V> = {
encode: (value: V) => JsonValue; encode: (value: V) => JsonValue;
decode: (value: JsonValue) => V; decode: (value: JsonValue) => V;
} };
export type OptionalEncoder<V> = export type OptionalEncoder<V> =
| Encoder<V> | Encoder<V>
| { | {

View File

@@ -24,7 +24,7 @@ describe("co.json TypeScript validation", () => {
value: string; value: string;
}; };
}; };
} };
class ValidNestedMap extends CoMap { class ValidNestedMap extends CoMap {
data = co.json<NestedType>(); data = co.json<NestedType>();
@@ -41,7 +41,7 @@ describe("co.json TypeScript validation", () => {
type TypeWithOptional = { type TypeWithOptional = {
value: string; value: string;
optional?: string | null; optional?: string | null;
} };
class ValidMap extends CoMap { class ValidMap extends CoMap {
data = co.json<TypeWithOptional>(); data = co.json<TypeWithOptional>();

View File

@@ -170,18 +170,20 @@ describe("subscribeToCoValue", () => {
const { me, meOnSecondPeer } = await setupAccount(); const { me, meOnSecondPeer } = await setupAccount();
const chatRoom = createChatRoom(me, "General"); 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); chatRoom.messages?.push(message);
const updateFn = vi.fn() const updateFn = vi.fn();
const unsubscribe = subscribeToCoValue( const unsubscribe = subscribeToCoValue(
ChatRoom, ChatRoom,
chatRoom.id, chatRoom.id,
meOnSecondPeer, meOnSecondPeer,
{ {
messages: [{ messages: [{}],
}],
}, },
updateFn, updateFn,
); );
@@ -200,30 +202,37 @@ describe("subscribeToCoValue", () => {
await waitFor(() => { await waitFor(() => {
expect(updateFn).toHaveBeenCalled(); expect(updateFn).toHaveBeenCalled();
}); });
const lastValue = updateFn.mock.lastCall[0]; 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 () => { it("should handle the updates as immutable changes", async () => {
const { me, meOnSecondPeer } = await setupAccount(); const { me, meOnSecondPeer } = await setupAccount();
const chatRoom = createChatRoom(me, "General"); 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!"); const message2 = createMessage(me, "Let's go!");
chatRoom.messages?.push(message); chatRoom.messages?.push(message);
chatRoom.messages?.push(message2); chatRoom.messages?.push(message2);
const updateFn = vi.fn() const updateFn = vi.fn();
const unsubscribe = subscribeToCoValue( const unsubscribe = subscribeToCoValue(
ChatRoom, ChatRoom,
chatRoom.id, chatRoom.id,
meOnSecondPeer, meOnSecondPeer,
{ {
messages: [{ messages: [
reactions: [], {
}], reactions: [],
},
],
}, },
updateFn, updateFn,
); );
@@ -249,12 +258,14 @@ describe("subscribeToCoValue", () => {
await waitFor(() => { await waitFor(() => {
expect(updateFn).toHaveBeenCalled(); expect(updateFn).toHaveBeenCalled();
}); });
const lastValue = updateFn.mock.lastCall[0]; const lastValue = updateFn.mock.lastCall[0];
expect(lastValue).not.toBe(initialValue); expect(lastValue).not.toBe(initialValue);
expect(lastValue.messages).not.toBe(initialMessagesList); expect(lastValue.messages).not.toBe(initialMessagesList);
expect(lastValue.messages[0]).not.toBe(initialMessage1); 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 // This shouldn't change
expect(lastValue.messages[1]).toBe(initialMessage2); expect(lastValue.messages[1]).toBe(initialMessage2);
@@ -270,21 +281,26 @@ describe("subscribeToCoValue", () => {
const { me, meOnSecondPeer } = await setupAccount(); const { me, meOnSecondPeer } = await setupAccount();
const chatRoom = createChatRoom(me, "General"); 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!"); const message2 = createMessage(me, "Let's go!");
chatRoom.messages?.push(message); chatRoom.messages?.push(message);
chatRoom.messages?.push(message2); chatRoom.messages?.push(message2);
const updateFn = vi.fn() const updateFn = vi.fn();
const unsubscribe = subscribeToCoValue( const unsubscribe = subscribeToCoValue(
ChatRoom, ChatRoom,
chatRoom.id, chatRoom.id,
meOnSecondPeer, meOnSecondPeer,
{ {
messages: [{ messages: [
reactions: [], {
}], reactions: [],
},
],
}, },
updateFn, updateFn,
); );
@@ -306,19 +322,18 @@ describe("subscribeToCoValue", () => {
await waitFor(() => { await waitFor(() => {
expect(updateFn).toHaveBeenCalled(); expect(updateFn).toHaveBeenCalled();
}); });
const lastValue = updateFn.mock.lastCall[0]; const lastValue = updateFn.mock.lastCall[0];
expect(lastValue).not.toBe(initialValue); expect(lastValue).not.toBe(initialValue);
expect(lastValue.name).toBe("Me and Luigi"); expect(lastValue.name).toBe("Me and Luigi");
expect(initialValue.name).toBe("General"); expect(initialValue.name).toBe("General");
expect(lastValue.messages).toBe(initialValue.messages); expect(lastValue.messages).toBe(initialValue.messages);
expect(lastValue.messages[0]).toBe(initialValue.messages[0]); expect(lastValue.messages[0]).toBe(initialValue.messages[0]);
expect(lastValue.messages[1]).toBe(initialValue.messages[1]); expect(lastValue.messages[1]).toBe(initialValue.messages[1]);
}); });
}); });
function waitFor(callback: () => boolean | void) { function waitFor(callback: () => boolean | void) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const checkPassed = () => { const checkPassed = () => {