This commit is contained in:
2025-05-20 04:16:03 +03:00
parent 21a05dd202
commit 9531bff01a
88 changed files with 7797 additions and 2246 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
# React Router
/.react-router/
/build/
/.idea/

View File

@@ -8,11 +8,101 @@
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
:root {
--background: oklch(1.00 0 0);
--foreground: oklch(0.32 0 0);
--card: oklch(1.00 0 0);
--card-foreground: oklch(0.32 0 0);
--popover: oklch(1.00 0 0);
--popover-foreground: oklch(0.32 0 0);
--primary: oklch(0.62 0.19 259.81);
--primary-foreground: oklch(1.00 0 0);
--secondary: oklch(0.97 0.00 264.54);
--secondary-foreground: oklch(0.45 0.03 256.80);
--muted: oklch(0.98 0.00 247.84);
--muted-foreground: oklch(0.55 0.02 264.36);
--accent: oklch(0.95 0.03 236.82);
--accent-foreground: oklch(0.38 0.14 265.52);
--destructive: oklch(0.64 0.21 25.33);
--destructive-foreground: oklch(1.00 0 0);
--border: oklch(0.93 0.01 264.53);
--input: oklch(0.93 0.01 264.53);
--ring: oklch(0.62 0.19 259.81);
--chart-1: oklch(0.62 0.19 259.81);
--chart-2: oklch(0.55 0.22 262.88);
--chart-3: oklch(0.49 0.22 264.38);
--chart-4: oklch(0.42 0.18 265.64);
--chart-5: oklch(0.38 0.14 265.52);
--sidebar: oklch(0.98 0.00 247.84);
--sidebar-foreground: oklch(0.32 0 0);
--sidebar-primary: oklch(0.62 0.19 259.81);
--sidebar-primary-foreground: oklch(1.00 0 0);
--sidebar-accent: oklch(0.95 0.03 236.82);
--sidebar-accent-foreground: oklch(0.38 0.14 265.52);
--sidebar-border: oklch(0.93 0.01 264.53);
--sidebar-ring: oklch(0.62 0.19 259.81);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
.dark {
--background: oklch(0.20 0 0);
--foreground: oklch(0.92 0 0);
--card: oklch(0.27 0 0);
--card-foreground: oklch(0.92 0 0);
--popover: oklch(0.27 0 0);
--popover-foreground: oklch(0.92 0 0);
--primary: oklch(0.62 0.19 259.81);
--primary-foreground: oklch(1.00 0 0);
--secondary: oklch(0.27 0 0);
--secondary-foreground: oklch(0.92 0 0);
--muted: oklch(0.27 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.38 0.14 265.52);
--accent-foreground: oklch(0.88 0.06 254.13);
--destructive: oklch(0.64 0.21 25.33);
--destructive-foreground: oklch(1.00 0 0);
--border: oklch(0.37 0 0);
--input: oklch(0.37 0 0);
--ring: oklch(0.62 0.19 259.81);
--chart-1: oklch(0.71 0.14 254.62);
--chart-2: oklch(0.62 0.19 259.81);
--chart-3: oklch(0.55 0.22 262.88);
--chart-4: oklch(0.49 0.22 264.38);
--chart-5: oklch(0.42 0.18 265.64);
--sidebar: oklch(0.20 0 0);
--sidebar-foreground: oklch(0.92 0 0);
--sidebar-primary: oklch(0.62 0.19 259.81);
--sidebar-primary-foreground: oklch(1.00 0 0);
--sidebar-accent: oklch(0.38 0.14 265.52);
--sidebar-accent-foreground: oklch(0.88 0.06 254.13);
--sidebar-border: oklch(0.37 0 0);
--sidebar-ring: oklch(0.62 0.19 259.81);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -28,6 +118,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -44,78 +135,37 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
/* width */
::-webkit-scrollbar {
@apply w-1
}
/* Handle */
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full mr-0.5
}
* {
@apply border-border outline-ring/50;
}
@@ -131,6 +181,7 @@
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;

View File

@@ -1,72 +1,41 @@
import { Headphones, Mic, Settings } from "lucide-react";
import React from "react";
import { NavLink } from "react-router";
import { useShallow } from 'zustand/react/shallow';
import { cn, getFirstLetters } from "~/lib/utils";
import { useServerListStore } from "~/store/server-list";
import { useUserStore } from "~/store/user";
import { CreateServerButton } from "./create-server";
import Discord from "./icons/Discord";
import { OnlineStatus } from "./online-status";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import { useMatches } from "react-router";
import { useShallow } from "zustand/react/shallow";
import { useServerListStore } from "~/stores/server-list-store";
import { CreateServerButton } from "./custom-ui/create-server-button";
import { HomeButton } from "./custom-ui/home-button";
import { ServerButton } from "./custom-ui/server-button";
import UserStatus from "./custom-ui/user-status";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
interface AppLayoutProps {
list: React.ReactNode;
children: React.ReactNode;
}
export default function AppLayout({ list, children }: AppLayoutProps) {
let user = useUserStore(state => state.user!)
let servers = useServerListStore(useShallow((state) => Array.from(state.servers.values())))
export default function AppLayout({ children }: AppLayoutProps) {
let servers = useServerListStore(useShallow((state) => Object.values(state.servers)))
const matches = useMatches();
let list = React.useMemo(() => {
return matches.map(match => (match.handle as {
listComponent?: React.ReactNode
})?.listComponent).reverse().find(component => !!component)
}, [matches])
return (
<div className="flex h-screen w-screen overflow-hidden">
<div className="grid h-full max-h-screen bg-accent/60 min-w-80" style={{ gridTemplateColumns: "auto 1fr", gridTemplateRows: "1fr auto" }}>
<div className="grid h-full max-h-screen bg-background min-w-80 border-r-2" style={{ gridTemplateColumns: "auto 1fr", gridTemplateRows: "1fr auto" }}>
<div className="col-start-1 row-start-1 col-span-1 row-span-1 overflow-hidden border-r-2 min-w-fit">
<ScrollArea className="h-full px-1" scrollbarSize="none">
<aside className="flex flex-col gap-4 p-2 h-full">
<NavLink to={`/app/@me`} className={({ isActive }) =>
cn(
"rounded-xl",
isActive ? "bg-primary" : "bg-accent",
)
}>
<Discord className="size-8 m-2" />
</NavLink>
<HomeButton />
<Separator />
{
servers.map((server, _) =>
<React.Fragment key={server.id}>
<TooltipProvider>
<Tooltip>
<NavLink to={`/app/server/${server.id}`} className={({ isActive }) =>
cn(
"rounded-xl overflow-hidden",
isActive ? "bg-primary" : "bg-accent",
)
}>
{({ isActive }) => (
<TooltipTrigger asChild>
<Avatar className="size-12 rounded-none">
<AvatarImage src={server.icon_url} className="rounded-none" />
<AvatarFallback className={cn(isActive ? "bg-primary text-accent" : "", "rounded-none")}>
<div>
{getFirstLetters(server.name, 4)}
</div>
</AvatarFallback>
</Avatar>
</TooltipTrigger>
)}
</NavLink>
<TooltipContent>
{server.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ServerButton server={server} />
</React.Fragment>
)
}
@@ -80,37 +49,7 @@ export default function AppLayout({ list, children }: AppLayoutProps) {
</div>
<div className="col-start-1 row-start-2 col-span-2 row-span-1 mb-2 mx-2 min-w-fit z-1">
<div className="outline-1 p-2 h-full rounded-xl">
<div className="flex justify-between items-center gap-2">
<div className="grow flex flex-row gap-2">
<OnlineStatus status="online">
<Avatar className="size-10">
<AvatarImage src="https://api.dicebear.com/9.x/bottts/jpg?seed=lionarius" />
</Avatar>
</OnlineStatus>
<div className="flex flex-col text-sm justify-center">
<div className="truncate max-w-30">
{user.displayName || user.username}
</div>
<span className="text-muted-foreground text-xs">@{user.username}</span>
</div>
</div>
<div>
<div className="flex flex-row-reverse gap-2 items-center">
<Button variant="secondary" size="none">
<Settings className="size-5 m-1.5" />
</Button>
<Button variant="secondary" size="none">
<Headphones className="size-5 m-1.5" />
</Button>
<Button variant="secondary" size="none">
<Mic className="size-5 m-1.5" />
</Button>
</div>
</div>
</div>
</div>
<UserStatus />
</div>
</div>

View File

@@ -0,0 +1,84 @@
import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query"
import type { Channel, MessageId } from "~/lib/api/types"
import ChatMessage from "./chat-message"
import MessageBox from "./message-box"
import VisibleTrigger from "./visible-trigger"
interface ChannelAreaProps {
channel: Channel
}
export default function ChannelArea(
{ channel }: ChannelAreaProps
) {
const channelId = channel.id
const fetchMessages = async ({ pageParam }: QueryFunctionContext) => {
return await import("~/lib/api/client/channel").then(m => m.default.paginatedMessages(channelId, 50, pageParam as MessageId | undefined))
}
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isPending,
status,
} = useInfiniteQuery({
queryKey: ["messages", channelId],
initialPageParam: undefined,
queryFn: fetchMessages,
getNextPageParam: (lastPage) => lastPage.length < 50 ? undefined : lastPage[lastPage.length - 1]?.id,
staleTime: Infinity,
})
const fetchNextPageVisible = () => {
if (!isFetchingNextPage && hasNextPage)
fetchNextPage()
}
let messageArea = null
if (isPending) {
messageArea = <div className="flex items-center justify-center size-full">
<span>Loading...</span>
</div>
} else {
messageArea = <>
<div className="flex-1" />
<div className="flex flex-col-reverse overflow-auto gap-2">
{
status === "success" && data.pages.map((page, i) => (
page.map((message) => (
<div key={message.id} className="w-full">
<ChatMessage message={message} />
</div>
))
)
)
}
<VisibleTrigger triggerOnce={false} onVisible={fetchNextPageVisible} />
</div>
</>
}
return (
<>
<div className="flex flex-col size-full">
<div className="w-full min-h-12 border-b-2 flex items-center justify-center">
{channel?.name}
</div>
<div className="flex-1 overflow-y-auto flex flex-col pl-2 pr-0.5">
{messageArea}
</div>
<div className="w-full max-w-full max-h-1/2">
<MessageBox channelId={channelId} />
</div>
</div>
</>
);
}

View File

@@ -1,45 +0,0 @@
import { ChevronDown, Hash, Volume2 } from "lucide-react"
import { Button } from "./ui/button"
interface Channel {
id: string
name: string
type: "text" | "voice" | "category"
}
interface ChannelListItemProps {
channel: Channel
}
export default function ChannelListItem({ channel }: ChannelListItemProps) {
if (channel.type === "category") {
return (
<div className="text-xs flex flex-row justify-between mt-4">
<div className="grow">
<div className="flex items-center gap-1">
{channel.name}
<ChevronDown className="size-4" />
</div>
</div>
<div>
</div>
</div>
)
}
return (
<>
<Button variant="secondary" size="sm" className="justify-start">
<div className="flex items-center gap-2 max-w-72">
<div>
{channel.type === "text" && <Hash />}
{channel.type === "voice" && <Volume2 />}
</div>
<div className="truncate">
{channel.name}
</div>
</div>
</Button>
</>
)
}

View File

@@ -0,0 +1,136 @@
import {
Download,
ExternalLink,
Maximize
} from "lucide-react";
import { AspectRatio } from "~/components/ui/aspect-ratio"; // Shadcn UI AspectRatio
import { Button } from "~/components/ui/button"; // Shadcn UI Button
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog"; // Shadcn UI Dialog
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip"; // Shadcn UI Tooltip
import type { UploadedFile } from "~/lib/api/types"; // Adjust path as needed
import { formatFileSize } from "~/lib/utils"; // Adjust path
import { FileIcon } from "./file-icon";
interface ChatMessageAttachmentProps {
file: UploadedFile;
}
export default function ChatMessageAttachment({ file }: ChatMessageAttachmentProps) {
if (file.contentType.startsWith("image/")) {
return <ImageAttachment file={file} />;
}
return <GenericFileAttachment file={file} />;
}
function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
return (
<TooltipProvider delayDuration={100}>
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-sm w-full max-w-xs sm:max-w-sm">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<FileIcon className="h-8 w-8 text-muted-foreground" contentType={file.contentType} />
</div>
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium text-card-foreground">
{file.filename}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer" download={file.filename}>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
<span className="sr-only">Open in new tab</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open in new tab</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
);
}
function ImageAttachment({ file }: ChatMessageAttachmentProps) {
return (
<Dialog>
<TooltipProvider delayDuration={100}>
<div className="group relative w-48 cursor-pointer sm:w-64">
<DialogTrigger asChild>
<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-lg border bg-muted">
<img
src={file.url}
alt={file.filename}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
<Maximize className="h-8 w-8 text-white" />
</div>
</AspectRatio>
</DialogTrigger>
<div className="mt-1">
<p className="truncate text-xs text-muted-foreground">
{file.filename} ({formatFileSize(file.size)})
</p>
</div>
</div>
</TooltipProvider>
<DialogContent className="max-w-3xl p-0">
<DialogHeader className="p-4 pb-0">
<DialogTitle className="truncate">{file.filename}</DialogTitle>
</DialogHeader>
<div className="p-4 pt-0 max-h-[70vh] overflow-y-auto">
<img
src={file.url}
alt={file.filename}
className="mx-auto max-h-full w-auto rounded-md object-contain"
/>
</div>
<DialogFooter className="flex-col items-stretch gap-2 border-t p-4 sm:flex-row sm:justify-end sm:space-x-2">
<Button variant="outline" asChild>
<a href={file.url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" /> Open original
</a>
</Button>
<Button asChild>
<a href={file.url} download={file.filename}>
<Download className="mr-2 h-4 w-4" /> Download
</a>
</Button>
<DialogClose asChild>
<Button variant="ghost">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,86 @@
import { Clock } from "lucide-react"
import React from "react"
import { useShallow } from "zustand/react/shallow"
import type { Message } from "~/lib/api/types"
import { useUsersStore } from "~/stores/users-store"
import ChatMessageAttachment from "./chat-message-attachment"
import UserAvatar from "./user-avatar"
interface ChatMessageProps {
message: Message
}
export default function ChatMessage(
{ message }: ChatMessageProps
) {
const { user, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({
user: state.users[message.authorId],
fetchUsersIfNotPresent: state.fetchUsersIfNotPresent
})))
React.useEffect(() => {
fetchUsersIfNotPresent([message.authorId])
}, [])
const formatMessageDate = (date: Date) => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
// Get localized time string
const timeString = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
if (messageDate.getTime() === today.getTime()) {
// Use Intl.RelativeTimeFormat for localized "Today"
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
return `${(capitalize(rtf.format(0, 'day')))}, ${timeString}`
} else if (messageDate.getTime() === yesterday.getTime()) {
// Use Intl.RelativeTimeFormat for localized "Yesterday"
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
return `${capitalize(rtf.format(-1, 'day'))}, ${timeString}`
} else {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
}
return (
<div className="grid gap-x-2" style={{ gridTemplateColumns: "auto 1fr", gridTemplateRows: "auto 1fr" }}>
<div className="row-start-1 col-start-1 row-span-2 col-span-1">
<UserAvatar user={user} />
</div>
<div className="row-start-1 col-start-2 row-span-1 col-span-1 flex items-center gap-2">
<span className="font-medium text-sm">
{user?.displayName || user?.username}
</span>
<div className="flex items-center gap-0.5 text-xs text-muted-foreground whitespace-nowrap">
<Clock className="size-3" />
<span>
{formatMessageDate(message.createdAt)}
</span>
</div>
</div>
<div className="row-start-2 col-start-2 row-span-1 col-span-1">
<div className="wrap-break-word contain-inline-size">
{message.content}
</div>
<div className="flex flex-col gap-2">
{
message.attachments.map((file, i) => (<div key={file.id}>
<ChatMessageAttachment file={file} />
</div>))
}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { ChevronDown, Hash, Volume2 } from "lucide-react"
import React from "react"
import { NavLink } from "react-router"
import { useShallow } from "zustand/react/shallow"
import { ChannelType, type ServerChannel } from "~/lib/api/types"
import { cn } from "~/lib/utils"
import { useChannelsVoiceStateStore } from "~/stores/channels-voice-state"
import { useGatewayStore } from "~/stores/gateway-store"
import { useUsersStore } from "~/stores/users-store"
import { Button } from "../ui/button"
import UserAvatar from "../user-avatar"
interface ChannelListItemProps {
channel: ServerChannel
}
function ServerCategory({ channel }: ChannelListItemProps) {
return (
<div className="text-xs flex flex-row justify-between mt-4">
<div className="grow">
<div className="flex items-center gap-1">
{channel.name}
<ChevronDown className="size-4" />
</div>
</div>
</div>
)
}
function ServerVoice({ channel }: ChannelListItemProps) {
const updateVoiceState = useGatewayStore(state => state.updateVoiceState)
const channelVoiceState = useChannelsVoiceStateStore(state => state.channels[channel.id]) || {}
const userIds = Object.keys(channelVoiceState.users ?? {})
const { users, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({
users: state.users,
fetchUsersIfNotPresent: state.fetchUsersIfNotPresent
})))
const channelUsers = React.useMemo(() => userIds.map(userId => users[userId]).filter(Boolean), [userIds, users])
React.useEffect(() => {
fetchUsersIfNotPresent(userIds)
}, [userIds])
const onClick = () => {
updateVoiceState(channel.serverId, channel.id)
}
return (
<>
<Button variant="secondary" size="sm" className="justify-start" onClick={onClick}>
<div className="flex items-center gap-2 max-w-72">
<div>
<Volume2 />
</div>
<div className="truncate">
{channel.name}
</div>
</div>
</Button>
{channelUsers.length > 0 &&
<div className="ml-2 border-l-2 flex flex-col gap-1">
{
channelUsers
.map(user => (
<div key={user.id} className="flex items-center gap-2 max-w-72 pl-4">
<UserAvatar user={user} className="size-6" />
{user.displayName || user.username}
</div>
))
}
</div>}
</>
)
}
function ServerText({ channel }: ChannelListItemProps) {
return (
<NavLink to={`/app/server/${channel.serverId}/${channel.id}`} discover="none" >
{({ isActive }) => (
<Button variant="secondary" size="sm" className={
cn(
"justify-start w-full",
isActive ? "bg-accent hover:bg-accent" : "bg-secondary"
)
}>
<div className="flex items-center gap-2 max-w-72">
<div>
<Hash />
</div>
<div className="truncate">
{channel.name}
</div>
</div>
</Button>
)
}
</NavLink>
)
}
export default function ServerChannelListItem({ channel }: ChannelListItemProps) {
switch (channel.type) {
case ChannelType.SERVER_CATEGORY:
return <ServerCategory channel={channel} />
case ChannelType.SERVER_VOICE:
return <ServerVoice channel={channel} />
case ChannelType.SERVER_TEXT:
return <ServerText channel={channel} />
default:
return null
}
}

View File

@@ -0,0 +1,15 @@
import { CirclePlus } from "lucide-react";
import { Button } from "~/components/ui/button";
import { ModalType, useModalStore } from "~/stores/modal-store";
export function CreateServerButton() {
const onOpen = useModalStore(state => state.onOpen)
return (
<Button variant="outline" size="none" onClick={() => onOpen(ModalType.CREATE_SERVER)}>
<CirclePlus className="size-8 m-2" />
</Button>
)
}

View File

@@ -0,0 +1,21 @@
import { NavLink } from "react-router";
import Discord from "../icons/Discord";
import { Button } from "../ui/button";
export function HomeButton() {
return (
<NavLink to={`/app/@me`}>
{
({ isActive }) => (
<Button variant="outline" size="none" asChild className={
isActive ? "bg-accent size-12" : "size-12"
}>
<div>
<Discord className="size-full p-2" />
</div>
</Button>
)
}
</NavLink>
)
}

View File

@@ -9,7 +9,7 @@ export function OnlineStatus({
return (
<div className="relative">
<div {...props}></div>
<div className="absolute bottom-0 right-0 bg-accent rounded-full p-0.5 size-1/2">
<div className="absolute bottom-0 right-0 bg-background rounded-full p-0.5 size-1/2">
{status === "online" && <Circle className="size-full stroke-emerald-400 fill-emerald-400" />}
{status === "dnd" && <CircleMinus className="size-full stroke-red-400 stroke-3" />}
{status === "idle" && <Moon className="size-full stroke-amber-400 fill-amber-400" />}

View File

@@ -1,7 +1,7 @@
import { EyeIcon, EyeOffIcon } from "lucide-react"
import React from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
export function PasswordInput(props: React.ComponentProps<"input">) {
const [showPassword, setShowPassword] = React.useState(false)

View File

@@ -0,0 +1,48 @@
import { Check } from "lucide-react"
import { NavLink } from "react-router"
import type { RecipientChannel } from "~/lib/api/types"
import { cn } from "~/lib/utils"
import { useUsersStore } from "~/stores/users-store"
import { Badge } from "../ui/badge"
import { Button } from "../ui/button"
import UserAvatar from "../user-avatar"
import { OnlineStatus } from "./online-status"
interface PrivateChannelListItemProps {
channel: RecipientChannel
}
export default function PrivateChannelListItem({ channel }: PrivateChannelListItemProps) {
const currentUserId = useUsersStore(state => state.currentUserId)
const recipients = channel.recipients.filter(recipient => recipient.id !== currentUserId);
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
return (
<>
<NavLink to={`/app/@me/channels/${channel.id}`}>
{
({ isActive }) => (
<Button variant="secondary" size="none" asChild className={
cn(
isActive ? "bg-accent hover:bg-accent" : "",
"w-full flex flex-row justify-start"
)
}>
<div className="flex items-center gap-2 max-w-72 p-2">
<div>
<OnlineStatus status="online">
<UserAvatar user={channel.recipients.find(recipient => recipient.id !== currentUserId)} />
</OnlineStatus>
</div>
<div className="truncate">
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
</div>
{renderSystemBadge && <Badge variant="default"> <Check /> System</Badge>}
</div>
</Button>
)
}
</NavLink>
</>
)
}

View File

@@ -0,0 +1,36 @@
import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"
import { NavLink } from "react-router"
import type { Server } from "~/lib/api/types"
import { getFirstLetters } from "~/lib/utils"
import { Button } from "../ui/button"
export interface ServerButtonProps {
server: Server
}
export function ServerButton(
{ server }: ServerButtonProps
) {
return (
<NavLink to={`/app/server/${server.id}`}>
{
({ isActive }) => (
<Button variant="outline" size="none" asChild className={
isActive ? "bg-accent" : ""
}>
<div>
<Avatar className="size-12 rounded-none flex items-center justify-center">
<AvatarImage src={server.iconUrl} className="rounded-none" />
<AvatarFallback>
<div>
{getFirstLetters(server.name, 4)}
</div>
</AvatarFallback>
</Avatar>
</div>
</Button>
)
}
</NavLink>
)
}

View File

@@ -0,0 +1,38 @@
import { Settings } from "lucide-react";
import { ModalType, useModalStore } from "~/stores/modal-store";
import { useTokenStore } from "~/stores/token-store";
import { Button } from "../ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
export function SettingsButton() {
const setToken = useTokenStore(state => state.setToken)
const onOpen = useModalStore(state => state.onOpen)
const onUpdateProfile = () => {
onOpen(ModalType.UPDATE_PROFILE)
}
const onLogout = () => {
setToken(undefined)
window.location.reload()
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Settings className="size-5 m-1.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={onUpdateProfile}>
Update profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onLogout}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -86,8 +86,7 @@ export const TextBox = forwardRef<HTMLDivElement, TextBoxProps>(
return (
<div
className={cn(
"max-h-96 w-full overflow-y-auto min-h-6 outline-none p-4 rounded-xl no-scrollbar bg-background border border-input",
"focus-within:ring-2 focus-within:ring-ring focus-within:border-ring",
"max-h-96 w-full overflow-y-auto min-h-6",
wrapperClassName
)}
onClick={() => localRef.current?.focus()}

View File

@@ -0,0 +1,80 @@
import { PhoneMissed, Signal } from "lucide-react";
import { useServerChannelsStore } from "~/stores/server-channels-store";
import { useServerListStore } from "~/stores/server-list-store";
import { useUsersStore } from "~/stores/users-store";
import { useVoiceStateStore } from "~/stores/voice-state-store";
import { ThemeToggle } from "../theme/theme-toggle";
import { Button } from "../ui/button";
import { Separator } from "../ui/separator";
import UserAvatar from "../user-avatar";
import { OnlineStatus } from "./online-status";
import { SettingsButton } from "./settings-button";
function VoiceStatus({
voiceState
}: { voiceState: { serverId: string; channelId: string } }) {
// const webrtcState = useWebRTCStore(state => state.status)
const leaveVoiceChannel = () => {
useVoiceStateStore.getState().leaveVoiceChannel()
}
const channel = useServerChannelsStore(state => state.channels[voiceState.serverId]?.[voiceState.channelId])
const server = useServerListStore(state => state.servers[voiceState.serverId])
return (
<div className="gap-1 flex justify-between items-center ">
<div className="flex items-center gap-2 text-green-500">
<Signal className="size-5" />
<div className="truncate max-w-60 text-sm">
{channel?.name || "Unknown channel"} / {server?.name}
</div>
</div>
<Button variant="secondary" size="none" onClick={leaveVoiceChannel}>
<PhoneMissed className="size-5 m-1.5" />
</Button>
</div>
)
}
export default function UserStatus() {
const user = useUsersStore(state => state.getCurrentUser()!)
const voiceState = useVoiceStateStore(state => state.activeChannel)
return (
<div className="outline-1 outline-none border border-input p-2 h-full rounded-xl flex flex-col gap-2">
{voiceState && <>
<VoiceStatus voiceState={voiceState} />
<Separator />
</>
}
<div className="flex justify-between items-center gap-2">
<div className="grow flex flex-row gap-2">
<OnlineStatus status="online">
<UserAvatar user={user} className="size-10" />
</OnlineStatus>
<div className="flex flex-col text-sm justify-center">
<div className="truncate max-w-30">
{user?.displayName || user?.username || "Unknown user"}
</div>
<span className="text-muted-foreground text-xs">@{user?.username}</span>
</div>
</div>
<div>
<div className="flex flex-row-reverse gap-2 items-center">
<SettingsButton />
<ThemeToggle />
{/* <Button variant="secondary" size="none">
<Headphones className="size-5 m-1.5" />
</Button> */}
{/* <Button variant="secondary" size="none">
<Mic className="size-5 m-1.5" />
</Button> */}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import {
FileArchive,
FileAudio,
FileQuestion,
FileText,
FileVideo,
ImageIcon,
type LucideProps,
} from "lucide-react";
interface FileIconProps extends LucideProps {
contentType: string;
className?: string;
}
export function FileIcon({ contentType, className, ...props }: FileIconProps) {
const commonProps = { className: className ?? "h-5 w-5", ...props };
if (contentType.startsWith("image/")) {
return <ImageIcon {...commonProps} />;
}
if (contentType.startsWith("audio/")) {
return <FileAudio {...commonProps} />;
}
if (contentType.startsWith("video/")) {
return <FileVideo {...commonProps} />;
}
if (contentType === "application/pdf") {
return <FileText {...commonProps} />;
}
if (
contentType.startsWith("application/vnd.ms-excel") ||
contentType.startsWith("application/vnd.openxmlformats-officedocument.spreadsheetml")
) {
return <FileText {...commonProps} />; // Could use a specific Excel icon if available/desired
}
if (
contentType.startsWith("application/msword") ||
contentType.startsWith("application/vnd.openxmlformats-officedocument.wordprocessingml")
) {
return <FileText {...commonProps} />;
}
if (
contentType.startsWith("application/vnd.ms-powerpoint") ||
contentType.startsWith("application/vnd.openxmlformats-officedocument.presentationml")
) {
return <FileText {...commonProps} />;
}
if (
contentType === "application/zip" ||
contentType === "application/x-rar-compressed" ||
contentType === "application/x-7z-compressed" ||
contentType === "application/gzip" ||
contentType === "application/x-tar"
) {
return <FileArchive {...commonProps} />;
}
if (contentType.startsWith("text/")) {
return <FileText {...commonProps} />;
}
return <FileQuestion {...commonProps} />; // Default for unknown types
}

View File

@@ -1,51 +0,0 @@
// ~/components/global-audio-player.tsx
import { useEffect, useRef } from 'react';
import { useWebRTCStore } from '~/store/webrtc';
export function GlobalWebRTCAudioPlayer() {
const audioRef = useRef<HTMLAudioElement>(null);
const remoteStream = useWebRTCStore(state => state.remoteStream);
const webRTCStatus = useWebRTCStore(state => state.status);
useEffect(() => {
const audioElement = audioRef.current;
if (audioElement) {
if (remoteStream && webRTCStatus === 'CONNECTED') {
console.log('GlobalAudioPlayer: Setting remote stream to audio element.');
if (audioElement.srcObject !== remoteStream) { // Avoid unnecessary re-assignments
audioElement.srcObject = remoteStream;
audioElement.play().catch(error => {
// Autoplay policy might prevent play without user interaction.
// You might need a UI element for the user to click to start playback.
console.warn('GlobalAudioPlayer: Error trying to play audio automatically:', error);
// A common pattern is to mute the element initially and then allow unmuting by user action
// audioElement.muted = true; // Then provide an unmute button
});
}
} else {
// If stream is null or not connected, clear the srcObject
// This also handles the case when leaving a channel.
if (audioElement.srcObject) {
console.log('GlobalAudioPlayer: Clearing remote stream from audio element.');
audioElement.srcObject = null;
}
}
}
}, [remoteStream, webRTCStatus]); // Re-run when remoteStream or connection status changes
// This component renders the audio element.
// It's generally good practice for <audio> and <video> to have controls if user interaction is expected.
// For background audio, you might hide it, but be mindful of autoplay policies.
// `playsInline` is more relevant for video but doesn't hurt.
// `autoPlay` is desired but subject to browser restrictions.
return (
<audio
ref={audioRef}
autoPlay
playsInline
// controls // Optional: for debugging or if you want user to control volume/mute from here
// muted={true} // Start muted if autoplay is problematic, then provide an unmute button
style={{ display: 'none' }} // Hide it if it's just for background audio
/>
);
}

View File

@@ -1,5 +1,5 @@
import type { SVGProps } from "react";
const Discord = (props: SVGProps<SVGSVGElement>) => <svg viewBox="0 0 256 199" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" {...props}><path d="M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z" fill="#5865F2" /></svg>;
const Discord = (props: SVGProps<SVGSVGElement>) => <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 256 226" {...props}><path fill="#1185FE" d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z" /></svg>;
export default Discord;

View File

@@ -1,47 +1,42 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
import { useTokenStore } from '~/store/token';
import { ConnectionState } from '~/lib/websocket/gateway/types';
import { useGatewayStore } from '~/stores/gateway-store';
import { useTokenStore } from '~/stores/token-store';
export function GatewayWebSocketConnectionManager() {
const connectWebSocket = useGatewayWebSocketStore((state) => state.connect);
const disconnectWebSocket = useGatewayWebSocketStore((state) => state.disconnect);
const wsStatus = useGatewayWebSocketStore((state) => state.status);
const token = useTokenStore((state) =>
state.token,
);
const { setQueryClient } = useGatewayStore();
const queryClient = useQueryClient();
useEffect(() => {
console.debug(`WS Manager: Status (${wsStatus})`);
setQueryClient(queryClient);
}, [queryClient])
useEffect(() => {
const { status, connect, disconnect } = useGatewayStore.getState();
if (!!token) {
// Connect if we should be connected and are currently idle, disconnected, or errored out
if (wsStatus === 'IDLE' || wsStatus === 'DISCONNECTED' || wsStatus === 'ERROR') {
console.log("WS Manager: Conditions met. Calling connect...");
// Pass the stable token getter function reference
connectWebSocket(() => token);
}
connect(token);
} else {
// Disconnect if we shouldn't be connected and are currently in any connected/connecting state
if (wsStatus !== 'IDLE' && wsStatus !== 'DISCONNECTED') {
console.log("WS Manager: Conditions no longer met. Calling disconnect...");
disconnectWebSocket(true); // Intentional disconnect
if (status === ConnectionState.CONNECTED) {
disconnect();
}
}
// The disconnect logic for component unmount (e.g., user logs out entirely)
return () => {
// Check status on unmount to avoid disconnecting if already idle/disconnected
const currentStatus = useGatewayWebSocketStore.getState().status;
if (currentStatus !== 'IDLE' && currentStatus !== 'DISCONNECTED') {
console.log("WS Manager: Unmounting. Calling disconnect...");
// Ensure Zustand has the latest state before calling disconnect
useGatewayWebSocketStore.getState().disconnect(true); // Intentional disconnect on unmount
if (status === ConnectionState.CONNECTED) {
disconnect();
}
};
// Dependencies: connect/disconnect actions, auth status, route location
}, [connectWebSocket, disconnectWebSocket]);
}, [token]);
// This component doesn't render anything itself
return null;
return (
<>
{null}
</>
);
}

View File

@@ -1,185 +1,52 @@
// ~/components/webrtc-connection-manager.tsx
import { useEffect, useRef } from 'react'; // Removed useState
import { useActiveVoiceChannelStore } from '~/store/active-voice-channel';
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
import { useWebRTCStore } from '~/store/webrtc'; // Ensure WebRTCStatus is exported
import { useEffect, useRef } from "react";
import { ConnectionState } from "~/lib/websocket/voice/types";
import { useGatewayStore } from "~/stores/gateway-store";
import { useVoiceStateStore } from "~/stores/voice-state-store";
import { useWebRTCStore } from "~/stores/webrtc-store";
export function WebRTCConnectionManager() {
console.log('WebRTC Manager: Mounting component.');
const gateway = useGatewayStore();
const voiceState = useVoiceStateStore();
const webrtc = useWebRTCStore();
const {
serverId: activeServerId,
channelId: activeChannelId,
isVoiceActive,
} = useActiveVoiceChannelStore(state => ({
serverId: state.serverId,
channelId: state.channelId,
isVoiceActive: state.isVoiceActive,
}));
const remoteStream = useWebRTCStore(state => state.remoteStream);
const audioRef = useRef<HTMLAudioElement>(null)
const gatewayStatus = useGatewayWebSocketStore((state) => state.status);
const {
joinVoiceChannel,
leaveVoiceChannel,
status: webRTCStatus,
currentChannelId: rtcCurrentChannelId,
} = useWebRTCStore((state) => ({
joinVoiceChannel: state.joinVoiceChannel,
leaveVoiceChannel: state.leaveVoiceChannel,
status: state.status,
currentChannelId: state.currentChannelId,
}));
// Use useRef for the stream to avoid re-triggering effect on set
const mediaStreamRef = useRef<MediaStream | null>(null);
// Use useRef for an operation lock to prevent re-entrancy
const operationLockRef = useRef<boolean>(false);
if (audioRef.current) {
audioRef.current.srcObject = remoteStream
}
useEffect(() => {
console.log('WebRTC Manager: Effect triggered', {
activeServerId,
activeChannelId,
isVoiceActive,
gatewayStatus,
webRTCStatus,
rtcCurrentChannelId,
operationLock: operationLockRef.current,
const unsubscribe = gateway.onVoiceServerUpdate(async (event) => {
await webrtc.connect(event.token);
voiceState.joinVoiceChannel(event.serverId, event.channelId);
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
noiseSuppression: false,
},
video: false
});
webrtc.createOffer(stream);
});
const manageWebRTCConnection = async () => {
if (operationLockRef.current) {
console.debug('WebRTC Manager: Operation in progress, skipping.');
return;
}
const isConnectedToSomeChannel =
webRTCStatus !== "IDLE" &&
webRTCStatus !== "DISCONNECTED" &&
webRTCStatus !== "FAILED";
// --- Condition to JOIN/SWITCH voice ---
if (isVoiceActive && activeServerId && activeChannelId && gatewayStatus === 'CONNECTED') {
// Condition 1: Not connected at all, and want to join.
// Condition 2: Connected to a DIFFERENT channel, and want to switch.
const needsToJoinOrSwitch =
!isConnectedToSomeChannel || (rtcCurrentChannelId !== activeChannelId);
if (needsToJoinOrSwitch) {
operationLockRef.current = true;
console.log(`WebRTC Manager: Attempting to join/switch to ${activeServerId}/${activeChannelId}. Current RTC status: ${webRTCStatus}, current RTC channel: ${rtcCurrentChannelId}`);
// If currently connected to a different channel, leave it first.
if (isConnectedToSomeChannel && rtcCurrentChannelId && rtcCurrentChannelId !== activeChannelId) {
console.log(`WebRTC Manager: Leaving current channel ${rtcCurrentChannelId} before switching.`);
leaveVoiceChannel();
// leaveVoiceChannel will change webRTCStatus, triggering this effect again.
// The operationLock will be released when status becomes IDLE/DISCONNECTED.
// No 'return' here needed, let the status change from leave drive the next step.
// The lock is set, so next iteration won't try to join immediately.
// It will fall through to the lock release logic when state becomes IDLE.
} else { // Not connected or switching from a null/same channel (should be IDLE then)
let streamToUse = mediaStreamRef.current;
// Acquire media if we don't have a usable stream
if (!streamToUse || streamToUse.getTracks().every(t => t.readyState === 'ended')) {
if (streamToUse) { // Clean up old ended stream
streamToUse.getTracks().forEach(track => track.stop());
}
try {
console.log('WebRTC Manager: Acquiring new local media stream...');
streamToUse = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
mediaStreamRef.current = streamToUse;
} catch (err) {
console.error('WebRTC Manager: Failed to get user media:', err);
useWebRTCStore.setState({ status: "FAILED", lastError: 'Failed to get user media.' });
operationLockRef.current = false; // Release lock on failure
return; // Stop further processing for this run
}
}
if (streamToUse) {
console.log(`WebRTC Manager: Calling joinVoiceChannel for ${activeServerId}/${activeChannelId}`);
await joinVoiceChannel(activeServerId, activeChannelId, streamToUse);
// Don't release lock here immediately; let status changes from joinVoiceChannel
// (e.g., to CONNECTED or FAILED) handle releasing the lock.
} else {
console.error('WebRTC Manager: No media stream available to join channel.');
useWebRTCStore.setState({ status: "FAILED", lastError: 'Media stream unavailable.' });
operationLockRef.current = false; // Release lock
}
}
}
}
// --- Condition to LEAVE voice ---
else { // Not (isVoiceActive && activeServerId && activeChannelId && gatewayStatus === 'CONNECTED')
if (isConnectedToSomeChannel) {
operationLockRef.current = true;
console.log('WebRTC Manager: Conditions met to leave active voice channel. Leaving...', { isVoiceActive, activeServerId, gatewayStatus, webRTCStatus });
leaveVoiceChannel();
// Lock will be released when status becomes IDLE/DISCONNECTED.
}
}
// --- Manage operation lock based on final WebRTC state for this "cycle" ---
// This part is crucial: if an operation was started, the lock is only released
// when the WebRTC state settles into a terminal (IDLE, DISCONNECTED, FAILED) or successful (CONNECTED) state.
if (operationLockRef.current) { // Only if a lock was acquired in this effect run or previous
if (
webRTCStatus === "IDLE" ||
webRTCStatus === "DISCONNECTED" ||
webRTCStatus === "FAILED" ||
(webRTCStatus === "CONNECTED" && rtcCurrentChannelId === activeChannelId && isVoiceActive) // Successfully connected to desired channel
) {
// console.debug(`WebRTC Manager: Releasing operation lock. Status: ${webRTCStatus}`);
operationLockRef.current = false;
}
}
// --- Release media stream if no longer needed ---
// This should happen if WebRTC is inactive AND user doesn't want voice.
if (
mediaStreamRef.current &&
(webRTCStatus === "IDLE" || webRTCStatus === "DISCONNECTED" || webRTCStatus === "FAILED") &&
!isVoiceActive // Only if voice is explicitly deactivated
) {
console.log('WebRTC Manager: Releasing local media stream as WebRTC is inactive and voice is not desired.');
mediaStreamRef.current.getTracks().forEach(track => track.stop());
mediaStreamRef.current = null;
}
};
manageWebRTCConnection();
}, [
activeServerId,
activeChannelId,
isVoiceActive,
gatewayStatus,
webRTCStatus,
rtcCurrentChannelId,
joinVoiceChannel, // Stable from Zustand
leaveVoiceChannel, // Stable from Zustand
]);
// Cleanup on component unmount
useEffect(() => {
return () => {
console.log('WebRTC Manager: Unmounting component.');
// Ensure we attempt to leave if connection is active
const { status: currentRtcStatus, leaveVoiceChannel: finalLeave } = useWebRTCStore.getState();
if (currentRtcStatus !== "IDLE" && currentRtcStatus !== "DISCONNECTED") {
console.log('WebRTC Manager: Unmounting. Leaving active voice channel.');
finalLeave();
}
// Stop any tracks held by the ref
if (mediaStreamRef.current) {
console.log('WebRTC Manager: Unmounting. Stopping tracks from mediaStreamRef.');
mediaStreamRef.current.getTracks().forEach(track => track.stop());
mediaStreamRef.current = null;
}
voiceState.leaveVoiceChannel();
unsubscribe();
};
}, []); // Empty dependency array for unmount cleanup only
}, []);
return null;
useEffect(() => {
if (webrtc.status === ConnectionState.DISCONNECTED) {
voiceState.leaveVoiceChannel();
}
}, [webrtc.status]);
return (
<>
<audio autoPlay ref={audioRef} className="hidden" />
</>
);
}

View File

@@ -0,0 +1,103 @@
import { Plus, Send, Trash } from "lucide-react"
import React from "react"
import { sendMessage } from "~/lib/api/client/channel"
import { uploadFiles } from "~/lib/api/client/file"
import TextBox from "./custom-ui/text-box"
import { Button } from "./ui/button"
export interface MessageBoxProps {
channelId: string
}
export default function MessageBox(
{ channelId }: MessageBoxProps
) {
const [text, setText] = React.useState("")
const [attachments, setAttachments] = React.useState<File[]>([])
const fileInputRef = React.useRef<HTMLInputElement>(null)
const onSendMessage = async () => {
const content = text.trim()
if (!content && !attachments.length)
return
const uploadedAttachments = await uploadFiles(attachments)
await sendMessage(channelId, text, uploadedAttachments)
setText("")
setAttachments([])
}
const addAttachment = async () => {
if (!fileInputRef.current)
return
fileInputRef.current.click()
}
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
const newAttachments = [...attachments, ...files]
setAttachments(newAttachments.slice(0, 10))
if (!fileInputRef.current)
return
fileInputRef.current.value = ""
}
React.useEffect(() => {
setText("")
setAttachments([])
}, [channelId])
return (
<div className="p-2">
<div className="max-h-1/2 w-full max-w-full grid items-center gap-x-2 outline-none py-4 px-2 rounded-xl no-scrollbar bg-background border border-input focus-within:ring-2 focus-within:ring-ring focus-within:border-ring"
style={{ gridTemplateColumns: "auto 1fr auto", gridTemplateRows: "1fr auto" }}>
<div className="row-start-1 col-start-1 row-span-2 col-span-1 h-full">
<Button size="icon" variant="ghost" onClick={addAttachment} disabled={attachments.length >= 10}>
<Plus />
</Button>
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={onFileChange} />
</div>
<div className="flex-1 row-start-1 col-start-2 row-span-1 col-span-1">
<TextBox value={text}
wrapperClassName="contain-inline-size"
onChange={setText}
placeholder="Type your message here..."
aria-label="Message input">
</TextBox>
</div>
<div className="flex-1 row-start-2 col-start-2 row-span-1 col-span-1 overflow-y-auto max-h-40">
<div className={`${attachments.length > 0 ? "pt-2" : "hidden"}`}>
<div className="flex flex-col gap-2">
{
attachments.map((file, i) => (
<div key={i} className="flex items-center gap-2 wrap-anywhere rounded-xl border border-input bg-background p-2">
<div className="flex-1">
{file.name}
</div>
<div>
<Button size="icon" variant="destructive" onClick={() => setAttachments(attachments.filter((_, j) => i !== j))}>
<Trash />
</Button>
</div>
</div>
))
}
</div>
</div>
</div>
<div className="row-start-1 col-start-3 row-span-2 col-span-1 h-full">
<Button size="icon" onClick={onSendMessage}>
<Send />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ChannelType } from "~/lib/api/types";
import { ModalType, useModalStore, type CreateServerChannelModalData } from "~/stores/modal-store";
import { Button } from "../ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
const schema = z.object({
name: z.string().min(1).max(32),
type: z.nativeEnum(ChannelType),
});
export default function CreateServerChannelModal() {
const { type, data, isOpen, onClose } = useModalStore();
const isModalOpen = type === ModalType.CREATE_SERVER_CHANNEL && isOpen
let form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
type: ChannelType.SERVER_TEXT
}
});
const onOpenChange = () => {
form.reset()
onClose()
}
const onSubmit = async (values: z.infer<typeof schema>) => {
const response = await import("~/lib/api/client/server").then(m => m.default.createChannel((data as CreateServerChannelModalData['data']).serverId, values))
form.reset()
onClose()
}
return (
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create channel</DialogTitle>
<DialogDescription>
Give your channel a name and choose a channel type.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required={!schema.shape.name.isOptional()}>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel required={!schema.shape.type.isOptional()}>Type</FormLabel>
<Select defaultValue={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a channel type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries({
[ChannelType.SERVER_TEXT]: "Text",
[ChannelType.SERVER_VOICE]: "Voice",
}).map(([type, label]) => (
<SelectItem key={type} value={type}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className=" justify-between">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,66 @@
import { Check, Copy, RefreshCw } from "lucide-react";
import React from "react";
import { useOrigin } from "~/hooks/use-origin";
import { ModalType, useModalStore } from "~/stores/modal-store";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
export default function CreateServerInviteModal() {
const { type, data, isOpen, onClose } = useModalStore();
const [inviteCode, setInviteCode] = React.useState<string | undefined>(undefined)
const origin = useOrigin()
const [isCopied, setCopied] = React.useState(false)
const isModalOpen = type === ModalType.CREATE_SERVER_INVITE && isOpen
const inviteLink = `${origin}/app/invite/${inviteCode}`
const onOpenChange = (openState: boolean) => {
onClose()
}
const regenerateInviteCode = () => {
import("~/lib/api/client/server").then(m => m.default.createInvite((data as { serverId: string }).serverId)).then(invite => { setInviteCode(invite.code) })
}
const onCopy = () => {
navigator.clipboard.writeText(inviteLink)
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}
React.useEffect(() => {
if (isModalOpen) {
import("~/lib/api/client/server").then(m => m.default.createInvite((data as { serverId: string }).serverId)).then(invite => { setInviteCode(invite.code) })
} else {
setInviteCode(undefined)
}
}, [isModalOpen])
return (
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite your friends</DialogTitle>
</DialogHeader>
<Label>Server invite link</Label>
<div className="flex items-center gap-x-2">
<Input value={inviteLink} readOnly />
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
<DialogFooter>
<Button variant="link" size="none" className="h-9 py-2" onClick={regenerateInviteCode}>
Generate a new invite
<RefreshCw />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,59 +1,54 @@
import { CirclePlus } from "lucide-react";
import React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import file from "~/lib/api/client/file";
import server from "~/lib/api/client/server";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { IconUploadField } from "./ui/icon-upload-field";
import { Input } from "./ui/input";
import { ModalType, useModalStore } from "~/stores/modal-store";
import { Button } from "../ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { IconUploadField } from "../ui/icon-upload-field";
import { Input } from "../ui/input";
const schema = z.object({
name: z.string().min(1).max(32),
icon: z.instanceof(File).optional(),
});
export function CreateServerButton() {
const [open, setOpen] = React.useState(false);
export default function CreateServerModal() {
const { type, isOpen, onClose } = useModalStore();
const isModalOpen = type === ModalType.CREATE_SERVER && isOpen
let form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
});
function onOpenChange(openState: boolean) {
setOpen(openState)
const onOpenChange = (openState: boolean) => {
form.reset()
onClose()
}
if (!openState) {
form.reset()
const onSubmit = async (values: z.infer<typeof schema>) => {
let iconId = undefined
if (values.icon) {
iconId = (await file.uploadFile(values.icon))[0]
}
const response = await server.create({
name: values.name,
iconId
})
form.reset()
onClose()
}
async function onSubmit(values: z.infer<typeof schema>) {
const response = await server.create(values)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="secondary" size="none">
<CirclePlus className="size-8 m-2" />
</Button>
</DialogTrigger>
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create server</DialogTitle>

View File

@@ -0,0 +1,40 @@
import { ModalType, useModalStore } from "~/stores/modal-store";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
export default function DeleteServerConfirmModal() {
const { type, data, isOpen, onClose } = useModalStore();
const isModalOpen = type === ModalType.DELETE_SERVER_CONFIRM && isOpen
const onOpenChange = () => {
onClose()
}
const onConfirm = async () => {
await import("~/lib/api/client/server").then(m => m.default.delet((data as { serverId: string }).serverId))
onClose()
}
return (
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="justify-between">
<Button variant="default" onClick={onClose}>
No
</Button>
<Button variant="destructive" onClick={onConfirm}>
Yes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useShallow } from "zustand/react/shallow";
import file from "~/lib/api/client/file";
import { patchUser } from "~/lib/api/client/user";
import { ModalType, useModalStore } from "~/stores/modal-store";
import { useUsersStore } from "~/stores/users-store";
import { Button } from "../ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { IconUploadField } from "../ui/icon-upload-field";
import { Input } from "../ui/input";
const schema = z.object({
displayName: z.string().min(1).max(32).optional().nullable(),
avatar: z.instanceof(File).optional().nullable(),
});
export default function UpdateProfileModal() {
const { type, isOpen, onClose } = useModalStore();
const user = useUsersStore(useShallow(state => state.getCurrentUser()))
const isModalOpen = type === ModalType.UPDATE_PROFILE && isOpen
let form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
});
const onOpenChange = () => {
form.reset()
onClose()
}
const onSubmit = async (values: z.infer<typeof schema>) => {
if (!values)
return
let avatarId = undefined
if (values.avatar) {
avatarId = (await file.uploadFile(values.avatar))[0]
}
const response = await patchUser({
displayName: values.displayName,
avatarId
})
form.reset()
onClose()
}
return (
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update profile</DialogTitle>
<DialogDescription>
Update your profile.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="avatar"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel required={!schema.shape.avatar.isOptional()}>Avatar</FormLabel>
<FormControl>
<div className="flex flex-col items-center justify-center">
<IconUploadField
field={field}
error={fieldState.error}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel required={!schema.shape.displayName.isOptional()}>Display Name</FormLabel>
<FormControl>
<Input {...field} placeholder={user?.displayName} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className=" justify-between">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Updating..." : "Update"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,43 +0,0 @@
import { Check } from "lucide-react"
import { NavLink } from "react-router"
import type { RecipientChannel } from "~/lib/api/types"
import { cn } from "~/lib/utils"
import { useUserStore } from "~/store/user"
import { OnlineStatus } from "./online-status"
import { Avatar, AvatarImage } from "./ui/avatar"
import { Badge } from "./ui/badge"
interface PrivateChannelListItemProps {
channel: RecipientChannel
}
export default function PrivateChannelListItem({ channel }: PrivateChannelListItemProps) {
const userId = useUserStore(state => state.user?.id)
const recipients = channel.recipients.filter(recipient => recipient.id !== userId);
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
return (
<>
<NavLink to={`/app/@me/channels/${channel.id}`} className={({ isActive }) =>
cn(
"rounded-xl justify-start",
isActive ? "bg-primary/20" : "bg-accent",
)
}>
<div className="flex items-center gap-2 max-w-72 p-2">
<div>
<OnlineStatus status="online">
<Avatar>
<AvatarImage src={`https://api.dicebear.com/9.x/bottts/jpg?seed=${channel.name}`} />
</Avatar>
</OnlineStatus>
</div>
<div className="truncate">
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
</div>
{renderSystemBadge && <Badge variant="default"> <Check /> System</Badge>}
</div>
</NavLink>
</>
)
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import CreateServerChannelModal from "../modals/create-server-channel-modal";
import CreateServerInviteModal from "../modals/create-server-invite-modal";
import CreateServerModal from "../modals/create-server-modal";
import DeleteServerConfirmModal from "../modals/delete-server-confirm-modal";
import UpdateProfileModal from "../modals/update-profile-modal";
export default function ModalProvider() {
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return (
<>
<CreateServerModal />
<CreateServerChannelModal />
<CreateServerInviteModal />
<DeleteServerConfirmModal />
<UpdateProfileModal />
</>
);
}

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
export type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode

View File

@@ -1,16 +1,17 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "~/components/theme/theme-provider"
import { useTheme, type Theme } from "~/components/theme/theme-provider"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
const { theme, setTheme } = useTheme()
return (
<DropdownMenu>
@@ -21,16 +22,18 @@ export function ThemeToggle() {
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={theme} onValueChange={(value) => setTheme(value as Theme)}>
<DropdownMenuRadioItem value="light">
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,9 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

View File

@@ -7,9 +7,11 @@ function ScrollArea({
className,
children,
scrollbarSize,
viewportRef,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
scrollbarSize?: "default" | "narrow" | "none"
scrollbarSize?: "default" | "narrow" | "none",
viewportRef?: React.Ref<HTMLDivElement>
}) {
return (
<ScrollAreaPrimitive.Root
@@ -18,6 +20,7 @@ function ScrollArea({
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,19 @@
import type { PartialUser } from "~/lib/api/types"
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
interface UserAvatarProps {
user: PartialUser | undefined
}
export default function UserAvatar(
{ user, ...props }: UserAvatarProps & React.ComponentProps<typeof Avatar>
) {
return (
<Avatar {...props}>
<AvatarImage src={user?.avatarUrl} />
<AvatarFallback className="text-muted-foreground">
{user?.username?.[0]}
</AvatarFallback>
</Avatar>
)
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react';
interface VisibleTriggerProps {
onVisible: () => void | Promise<void>;
options?: IntersectionObserverInit;
triggerOnce?: boolean;
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* A component that calls a function when it becomes visible in the viewport
* or a specified scrollable container.
*/
export default function VisibleTrigger({
onVisible, // Function to call when the element becomes visible
options = {}, // Optional: IntersectionObserver options (root, rootMargin, threshold)
triggerOnce = true, // Optional: If true, trigger only the first time it becomes visible
children,
style,
...props
}: VisibleTriggerProps & React.ComponentProps<'div'>) {
const elementRef = useRef(null); // Ref to attach to the DOM element we want to observe
useEffect(() => {
const element = elementRef.current;
// Only proceed if we have the DOM element and the function to call
if (!element) {
return;
}
// Default IntersectionObserver options
const defaultOptions = {
root: null, // default is the viewport
rootMargin: '0px', // No margin by default
threshold: 0, // Trigger as soon as any part of the element is visible
};
// Merge provided options with defaults
const observerOptions = { ...defaultOptions, ...options };
// Create the Intersection Observer instance
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]; // Assuming only one target element
// If the element is intersecting (visible)...
if (entry.isIntersecting) {
// console.log('VisibleTrigger: Element is intersecting.', entry);
// Call the provided function
onVisible();
// If triggerOnce is true, stop observing this element immediately
if (triggerOnce) {
// console.log('VisibleTrigger: Triggered once, disconnecting observer.');
observer.disconnect(); // Disconnect stops all observations by this instance
}
} else {
// console.log('VisibleTrigger: Element is NOT intersecting.', entry);
}
},
observerOptions // Pass the options to the observer
);
// Start observing the element
// console.log('VisibleTrigger: Starting observation.', element);
observer.observe(element);
// Cleanup function: Disconnect the observer when the component unmounts
// or when the effect dependencies change.
return () => {
// console.log('VisibleTrigger: Cleaning up observer.');
if (observer) {
// Calling disconnect multiple times is safe.
observer.disconnect();
}
};
// Effect dependencies:
// - elementRef: Need the DOM element reference.
// - onVisible: If the function prop changes, we need a new observer with the new function.
// - options: If observer options change, we need a new observer.
// - triggerOnce: If triggerOnce changes, the logic inside the observer callback changes,
// so we need a new observer instance.
}, [elementRef, onVisible, options, triggerOnce]);
// Render a div that we will attach the ref to.
// Ensure it has some minimal dimension if no children are provided,
// so the observer can detect its presence.
return (
<div
ref={elementRef}
style={{ minHeight: children ? 'auto' : '1px', ...style }}
{...props}
>
{children} {/* Render any children passed to the component */}
</div>
);
};

17
app/hooks/use-origin.ts Normal file
View File

@@ -0,0 +1,17 @@
import React from "react";
export const useOrigin = () => {
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
setIsMounted(true);
}, []);
const origin = typeof window !== 'undefined' && window.location.origin ? window.location.origin : '';
if (!isMounted) {
return '';
}
return origin;
}

View File

@@ -1,5 +1,5 @@
import axios from "../http-client"
import type { User } from "../types"
import type { FullUser } from "../types"
interface RegisterRequest {
email: string
@@ -14,7 +14,7 @@ interface LoginRequest {
}
interface LoginResponse {
user: User
user: FullUser
token: string
}

View File

@@ -0,0 +1,35 @@
import axios from "../http-client";
import { messageSchema, type ChannelId, type Uuid } from "../types";
export async function paginatedMessages(
channelId: ChannelId,
limit: number,
before: ChannelId | undefined,
) {
const response = await axios.get(`/channels/${channelId}/messages`, {
params: {
limit,
before,
}
})
return (response.data as any[]).map((value, _) => messageSchema.parse(value))
}
export async function sendMessage(
channelId: ChannelId,
content: string,
attachments?: Uuid[]
) {
const response = await axios.post(`/channels/${channelId}/messages`, {
content,
attachments,
})
return messageSchema.parse(response.data)
}
export default {
paginatedMessages,
sendMessage,
}

View File

@@ -0,0 +1,26 @@
import axios from "../http-client"
export async function uploadFile(file: File) {
const formData = new FormData()
formData.append("files", file)
const response = await axios.postForm(`/files`, formData)
return response.data as string[]
}
export async function uploadFiles(file: File[]) {
const formData = new FormData()
for (const f of file) {
formData.append("files", f)
}
const response = await axios.postForm(`/files`, formData)
return response.data as string[]
}
export default {
uploadFile,
uploadFiles
}

View File

@@ -1,15 +1,14 @@
import axios from "../http-client"
import type { Server } from "../types"
import type { ChannelId, ChannelType, Server, ServerChannel, ServerId, ServerInvite } from "../types"
interface CreateServerRequest {
name: string
icon?: File
iconId?: string
}
export async function create(request: CreateServerRequest) {
const response = await axios.postForm("/servers", request)
return response.data as Server
interface CreateServerChannelRequest {
name: string
type: ChannelType
}
export async function list() {
@@ -18,7 +17,69 @@ export async function list() {
return response.data as Server[]
}
export async function create(request: CreateServerRequest) {
const response = await axios.post("/servers", request)
return response.data as Server
}
export async function get(serverId: ServerId) {
const response = await axios.get(`/servers/${serverId}`)
return response.data as Server
}
export async function delet(serverId: ServerId) {
const response = await axios.delete(`/servers/${serverId}`)
return response.data as Server
}
export async function listChannels(serverId: ServerId) {
const response = await axios.get(`/servers/${serverId}/channels`)
return response.data as ServerChannel[]
}
export async function createChannel(serverId: ServerId, request: CreateServerChannelRequest) {
const response = await axios.post(`/servers/${serverId}/channels`, request)
return response.data as ServerChannel
}
export async function getChannel(serverId: ServerId, channelId: ChannelId) {
const response = await axios.get(`/servers/${serverId}/channels/${channelId}`)
return response.data as ServerChannel
}
export async function deleteChannel(serverId: ServerId, channelId: ChannelId) {
const response = await axios.delete(`/servers/${serverId}/channels/${channelId}`)
return response.data as ServerChannel
}
export async function createInvite(serverId: ServerId) {
const response = await axios.post(`/servers/${serverId}/invites`)
return response.data as ServerInvite
}
export async function getInvite(inviteCode: string) {
const response = await axios.get(`/invites/${inviteCode}`)
return response.data as Server
}
export default {
create,
list,
listChannels,
get,
delet,
createChannel,
getChannel,
deleteChannel,
createInvite,
getInvite
}

View File

@@ -1,14 +0,0 @@
import axios from "../http-client"
import type { Uuid } from "../types"
export async function test(channel_id: Uuid, sdp: RTCSessionDescriptionInit) {
const response = await axios.post(`/voice/${channel_id}/connect`, {
sdp: sdp
})
return response.data.sdp as RTCSessionDescriptionInit
}
export default {
test
}

View File

@@ -1,10 +1,16 @@
import axios from "../http-client"
import type { RecipientChannel, User } from "../types"
import type { FullUser, PartialUser, RecipientChannel, UserId, Uuid } from "../types"
export async function me() {
const response = await axios.get("/users/@me")
return response.data as User
return response.data as FullUser
}
export async function getUser(userId: UserId) {
const response = await axios.get(`/users/${userId}`)
return response.data as PartialUser
}
export async function channels() {
@@ -13,7 +19,20 @@ export async function channels() {
return response.data as RecipientChannel[]
}
interface PatchUserRequest {
displayName?: string | null
avatarId?: Uuid | null
}
export async function patchUser(request: PatchUserRequest) {
const response = await axios.patch(`/users/@me`, request)
return response.data as FullUser
}
export default {
me,
channels
channels,
getUser,
patchUser
}

View File

@@ -1,5 +1,5 @@
import axios from "axios"
import { useTokenStore } from "~/store/token"
import { useTokenStore } from "~/stores/token-store"
import { API_URL } from "../consts"
axios.interceptors.request.use(

View File

@@ -1,7 +1,45 @@
import { z } from "zod";
export type TypeToZod<T> = {
[K in keyof T]:
// 1. Handle Arrays (including arrays of objects, optional or required)
T[K] extends ReadonlyArray<infer E> | undefined
? undefined extends T[K]
? E extends object
? z.ZodOptional<z.ZodArray<z.ZodObject<TypeToZod<E>>>>
: z.ZodOptional<z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>>
: E extends object
? z.ZodArray<z.ZodObject<TypeToZod<E>>>
: z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>
// 2. Handle Primitives
: T[K] extends string | number | boolean | Date | null | undefined
? undefined extends T[K]
? z.ZodOptional<z.ZodType<Exclude<T[K], undefined | null>>>
: z.ZodType<T[K]>
// 3. Handle Objects (required or optional, but not arrays)
: T[K] extends object | undefined
? undefined extends T[K]
? z.ZodOptional<z.ZodObject<TypeToZod<NonNullable<T[K]>>>>
: T[K] extends object
? z.ZodObject<TypeToZod<T[K]>>
: z.ZodUnknown // Fallback for unexpected required non-object/non-primitive types
// 4. Fallback for any other types
: z.ZodUnknown;
};
export const createZodObject = <T>(obj: TypeToZod<T>) => {
return z.object(obj);
};
export type Uuid = string
export interface User {
id: Uuid
export type UserId = Uuid
export type ServerId = Uuid
export type ChannelId = Uuid
export type MessageId = Uuid
export interface FullUser {
id: UserId
avatarUrl?: string
username: string
displayName?: string
@@ -12,22 +50,88 @@ export interface User {
}
export interface Server {
id: Uuid
id: ServerId
name: string
icon_url?: string
owner: Uuid
iconUrl?: string
ownerId: UserId
}
export enum ChannelType {
SERVER_TEXT = 'server_text',
SERVER_VOICE = 'server_voice',
SERVER_CATEGORY = 'server_category',
DIRECT_MESSAGE = 'direct_message',
GROUP = 'group',
}
export interface Message {
id: MessageId
channelId: ChannelId
authorId: UserId
content: string
createdAt: Date
attachments: UploadedFile[]
}
export interface UploadedFile {
id: Uuid
filename: string
contentType: string
size: number
url: string
}
export const uploadFileSchema = createZodObject<UploadedFile>({
id: z.string(),
filename: z.string(),
contentType: z.string(),
size: z.number(),
url: z.string(),
})
export const messageSchema = createZodObject<Message>({
id: z.string(),
channelId: z.string(),
authorId: z.string(),
content: z.string(),
createdAt: z.coerce.date(),
attachments: z.array(uploadFileSchema),
})
export interface Channel {
id: ChannelId
name: string
type: ChannelType
lastMessageId?: MessageId
}
export interface ServerChannel {
id: ChannelId
name: string
type: ChannelType
lastMessageId?: MessageId
serverId: ServerId
parentId?: ChannelId
}
export interface ServerInvite {
code: string
serverId: ServerId
inviterId?: UserId
expiresAt?: string
}
export interface RecipientChannel {
id: Uuid
id: ChannelId
name: string
type: string
lastMessageId?: Uuid
type: ChannelType
lastMessageId?: MessageId
recipients: PartialUser[]
}
export interface PartialUser {
id: Uuid
id: ChannelId
username: string
displayName?: string
avatarUrl?: string,

View File

@@ -12,3 +12,32 @@ export function getFirstLetters(str: string, n: number): string {
.map(word => word[0] || '')
.join('');
}
export function formatFileSize(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function createPrefixedLogger(prefix: string, styles?: string[]) {
const result: Record<string, (...args: any[]) => void> = {};
const methods = ['log', 'trace', 'debug', 'info', 'warn', 'error'] as const;
for (const methodName of methods) {
const originalMethod = console[methodName].bind(console);
result[methodName] = (...args: any[]) => {
if (typeof args[0] === 'string') {
originalMethod(`${prefix} ${args[0]}`, ...(styles || []), ...args.slice(1));
} else {
originalMethod(prefix, styles, ...args);
}
};
}
return result;
}

View File

@@ -1,79 +0,0 @@
// ~/lib/websocket/types.ts
import type { Server, Uuid } from "../api/types";
export type WebSocketStatus =
| "IDLE" // Initial state, not connected
| "CONNECTING" // Attempting to connect
| "AUTHENTICATING"// Connection open, sending/awaiting auth
| "CONNECTED" // Authenticated and operational
| "DISCONNECTED" // User or clean disconnect, no auto-reconnect
| "RECONNECTING" // Attempting to reconnect after an unexpected disconnect
| "ERROR"; // Error state, usually after server reports an error, no auto-reconnect
// --- Client to Server Messages ---
interface AuthenticateClientPayload {
token: string;
}
interface AuthenticateClientMessage {
type: "AUTHENTICATE";
data: AuthenticateClientPayload;
}
export interface VoiceStateUpdateClientPayload {
serverId: Uuid;
channelId: Uuid;
}
interface VoiceStateUpdateClientMessage {
type: "VOICE_STATE_UPDATE";
data: VoiceStateUpdateClientPayload;
}
export type ClientMessage =
| AuthenticateClientMessage
| VoiceStateUpdateClientMessage;
// --- Server to Client Messages ---
interface AuthenticateAcceptedServerPayload {
userId: Uuid; // Or number, depending on your entity::user::Id serialization
sessionKey: string; // A unique identifier for the user's session
}
export interface AuthenticateAcceptedServerMessage { // Export if used directly
type: "AUTHENTICATE_ACCEPTED";
data: AuthenticateAcceptedServerPayload;
}
export interface AuthenticateDeniedServerMessage { // Export if used directly
type: "AUTHENTICATE_DENIED";
// No data for this message
}
export type AddServerEvent = { type: 'ADD_SERVER'; data: { server: Server } };
export type RemoveServerEvent = { type: 'REMOVE_SERVER'; data: { serverId: Uuid } };
export type VoiceServerUpdateServerEvent = { type: 'VOICE_SERVER_UPDATE'; data: { serverId: Uuid, channelId: Uuid, token: string } };
// Union of all your specific business events
export type EventServerPayload = AddServerEvent | RemoveServerEvent | VoiceServerUpdateServerEvent; // Add other events here
interface EventServerMessage {
type: "EVENT";
data: {
event: EventServerPayload; // The actual event object
};
}
interface ErrorServerPayload {
code: string;
}
export interface ErrorServerMessage { // Export if used directly
type: "ERROR";
data: ErrorServerPayload;
}
export type ServerMessage =
| AuthenticateAcceptedServerMessage
| AuthenticateDeniedServerMessage
| EventServerMessage
| ErrorServerMessage;

View File

@@ -0,0 +1,308 @@
import type { ChannelId, ServerId, UserId } from '~/lib/api/types';
import { createPrefixedLogger } from '~/lib/utils';
import {
type ClientMessage,
ClientMessageType,
ConnectionState,
ErrorCode,
type EventData,
EventType,
type ServerMessage,
ServerMessageType
} from './types';
export type GatewayEvents = {
[K in EventType]: (data: Extract<EventData, { type: K }>['data']) => void;
};
export type ControlEvents = {
stateChange: (state: ConnectionState) => void;
error: (error: Error, code?: ErrorCode) => void;
authenticated: (userId: UserId, sessionKey: string) => void;
};
export interface GatewayClientOptions {
reconnect?: boolean;
reconnectDelay?: number;
maxReconnectAttempts?: number;
}
export class GatewayClient {
private socket: WebSocket | null = null;
private state: ConnectionState = ConnectionState.DISCONNECTED;
private url: string;
private token: string | null = null;
private sessionKey: string | null = null;
private userId: string | null = null;
private reconnectAttempts = 0;
private reconnectTimeout: NodeJS.Timeout | null = null;
private eventHandlers: Partial<ControlEvents> = {};
private serverEventHandlers: Partial<GatewayEvents> = {};
private options: Required<GatewayClientOptions>;
private closeInitiatedByClient = false;
private connectionLock = false;
constructor(url: string, options: GatewayClientOptions = {}) {
this.url = url;
this.options = {
reconnect: options.reconnect ?? true,
reconnectDelay: options.reconnectDelay ?? 5000,
maxReconnectAttempts: options.maxReconnectAttempts ?? 10
};
}
// Public methods
public connect(token: string): void {
logger.log('Connecting to %s', this.url);
if (this.connectionLock) {
logger.warn('Connection already in progress');
return;
}
if (this.token === token) {
logger.warn('Token is the same as the current token');
return;
}
this.connectionLock = true;
if (this.state !== ConnectionState.DISCONNECTED) {
this.disconnect();
}
this.token = token;
this.closeInitiatedByClient = false;
this.reconnectAttempts = 0;
this.connectToWebSocket();
}
public disconnect(): void {
logger.log('Disconnecting');
this.closeInitiatedByClient = true;
this.cleanupSocket();
this.setState(ConnectionState.DISCONNECTED);
this.connectionLock = false;
}
public updateVoiceState(serverId: ServerId, channelId: ChannelId): void {
this.sendMessage({
type: ClientMessageType.VOICE_STATE_UPDATE,
data: { serverId, channelId }
});
}
public requestVoiceStates(serverId: ServerId): void {
this.sendMessage({
type: ClientMessageType.REQUEST_VOICE_STATES,
data: { serverId }
});
}
public onEvent<K extends keyof GatewayEvents>(event: K | string, handler: GatewayEvents[K]): void {
this.serverEventHandlers[event as K] = handler;
}
public offEvent<K extends keyof GatewayEvents>(event: K): void {
delete this.serverEventHandlers[event];
}
public onControl<K extends keyof ControlEvents>(event: K, handler: ControlEvents[K]): void {
this.eventHandlers[event] = handler;
}
public offControl<K extends keyof ControlEvents>(event: K): void {
delete this.eventHandlers[event];
}
public get connectionState(): ConnectionState {
return this.state;
}
public get currentUserId(): UserId | null {
return this.userId;
}
public get currentSessionKey(): string | null {
return this.sessionKey;
}
// Private methods
private connectToWebSocket(): void {
this.setState(ConnectionState.CONNECTING);
try {
this.socket = new WebSocket(this.url);
this.socket.onopen = this.onSocketOpen.bind(this);
this.socket.onmessage = this.onSocketMessage.bind(this);
this.socket.onerror = this.onSocketError.bind(this);
this.socket.onclose = this.onSocketClose.bind(this);
} catch (error) {
this.emitError(new Error('Failed to create WebSocket connection'));
this.setState(ConnectionState.ERROR);
}
}
private onSocketOpen(): void {
this.connectionLock = false;
logger.log('Socket opened');
this.setState(ConnectionState.AUTHENTICATING);
if (this.token) {
this.sendMessage({
type: ClientMessageType.AUTHENTICATE,
data: { token: this.token }
});
} else {
this.emitError(new Error('No authentication token provided'));
this.disconnect();
}
}
private onSocketMessage(event: MessageEvent): void {
try {
const message = JSON.parse(event.data) as ServerMessage;
this.handleServerMessage(message);
} catch (error) {
this.emitError(new Error('Failed to parse WebSocket message', { cause: error }));
}
}
private onSocketError(event: Event): void {
this.connectionLock = false;
logger.log('Socket error: %s', event);
this.emitError(new Error('WebSocket error occurred'));
}
private onSocketClose(event: CloseEvent): void {
logger.log('Socket closed: %s', event);
this.connectionLock = false;
if (
this.options.reconnect &&
!this.closeInitiatedByClient &&
this.reconnectAttempts < this.options.maxReconnectAttempts
) {
logger.log('Reconnecting in %d seconds (%d/%d)', this.options.reconnectDelay / 1000, this.reconnectAttempts + 1, this.options.maxReconnectAttempts);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
if (this.token) {
this.connectToWebSocket();
}
}, this.options.reconnectDelay);
} else {
this.setState(ConnectionState.DISCONNECTED);
}
}
private handleServerMessage(message: ServerMessage): void {
logger.log('Received message: ', message);
switch (message.type) {
case ServerMessageType.AUTHENTICATE_ACCEPTED:
this.userId = message.data.userId;
this.sessionKey = message.data.sessionKey;
this.setState(ConnectionState.CONNECTED);
this.emitControl('authenticated', message.data.userId, message.data.sessionKey);
break;
case ServerMessageType.AUTHENTICATE_DENIED:
this.emitError(new Error('Authentication denied'));
this.disconnect();
break;
case ServerMessageType.ERROR:
this.emitError(new Error(`Server error: ${message.data.code}`), message.data.code);
break;
case ServerMessageType.EVENT:
this.handleEventMessage(message.data.event);
break;
default:
console.warn('Unhandled server message type:', message);
}
}
private handleEventMessage(event: EventData): void {
this.emitEvent(event.type, event.data as any);
}
private sendMessage(message: ClientMessage): void {
logger.log('Sending message: %o', message);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
this.emitError(new Error('Cannot send message: socket not connected'));
}
}
private setState(state: ConnectionState): void {
if (this.state !== state) {
logger.log('State changed to %s', state);
this.state = state;
this.emitControl('stateChange', state);
}
}
private emitError(error: Error, code?: ErrorCode): void {
logger.error('Error: %s', error, error.cause);
this.setState(ConnectionState.ERROR);
this.emitControl('error', error, code);
}
private emitControl<K extends keyof ControlEvents>(event: K, ...args: Parameters<ControlEvents[K]>): void {
const handler = this.eventHandlers[event];
if (handler) {
(handler as Function)(...args);
}
}
private emitEvent<K extends keyof GatewayEvents>(event: K, ...args: Parameters<GatewayEvents[K]>): void {
const handler = this.serverEventHandlers[event];
if (handler) {
(handler as Function)(...args);
}
}
private cleanupSocket(): void {
logger.log('Cleaning up socket');
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.socket) {
// Remove all event listeners
this.socket.onopen = null;
this.socket.onmessage = null;
this.socket.onerror = null;
this.socket.onclose = null;
// Close the connection if it's still open
if (this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING) {
this.socket.close();
}
this.socket = null;
}
this.sessionKey = null;
this.userId = null;
}
}
const logger = createPrefixedLogger('%cGateway WS%c:', ['color: red; font-weight: bold;', '']);

View File

@@ -0,0 +1,249 @@
import type { ChannelId, Message, MessageId, PartialUser, Server, ServerId, UserId } from "~/lib/api/types";
type Channel = any; // TODO: Define Channel type
export enum ServerMessageType {
AUTHENTICATE_ACCEPTED = 'AUTHENTICATE_ACCEPTED',
AUTHENTICATE_DENIED = 'AUTHENTICATE_DENIED',
EVENT = 'EVENT',
ERROR = 'ERROR'
}
export enum ClientMessageType {
AUTHENTICATE = 'AUTHENTICATE',
VOICE_STATE_UPDATE = 'VOICE_STATE_UPDATE',
REQUEST_VOICE_STATES = 'REQUEST_VOICE_STATES',
}
// Error codes from the server
export enum ErrorCode {
AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
TOKEN_GENERATION_FAILED = 'TOKEN_GENERATION_FAILED'
}
// Event types from the server
export enum EventType {
ADD_SERVER = 'ADD_SERVER',
REMOVE_SERVER = 'REMOVE_SERVER',
ADD_DM_CHANNEL = 'ADD_DM_CHANNEL',
REMOVE_DM_CHANNEL = 'REMOVE_DM_CHANNEL',
ADD_SERVER_CHANNEL = 'ADD_SERVER_CHANNEL',
REMOVE_SERVER_CHANNEL = 'REMOVE_SERVER_CHANNEL',
ADD_USER = 'ADD_USER',
REMOVE_USER = 'REMOVE_USER',
ADD_SERVER_MEMBER = 'ADD_SERVER_MEMBER',
REMOVE_SERVER_MEMBER = 'REMOVE_SERVER_MEMBER',
ADD_MESSAGE = 'ADD_MESSAGE',
REMOVE_MESSAGE = 'REMOVE_MESSAGE',
VOICE_CHANNEL_CONNECTED = 'VOICE_CHANNEL_CONNECTED',
VOICE_CHANNEL_DISCONNECTED = 'VOICE_CHANNEL_DISCONNECTED',
VOICE_SERVER_UPDATE = 'VOICE_SERVER_UPDATE'
}
// Client message types
export interface AuthenticateMessage {
type: ClientMessageType.AUTHENTICATE;
data: {
token: string;
};
}
export interface VoiceStateUpdateMessage {
type: ClientMessageType.VOICE_STATE_UPDATE;
data: {
serverId: ServerId;
channelId: ChannelId;
};
}
export interface RequestVoiceStatesMessage {
type: ClientMessageType.REQUEST_VOICE_STATES;
data: {
serverId: ServerId;
};
}
export type ClientMessage = AuthenticateMessage | VoiceStateUpdateMessage | RequestVoiceStatesMessage;
// Server message types
export interface AuthenticateAcceptedMessage {
type: ServerMessageType.AUTHENTICATE_ACCEPTED;
data: {
userId: UserId;
sessionKey: string;
};
}
export interface AuthenticateDeniedMessage {
type: ServerMessageType.AUTHENTICATE_DENIED;
}
export interface ErrorMessage {
type: ServerMessageType.ERROR;
data: {
code: ErrorCode;
};
}
// Event message types
export interface AddServerEvent {
type: EventType.ADD_SERVER;
data: {
server: Server;
};
}
export interface RemoveServerEvent {
type: EventType.REMOVE_SERVER;
data: {
serverId: ServerId;
};
}
export interface AddDmChannelEvent {
type: EventType.ADD_DM_CHANNEL;
data: {
channel: Channel;
};
}
export interface RemoveDmChannelEvent {
type: EventType.REMOVE_DM_CHANNEL;
data: {
channelId: ChannelId;
};
}
export interface AddServerChannelEvent {
type: EventType.ADD_SERVER_CHANNEL;
data: {
channel: Channel;
};
}
export interface RemoveServerChannelEvent {
type: EventType.REMOVE_SERVER_CHANNEL;
data: {
serverId: ServerId;
channelId: ChannelId;
};
}
export interface AddUserEvent {
type: EventType.ADD_USER;
data: {
user: PartialUser;
};
}
export interface RemoveUserEvent {
type: EventType.REMOVE_USER;
data: {
userId: UserId;
};
}
export interface AddServerMemberEvent {
type: EventType.ADD_SERVER_MEMBER;
data: {
serverId: ServerId;
user: PartialUser;
};
}
export interface RemoveServerMemberEvent {
type: EventType.REMOVE_SERVER_MEMBER;
data: {
serverId: ServerId;
userId: UserId;
};
}
export interface AddMessageEvent {
type: EventType.ADD_MESSAGE;
data: {
channelId: ChannelId;
message: Message;
};
}
export interface RemoveMessageEvent {
type: EventType.REMOVE_MESSAGE;
data: {
channelId: ChannelId;
messageId: MessageId;
};
}
export interface VoiceChannelConnectedEvent {
type: EventType.VOICE_CHANNEL_CONNECTED;
data: {
serverId: ServerId;
channelId: ChannelId;
userId: UserId;
};
}
export interface VoiceChannelDisconnectedEvent {
type: EventType.VOICE_CHANNEL_DISCONNECTED;
data: {
serverId: ServerId;
channelId: ChannelId;
userId: UserId;
};
}
export interface VoiceServerUpdateEvent {
type: EventType.VOICE_SERVER_UPDATE;
data: {
serverId: ServerId;
channelId: ChannelId;
token: string;
};
}
export type EventData =
| AddServerEvent
| RemoveServerEvent
| AddDmChannelEvent
| RemoveDmChannelEvent
| AddServerChannelEvent
| RemoveServerChannelEvent
| AddUserEvent
| RemoveUserEvent
| AddServerMemberEvent
| RemoveServerMemberEvent
| AddMessageEvent
| RemoveMessageEvent
| VoiceChannelConnectedEvent
| VoiceChannelDisconnectedEvent
| VoiceServerUpdateEvent;
export interface EventMessage {
type: ServerMessageType.EVENT;
data: {
event: EventData;
};
}
export type ServerMessage =
| AuthenticateAcceptedMessage
| AuthenticateDeniedMessage
| EventMessage
| ErrorMessage;
// Connection states
export enum ConnectionState {
DISCONNECTED = 'DISCONNECTED',
CONNECTING = 'CONNECTING',
AUTHENTICATING = 'AUTHENTICATING',
CONNECTED = 'CONNECTED',
ERROR = 'ERROR'
}

View File

@@ -1,33 +0,0 @@
export type WebSocketStatus =
| "IDLE" // Initial state, no connection attempt made
| "CONNECTING" // WebSocket connection is being established
| "AUTHENTICATING"// WebSocket connected, awaiting authentication response
| "CONNECTED" // Authenticated and ready for general messages
| "DISCONNECTED" // Intentionally disconnected or connection lost (no retry)
| "ERROR"; // An error occurred (network, auth, server-reported, no retry)
// Server -> Client Payloads
export interface SdpAnswerVoicePayload {
sdp: RTCSessionDescriptionInit; // From browser's RTCSessionDescriptionInit
}
// Server -> Client Messages
export type VoiceServerMessage =
| { type: "AUTHENTICATE_ACCEPTED"; data?: Record<string, never> }
| { type: "AUTHENTICATE_DENIED"; data?: { reason?: string } }
| { type: "SDP_ANSWER"; data: SdpAnswerVoicePayload }
| { type: "ERROR"; data: { code: string | number; message?: string } };
// Client -> Server Payloads
export interface AuthenticateVoicePayload {
token: string;
}
export interface SdpOfferVoicePayload {
sdp: RTCSessionDescriptionInit;
}
// Client -> Server Messages
export type VoiceClientMessage =
| { type: "AUTHENTICATE"; data: AuthenticateVoicePayload }
| { type: "SDP_OFFER"; data: SdpOfferVoicePayload };

View File

@@ -0,0 +1,271 @@
import { createPrefixedLogger } from "~/lib/utils";
import { ClientMessageType, ConnectionState, ServerMessageType, type ClientMessage, type ServerMessage } from "./types";
export class WebRTCClient {
private socket: WebSocket | null = null;
private peerConnection: RTCPeerConnection | null = null;
private onStateChange: (state: ConnectionState) => void;
private onError: (error: Error) => void;
private onRemoteStream: (stream: MediaStream) => void;
private state: ConnectionState = ConnectionState.DISCONNECTED;
private url: string;
private connectionLock = false;
private disconnectPromise: Promise<void> | null = null;
private disconnectResolve: (() => void) | null = null;
constructor(
url: string,
onStateChange: (state: ConnectionState) => void,
onError: (error: Error) => void,
onRemoteStream: (stream: MediaStream) => void
) {
this.url = url;
this.onStateChange = onStateChange;
this.onError = onError;
this.onRemoteStream = onRemoteStream;
}
public connect = async (token: string) => {
if (this.connectionLock) {
warn('WebRTC: Connection already in progress');
return;
}
this.connectionLock = true;
if (this.state !== ConnectionState.DISCONNECTED && this.state !== ConnectionState.ERROR) {
this.disconnect();
}
if (this.disconnectPromise) {
warn('WebRTC: Waiting for previous disconnect to complete');
try {
await this.disconnectPromise;
} catch (error) {
console.error('WebRTC: Previous disconnect failed:', error);
}
}
log('Connecting to %s', this.url);
try {
this.setState(ConnectionState.CONNECTING);
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
log('Socket opened');
this.connectionLock = false;
this.setState(ConnectionState.AUTHENTICATING);
this.sendMessage({
type: ClientMessageType.AUTHENTICATE,
data: { token }
});
};
this.socket.onmessage = this.handleServerMessage;
this.socket.onerror = (event) => {
this.handleError(new Error('WebSocket error occurred'));
};
this.socket.onclose = (e) => {
log('Socket closed', e);
this.cleanupResources();
if (this.state !== ConnectionState.ERROR) {
this.setState(ConnectionState.DISCONNECTED);
}
if (this.disconnectResolve) {
this.disconnectResolve();
this.disconnectResolve = null;
this.disconnectPromise = null;
}
};
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Unknown error'));
}
};
public disconnect = (): void => {
if (this.state === ConnectionState.DISCONNECTED) {
return;
}
this.setState(ConnectionState.DISCONNECTING);
this.connectionLock = false;
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
// If we're already waiting for a disconnect, cancel it
if (this.disconnectPromise) {
this.disconnectResolve = null;
this.disconnectPromise = null;
}
this.disconnectPromise = new Promise((resolve) => {
this.disconnectResolve = resolve;
});
const onSocketClose = () => {
this.socket?.removeEventListener('close', onSocketClose);
this.disconnectResolve?.();
this.disconnectResolve = null;
this.disconnectPromise = null;
};
this.socket.addEventListener('close', onSocketClose);
if (this.socket.readyState !== WebSocket.CLOSING) {
this.socket.close(1000, 'WebRTC: Cleaning up resources');
}
} else {
this.cleanupResources();
this.setState(ConnectionState.DISCONNECTED);
}
};
public createOffer = async (localStream?: MediaStream): Promise<void> => {
if (this.state !== ConnectionState.CONNECTED) {
this.handleError(new Error('Cannot create offer: not connected'));
return;
}
try {
// Create RTCPeerConnection with standard configuration
const configuration: RTCConfiguration = {
iceServers: []
};
this.peerConnection = new RTCPeerConnection(configuration);
// Add local stream tracks if provided
if (localStream) {
localStream.getTracks().forEach(track => {
this.peerConnection!.addTrack(track, localStream);
});
}
// Handle ICE candidates
this.peerConnection.onicecandidate = (event) => {
if (event.candidate === null) {
// ICE gathering completed
}
};
// Handle remote stream
this.peerConnection.ontrack = (event) => {
const [remoteStream] = event.streams;
if (remoteStream) {
this.onRemoteStream(remoteStream);
}
};
// Create offer and set local description
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
// Send offer to server
if (this.peerConnection.localDescription) {
this.sendMessage({
type: ClientMessageType.SDP_OFFER,
data: {
sdp: this.peerConnection.localDescription
}
});
}
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Error creating WebRTC offer'));
}
};
private handleServerMessage = async (event: MessageEvent): Promise<void> => {
try {
const message: ServerMessage = JSON.parse(event.data);
log('Received message: %o', message);
switch (message.type) {
case ServerMessageType.AUTHENTICATE_ACCEPTED:
this.setState(ConnectionState.CONNECTED);
break;
case ServerMessageType.AUTHENTICATE_DENIED:
this.handleError(new Error('Authentication failed'));
break;
case ServerMessageType.SDP_ANSWER:
await this.handleSdpAnswer(message.data.sdp);
break;
default:
warn('Unhandled message type:', message);
}
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Failed to process message'));
}
};
private handleSdpAnswer = async (sdp: RTCSessionDescription): Promise<void> => {
log('Received SDP answer: %o', sdp);
if (!this.peerConnection) {
this.handleError(new Error('No peer connection established'));
return;
}
try {
await this.peerConnection.setRemoteDescription(sdp);
} catch (error) {
this.handleError(error instanceof Error ? error : new Error('Error setting remote description'));
}
};
private sendMessage = (message: ClientMessage): void => {
log('Sending message: %o', message);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
} else {
this.handleError(new Error('Cannot send message: socket not connected'));
}
};
private setState = (state: ConnectionState): void => {
log('State changed to %s', state);
this.state = state;
this.onStateChange(state);
};
private handleError = (error: Error): void => {
log('Error: %s', error.message);
this.setState(ConnectionState.ERROR);
this.onError(error);
};
private cleanupResources = (): void => {
log('Cleaning up resources');
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
if (this.socket) {
this.socket.close(1000, 'WebRTC: Cleaning up resources');
this.socket = null;
}
};
}
const {
log,
warn,
...other
} = createPrefixedLogger('%cWebRTC WS%c:', ['color: blue; font-weight: bold;', '']);

View File

@@ -0,0 +1,36 @@
export enum ConnectionState {
DISCONNECTED = 'DISCONNECTED',
DISCONNECTING = 'DISCONNECTING',
CONNECTING = 'CONNECTING',
AUTHENTICATING = 'AUTHENTICATING',
CONNECTED = 'CONNECTED',
ERROR = 'ERROR',
}
export enum ServerMessageType {
AUTHENTICATE_ACCEPTED = 'AUTHENTICATE_ACCEPTED',
AUTHENTICATE_DENIED = 'AUTHENTICATE_DENIED',
SDP_ANSWER = 'SDP_ANSWER',
}
export type ServerMessage =
| { type: ServerMessageType.AUTHENTICATE_ACCEPTED }
| { type: ServerMessageType.AUTHENTICATE_DENIED }
| {
type: ServerMessageType.SDP_ANSWER; data: {
sdp: RTCSessionDescription
}
}
export enum ClientMessageType {
AUTHENTICATE = 'AUTHENTICATE',
SDP_OFFER = 'SDP_OFFER',
}
export type ClientMessage =
| { type: ClientMessageType.AUTHENTICATE; data: { token: string } }
| {
type: ClientMessageType.SDP_OFFER; data: {
sdp: RTCSessionDescription
}
};

View File

@@ -7,20 +7,23 @@ export default [
route("/register", "routes/auth/register.tsx"),
]),
...prefix("/app", [
layout("routes/app/layout.tsx", [
index("routes/app/index.tsx"),
...prefix("/@me", [
layout("routes/app/me/layout.tsx", [
index("routes/app/me/index.tsx"),
route("/channels/:channelId", "routes/app/me/channel.tsx"),
layout("routes/app/providers.tsx", [
route("/settings", "routes/app/settings.tsx"),
layout("routes/app/layout.tsx", [
index("routes/app/index.tsx"),
route("/invite/:inviteCode", "routes/app/invite.tsx"),
...prefix("/@me", [
layout("routes/app/me/layout.tsx", [
index("routes/app/me/index.tsx"),
route("/channels/:channelId", "routes/app/me/channel.tsx"),
])
]),
...prefix("/server/:serverId", [
layout("routes/app/server/layout.tsx", [
index("routes/app/server/index.tsx"),
route("/:channelId", "routes/app/server/channel.tsx"),
])
])
]),
...prefix("/server/:serverId", [
layout("routes/app/server/layout.tsx", [
index("routes/app/server/index.tsx"),
route("/channels/:channelId", "routes/app/server/channel.tsx"),
])
])
])
])]),
]),
] satisfies RouteConfig;

View File

@@ -1,58 +1,9 @@
import ChannelListItem from "~/components/channel-list-item";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
import { ScrollArea } from "~/components/ui/scroll-area";
import { redirect } from "react-router";
export const handle = {
listComponent: (
<>
<div className="h-full flex flex-col">
<DropdownMenu>
<DropdownMenuTrigger className="w-full min-h-12">
<div className="border-b-2 h-full flex items-center justify-center">
Server Name
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Team</DropdownMenuItem>
<DropdownMenuItem>Subscription</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
<div className="p-2 flex flex-col gap-1 h-full">
{Array.from({ length: 10 }).map((_, i) => (
<>
<ChannelListItem channel={{ id: i.toString(), name: `Channel ${i + 1}`, type: "category" }} />
<ChannelListItem channel={{ id: i.toString(), name: `Channel ${i + 1}`, type: "text" }} />
<ChannelListItem channel={{ id: i.toString(), name: `Channel ${i + 1}`, type: "voice" }} />
</>
))}
</div>
</ScrollArea>
</div>
</>
)
export function clientLoader() {
return redirect("/app/@me")
}
export default function Index() {
return (
<>
<div className="h-full">
<div className="size-full relative">
<div className="absolute bottom-0 w-full max-h-1/2">
<div className=" p-2">
<div className="max-h-96 w-full overflow-y-auto min-h-6 outline-1 p-4 rounded-xl no-scrollbar">
<div contentEditable="plaintext-only" className="break-words outline-0">
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
return null;
}

20
app/routes/app/invite.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { redirect } from "react-router";
import type { Route } from "./+types/invite";
export async function clientLoader(
{ params }: Route.ClientLoaderArgs
) {
const inviteCode = params.inviteCode
try {
const response = await import("~/lib/api/client/server").then(m => m.default.getInvite(inviteCode))
return redirect(`/app/server/${response.id}`)
} catch (error) {
return redirect("/app/@me")
}
}
export default function Index() {
return null;
}

View File

@@ -1,43 +1,26 @@
import type React from "react";
import { Outlet, redirect, useMatches } from "react-router";
import { Outlet, redirect } from "react-router";
import AppLayout from "~/components/app-layout";
import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-websocket-connection-manager";
import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager";
import { useServerListStore } from "~/store/server-list";
import { useUserStore } from "~/store/user";
import type { Route } from "../app/+types/layout";
import { useServerListStore } from "~/stores/server-list-store";
export async function clientLoader() {
let { user, setUser } = useUserStore.getState()
const { servers, addServers } = useServerListStore.getState()
try {
if (!user) {
const user = await import("~/lib/api/client/user").then(m => m.default.me())
setUser(user)
if (!servers || Object.values(servers).length === 0) {
const newServers = await import("~/lib/api/client/server").then(m => m.default.list())
addServers(newServers)
}
const servers = await import("~/lib/api/client/server").then(m => m.default.list())
useServerListStore.getState().setServers(servers)
} catch (error) {
return redirect("/login")
}
}
export default function Layout({
loaderData
}: Route.ComponentProps) {
const matches = useMatches();
let list = matches.map(match => (match.handle as { listComponent?: React.ReactNode })?.listComponent).reverse().find(component => !!component)
export default function Layout() {
return (
<>
<GatewayWebSocketConnectionManager />
<WebRTCConnectionManager />
{/* <GlobalWebRTCAudioPlayer /> */}
<AppLayout list={list}>
<Outlet />
</AppLayout>
</>
<AppLayout >
<Outlet />
</AppLayout>
);
}

View File

@@ -1,69 +1,37 @@
import type { Route } from ".react-router/types/app/routes/app/me/+types/channel";
import { useRef } from "react";
import TextBox from "~/components/text-box";
import { Button } from "~/components/ui/button";
import type { Uuid } from "~/lib/api/types";
import { Check } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import ChannelArea from "~/components/channel-area";
import { Badge } from "~/components/ui/badge";
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
import { useUsersStore } from "~/stores/users-store";
export default function Channel({
params
}: Route.ComponentProps) {
let channelId = params.channelId
const channelId = params.channelId
const currentUserId = useUsersStore(state => state.currentUserId)
const audioRef = useRef<HTMLAudioElement>(null)
let peerConnection: RTCPeerConnection | null = null
const nativeChannel = usePrivateChannelsStore(useShallow(state => state.channels[channelId]))
const recipients = nativeChannel.recipients.filter(recipient => recipient.id !== currentUserId)
async function testSdp(channelId: Uuid) {
const stream = await navigator.mediaDevices.getUserMedia(
{
audio: true,
}
);
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
const config = {
iceServers: [],
};
peerConnection = new RTCPeerConnection(config);
stream.getTracks().forEach((track) => peerConnection!.addTrack(track, stream));
peerConnection.addEventListener("track", (event) => {
console.log(event);
audioRef.current!.srcObject = event.streams[0];
});
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
console.log(offer);
const answer = await import("~/lib/api/client/test").then(m => m.default.test(channelId, offer))
await peerConnection.setRemoteDescription(answer);
console.log(answer);
const channel = {
...nativeChannel,
name: <>
<div className="flex items-center gap-2">
<div>
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
</div>
{renderSystemBadge && <Badge variant="default"> <Check />System</Badge>}
</div>
</>
}
return (
<>
<audio autoPlay ref={audioRef} />
<div className="h-full">
<div className="size-full relative">
<Button variant="secondary" onClick={() => testSdp('0196cbc6-5321-7531-974c-c87bd3066e14')}>
Test SDP
</Button>
<div className="absolute bottom-0 w-full max-h-1/2">
<div className="p-2">
<TextBox value={""}
onChange={(m) => { }}
placeholder="Type your message here..."
// Example of custom styling:
// wrapperClassName="bg-gray-700 border-gray-600 rounded-lg"
// inputClassName="text-lg"
aria-label="Message input" />
</div>
</div>
</div>
</div>
<ChannelArea channel={channel} />
</>
);
}

View File

@@ -1,9 +1,8 @@
import TextBox from "~/components/text-box";
export default function Index() {
return (
<>
<div className="h-full">
{/* <div className="h-full">
<div className="size-full relative">
<div className="absolute bottom-0 w-full max-h-1/2">
<div className="p-2">
@@ -17,7 +16,7 @@ export default function Index() {
</div>
</div>
</div>
</div>
</div> */}
</>
);
}

View File

@@ -1,19 +1,23 @@
import React from "react";
import { Outlet } from "react-router";
import PrivateChannelListItem from "~/components/private-channel-list-item";
import { useShallow } from "zustand/react/shallow";
import PrivateChannelListItem from "~/components/custom-ui/private-channel-list-item";
import { ScrollArea } from "~/components/ui/scroll-area";
import { usePrivateChannelsStore } from "~/store/private-channels";
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
export async function clientLoader() {
const {channels, setChannels} = usePrivateChannelsStore.getState()
const { channels, addChannels: setChannels } = usePrivateChannelsStore.getState()
if (!channels || channels.length === 0) {
const channelList = Object.values(channels)
if (!channels || channelList.length === 0) {
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
setChannels(channels)
}
}
function ListComponent() {
const channels = usePrivateChannelsStore(state => state.channels)
const channels = usePrivateChannelsStore(useShallow(state => Object.values(state.channels)))
return (
<div className="h-full flex flex-col">
@@ -26,9 +30,9 @@ function ListComponent() {
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
<div className="p-2 flex flex-col gap-1 h-full">
{channels.sort((a, b) => (a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1).map((channel, _) => (
<>
<React.Fragment key={channel.id}>
<PrivateChannelListItem channel={channel} />
</>
</React.Fragment>
))}
</div>
</ScrollArea>

View File

@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Outlet, redirect } from "react-router";
import { create } from "zustand";
import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-websocket-connection-manager";
import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager";
import ModalProvider from "~/components/providers/modal-provider";
import { useUsersStore } from "~/stores/users-store";
export async function clientLoader() {
const { currentUserId, setCurrentUserId, addUser } = useUsersStore.getState()
try {
if (!currentUserId) {
const user = await import("~/lib/api/client/user").then(m => m.default.me())
setCurrentUserId(user.id)
addUser(user)
}
} catch (error) {
return redirect("/login")
}
}
const useQueryClient = create(() => new QueryClient());
export default function Layout() {
const queryClient = useQueryClient();
return (
<QueryClientProvider client={queryClient}>
<>
<ModalProvider />
<GatewayWebSocketConnectionManager />
<WebRTCConnectionManager />
<Outlet />
</>
</QueryClientProvider>
);
}

View File

@@ -1,23 +1,16 @@
import TextBox from "~/components/text-box";
import { useShallow } from "zustand/react/shallow";
import ChannelArea from "~/components/channel-area";
import { useServerChannelsStore } from "~/stores/server-channels-store";
import type { Route } from "./+types/channel";
export default function Channel(
{ params: { serverId, channelId } }: Route.ComponentProps
) {
const channel = useServerChannelsStore(useShallow(state => state.channels[serverId][channelId]))
export default function Channel() {
return (
<>
<div className="h-full">
<div className="size-full relative">
<div className="absolute bottom-0 w-full max-h-1/2">
<div className="p-2">
<TextBox value={""}
onChange={(m) => { }}
placeholder="Type your message here..."
// Example of custom styling:
// wrapperClassName="bg-gray-700 border-gray-600 rounded-lg"
// inputClassName="text-lg"
aria-label="Message input" />
</div>
</div>
</div>
</div>
<ChannelArea channel={channel} />
</>
);
}

View File

@@ -1,9 +1,8 @@
import TextBox from "~/components/text-box";
export default function Index() {
return (
<>
<div className="h-full">
{/* <div className="h-full">
<div className="size-full relative">
<div className="absolute bottom-0 w-full max-h-1/2">
<div className="p-2">
@@ -17,7 +16,7 @@ export default function Index() {
</div>
</div>
</div>
</div>
</div> */}
</>
);
}

View File

@@ -1,38 +1,85 @@
import { Outlet, useParams } from "react-router";
import ChannelListItem from "~/components/channel-list-item";
import React from "react";
import { Outlet, redirect, useNavigate, useParams, type ShouldRevalidateFunctionArgs } from "react-router";
import { useShallow } from "zustand/react/shallow";
import ServerChannelListItem from "~/components/custom-ui/channel-list-item";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
import { ScrollArea } from "~/components/ui/scroll-area";
import { usePrivateChannelsStore } from "~/store/private-channels";
import { useServerListStore } from "~/store/server-list";
import type { ServerId } from "~/lib/api/types";
import { useGatewayStore } from "~/stores/gateway-store";
import { ModalType, useModalStore } from "~/stores/modal-store";
import { useServerChannelsStore } from "~/stores/server-channels-store";
import { useServerListStore } from "~/stores/server-list-store";
import { useUsersStore } from "~/stores/users-store";
import type { Route } from "../server/+types/layout";
export async function clientLoader() {
const {channels, setChannels} = usePrivateChannelsStore.getState()
export async function clientLoader({
params: { serverId }
}: Route.ClientLoaderArgs) {
const { channels, addChannels, addServer } = useServerChannelsStore.getState()
if (!channels || channels.length === 0) {
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
setChannels(channels)
const server = useServerListStore.getState().servers[serverId as ServerId] || undefined
if (!server) {
return redirect("/app/@me")
}
const channelList = channels[serverId as ServerId]
if (channelList === undefined) {
const channels = await import("~/lib/api/client/server").then(m => m.default.listChannels(serverId as ServerId))
addServer(serverId as ServerId)
addChannels(channels)
useGatewayStore.getState().requestVoiceStates(serverId as ServerId)
}
}
function ListComponent() {
const channels = []
export function shouldRevalidate(
arg: ShouldRevalidateFunctionArgs
) {
return true
}
const serverId = useParams<{ serverId: string }>().serverId!
const server = useServerListStore(state => state.servers.get(serverId))
function ListComponent() {
const serverId = useParams<{ serverId: ServerId }>().serverId!
const currentUserId = useUsersStore(state => state.currentUserId)
const onOpen = useModalStore(state => state.onOpen)
const server = useServerListStore(useShallow(state => state.servers[serverId] || null))
const channels = Array.from(useServerChannelsStore(useShallow(state => Object.values(state.channels[serverId] || {}))))
if (!server) {
return null
}
return (
<div className="h-full flex flex-col">
<div className="w-full min-h-12">
<div className="border-b-2 h-full flex items-center justify-center">
{server?.name}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="border-b-2 h-full flex items-center justify-center cursor-pointer">
{server?.name}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onOpen(ModalType.CREATE_SERVER_INVITE, { serverId })}>Create invite</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpen(ModalType.CREATE_SERVER_CHANNEL, { serverId })}>Create channel</DropdownMenuItem>
<DropdownMenuSeparator />
{currentUserId === server.ownerId &&
<DropdownMenuItem variant="destructive" onClick={() => onOpen(ModalType.DELETE_SERVER_CONFIRM, { serverId })}>Delete</DropdownMenuItem>}
{currentUserId !== server.ownerId &&
<DropdownMenuItem variant="destructive">Leave</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
</div>
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
<div className="p-2 flex flex-col gap-1 h-full">
{channels.sort((a, b) => (a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1).map((channel, _) => (
<>
<ChannelListItem channel={channel} />
</>
<React.Fragment key={channel.id}>
<ServerChannelListItem channel={channel} />
</React.Fragment>
))}
</div>
</ScrollArea>
@@ -44,7 +91,17 @@ export const handle = {
listComponent: <ListComponent />
}
export default function Layout() {
export default function Layout(
{ params: { serverId } }: Route.ComponentProps
) {
const server = useServerListStore(useShallow(state => state.servers[serverId!] || null))
const navigate = useNavigate()
if (!server) {
setTimeout(() => navigate("/app/@me"), 0)
return null;
}
return (
<>
<Outlet />

103
app/routes/app/settings.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { LogOutIcon, PencilIcon, UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useUsersStore } from "~/stores/users-store";
// Note: This is a mockup based on the provided store structure
export default function Settings() {
const user = useUsersStore(state => state.getCurrentUser());
return (
<div className="flex min-h-screen">
{/* Sidebar */}
<div className="w-64 border-r p-6 flex flex-col">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<Button variant="outline" className="justify-start mb-2 w-full">
<UserIcon className="mr-2 h-4 w-4" />
Profile
</Button>
<div className="mt-auto">
<Button variant="destructive" className="w-full">
<LogOutIcon className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
</div>
{/* Main content */}
<div className="flex-1 p-8">
<div className="max-w-2xl mx-auto">
<div className="flex flex-col items-center mb-8">
<Avatar className="h-24 w-24 mb-4">
<AvatarImage src={user?.avatarUrl} alt={user?.displayName} />
<AvatarFallback className="text-yellow-500">
<UserIcon className="h-12 w-12" />
</AvatarFallback>
</Avatar>
<Button variant="outline" size="sm">
<PencilIcon className="mr-2 h-4 w-4" />
Change
</Button>
</div>
<form>
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm mb-1">
Username
</label>
<Input
id="name"
name="name"
value={user?.username}
/>
</div>
<div>
<label htmlFor="displayName" className="block text-sm mb-1">
Display name
</label>
<Input
id="displayName"
name="displayName"
value={user?.displayName}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm mb-1">
Email
</label>
<Input
id="email"
name="email"
type="email"
value={user?.email}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm mb-1">
Password
</label>
<Input
id="password"
name="password"
type="password"
/>
</div>
<Button type="submit" className="mt-4">
Save
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,8 @@ import { AxiosError } from "axios";
import { useForm } from "react-hook-form";
import { Link, redirect, useNavigate } from "react-router";
import { z } from "zod";
import { PasswordInput } from "~/components/password-input";
import { useShallow } from "zustand/react/shallow";
import { PasswordInput } from "~/components/custom-ui/password-input";
import { ThemeToggle } from "~/components/theme/theme-toggle";
import { Button, buttonVariants } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
@@ -17,8 +18,8 @@ import {
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import auth from "~/lib/api/client/auth";
import { useTokenStore } from "~/store/token";
import { useUserStore } from "~/store/user";
import { useTokenStore } from "~/stores/token-store";
import { useUsersStore } from "~/stores/users-store";
const schema = z.object({
username: z.string(),
@@ -46,7 +47,12 @@ export async function clientLoader() {
export default function Login() {
let navigate = useNavigate()
let setToken = useTokenStore(state => state.setToken)
let setUser = useUserStore(state => state.setUser)
const { setCurrentUserId, addUser } = useUsersStore(useShallow(state => {
return {
setCurrentUserId: state.setCurrentUserId,
addUser: state.addUser
}
}))
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
@@ -56,7 +62,8 @@ export default function Login() {
const response = await auth.login(values)
setToken(response.token)
setUser(response.user)
setCurrentUserId(response.user.id)
addUser(response.user)
navigate("/app")
}

View File

@@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { z } from "zod";
import { PasswordInput } from "~/components/password-input";
import { PasswordInput } from "~/components/custom-ui/password-input";
import { ThemeToggle } from "~/components/theme/theme-toggle";
import { Button, buttonVariants } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";

View File

@@ -1,39 +0,0 @@
// ~/store/active-voice-channel.ts
import { create } from 'zustand';
import type { Uuid } from '~/lib/api/types';
interface ActiveVoiceChannelState {
serverId: Uuid | null;
channelId: Uuid | null;
// User's explicit intent to be in voice, helps differentiate between just selecting a channel and actually joining
isVoiceActive: boolean;
setActiveVoiceChannel: (serverId: Uuid | null, channelId: Uuid | null) => void;
toggleVoiceActivation: (activate: boolean) => void; // To explicitly join/leave voice for the selected channel
}
export const useActiveVoiceChannelStore = create<ActiveVoiceChannelState>((set, get) => ({
serverId: null,
channelId: null,
isVoiceActive: false,
setActiveVoiceChannel: (serverId, channelId) => {
const current = get();
// If changing channels, implicitly deactivate voice from the old one.
// The manager will then pick up the new channel and wait for isVoiceActive.
if (current.channelId !== channelId && current.isVoiceActive) {
set({ serverId, channelId, isVoiceActive: false }); // User needs to explicitly activate new channel
} else {
set({ serverId, channelId });
}
console.log(`ActiveVoiceChannel: Selected Server: ${serverId}, Channel: ${channelId}`);
},
toggleVoiceActivation: (activate) => {
const { serverId, channelId } = get();
if (activate && (!serverId || !channelId)) {
console.warn("ActiveVoiceChannel: Cannot activate voice without a selected server/channel.");
set({ isVoiceActive: false });
return;
}
set({ isVoiceActive: activate });
console.log(`ActiveVoiceChannel: Voice activation set to ${activate} for ${serverId}/${channelId}`);
},
}));

View File

@@ -1,435 +0,0 @@
import { create } from 'zustand';
import type {
ClientMessage,
EventServerPayload,
ServerMessage,
VoiceStateUpdateClientPayload,
WebSocketStatus
} from '~/lib/websocket/gateway.types'; // Adjust path as needed
import { useServerListStore } from './server-list'; // Adjust path as needed
// --- Configuration ---
const WEBSOCKET_URL = "ws://localhost:12345/gateway/ws";
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const BACKOFF_FACTOR = 2;
const MAX_RECONNECT_ATTEMPTS = 10;
let isConnectingInProgress = false;
let currentConnectCallId = 0;
function handleBusinessEvent(event: EventServerPayload) {
console.debug("WS: Received business event:", event.type, event.data);
switch (event.type) {
case "ADD_SERVER":
useServerListStore.getState().addServer(event.data.server);
break;
case "REMOVE_SERVER":
useServerListStore.getState().removeServer(event.data.serverId);
break;
// Add other event types from your application
case "VOICE_SERVER_UPDATE":
break;
default:
// This ensures all event types are handled if EventServerPayload is a strict union
const _exhaustiveCheck: never = event;
console.warn("WS: Received unhandled business event type:", _exhaustiveCheck);
return _exhaustiveCheck;
}
}
interface GatewayWebSocketState {
status: WebSocketStatus;
userId: string | null;
sessionKey: string | null;
lastError: string | null;
connect: (getToken: () => string | null | Promise<string | null>) => void;
disconnect: (intentional?: boolean) => void;
sendVoiceStateUpdate: (payload: VoiceStateUpdateClientPayload) => boolean;
}
let socket: WebSocket | null = null;
let pingIntervalId: ReturnType<typeof setInterval> | null = null;
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
let getTokenFunc: (() => string | null | Promise<string | null>) | null = null;
let isIntentionalDisconnect = false;
let serverReportedError = false; // Flag: true if server sent an ERROR message
export const useGatewayWebSocketStore = create<GatewayWebSocketState>((set, get) => {
const clearPingInterval = () => {
if (pingIntervalId) clearInterval(pingIntervalId);
pingIntervalId = null;
};
const clearReconnectTimeout = () => {
if (reconnectTimeoutId) clearTimeout(reconnectTimeoutId);
reconnectTimeoutId = null;
};
const sendWebSocketMessage = (message: ClientMessage): boolean => {
if (socket?.readyState === WebSocket.OPEN) {
console.debug("WS: Sending message:", message);
socket.send(JSON.stringify(message));
return true;
}
console.warn(`WS: Cannot send ${message.type}. WebSocket not open (state: ${socket?.readyState}).`);
return false;
};
const resetConnectingFlag = () => {
if (isConnectingInProgress) {
console.debug("WS: Resetting isConnectingInProgress flag.");
isConnectingInProgress = false;
}
};
const handleOpen = async () => {
console.log("WS: Connection established. Authenticating...");
set({ status: "AUTHENTICATING", lastError: null });
if (!getTokenFunc) {
console.error("WS: Auth failed. Token getter missing.");
get().disconnect(false); // Non-intentional, treat as error
set({ lastError: "Token provider missing for authentication." });
return;
}
try {
const token = await getTokenFunc();
if (!token) {
console.error("WS: Auth failed. No token from getter.");
isIntentionalDisconnect = true; // Prevent auto-reconnect for this specific failure
get().disconnect(true);
set({ lastError: "Authentication token not available." });
return;
}
sendWebSocketMessage({ type: "AUTHENTICATE", data: { token } });
} catch (error) {
console.error("WS: Error getting token for auth:", error);
isIntentionalDisconnect = true;
get().disconnect(true);
set({ lastError: "Failed to retrieve authentication token." });
}
};
const handleMessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data as string) as ServerMessage;
console.debug("WS: Received message:", message);
switch (message.type) {
case "AUTHENTICATE_ACCEPTED":
resetConnectingFlag();
reconnectAttempts = 0;
clearReconnectTimeout();
set({ status: "CONNECTED", userId: message.data.userId, sessionKey: message.data.sessionKey, lastError: null });
console.log(`WS: Authenticated as user ${message.data.userId}.`);
break;
case "AUTHENTICATE_DENIED":
resetConnectingFlag();
console.warn("WS: Authentication denied by server.");
isIntentionalDisconnect = true; // Prevent auto-reconnect
serverReportedError = false; // This is an auth denial, not a runtime server error
get().disconnect(true); // Disconnect, don't retry
set({ status: "ERROR", lastError: "Authentication denied by server." });
break;
case "EVENT":
handleBusinessEvent(message.data.event);
break;
case "ERROR":
resetConnectingFlag();
console.error(`WS: Server reported error. Code: ${message.data.code}`);
serverReportedError = true; // CRITICAL: Set flag
isIntentionalDisconnect = true; // Treat as a definitive stop from server
set({
status: "ERROR",
lastError: `Server error (${message.data.code})`,
userId: null, // Clear user session on server error
sessionKey: null, // Clear session key on server error
});
// Server is expected to close the connection. `onclose` will use `serverReportedError`.
// Ping interval will be cleared by `cleanupConnection` via `onclose`.
break;
default:
const _exhaustiveCheck: never = message;
console.warn("WS: Received unknown server message type:", _exhaustiveCheck);
set({ lastError: "Received unknown message type from server." });
}
} catch (error) {
console.error("WS: Failed to parse or handle server message:", error, "Raw data:", event.data);
set({ lastError: "Failed to parse server message." });
// This is a client-side parsing error. We might want to disconnect if this happens.
// serverReportedError = true; // Consider this a fatal client error
// isIntentionalDisconnect = true;
// get().disconnect(false); // Or true depending on desired reconnect behavior
}
};
const handleError = (event: Event) => {
resetConnectingFlag();
// This event is often vague and usually followed by onclose.
console.error("WS: WebSocket error event occurred:", event);
// Avoid changing status directly if onclose will handle it, to prevent state flickering.
// But, set lastError as it might be the only indication of a problem if onclose is not detailed.
set(state => ({ lastError: state.lastError || `WebSocket error: ${event.type || 'Unknown error'}` }));
// `onclose` will call `cleanupConnection`.
};
const handleClose = (event: CloseEvent) => {
const wasInProgress = isConnectingInProgress;
resetConnectingFlag();
console.log(
`WS: Connection closed. Code: ${event.code}, Reason: "${event.reason || 'N/A'}", Clean: ${event.wasClean}, Intentional: ${isIntentionalDisconnect}, ServerError: ${serverReportedError}, WasInProgress: ${wasInProgress}`
);
const wasNormalClosure = event.code === 1000 || event.code === 1001; // 1001: Going Away
const shouldAttemptReconnect = !isIntentionalDisconnect && !serverReportedError && !wasNormalClosure;
const previousStatus = get().status;
cleanupConnection(shouldAttemptReconnect);
if (!shouldAttemptReconnect) {
console.log("WS: No reconnect attempt scheduled.");
if (serverReportedError) {
// State already set to ERROR by handleMessage or will be set by cleanup.
if (get().status !== 'ERROR') set({ status: "ERROR", userId: null, sessionKey: null }); // Ensure it
} else if (isIntentionalDisconnect && previousStatus !== "ERROR") {
set({ status: "DISCONNECTED", userId: null, sessionKey: null });
} else if (wasNormalClosure && previousStatus !== "ERROR") {
set({ status: "DISCONNECTED", userId: null, sessionKey: null });
} else if (previousStatus !== "ERROR" && previousStatus !== "DISCONNECTED" && previousStatus !== "IDLE") {
set({ status: "DISCONNECTED", userId: null, sessionKey: null, lastError: get().lastError || "Connection closed unexpectedly." });
}
}
// If shouldAttemptReconnect, cleanupConnection -> scheduleReconnect sets status to RECONNECTING.
};
const cleanupConnection = (attemptReconnect: boolean) => {
resetConnectingFlag();
console.debug(`WS: Cleaning up connection. Attempt reconnect: ${attemptReconnect}`);
clearPingInterval();
clearReconnectTimeout();
if (socket) {
socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null;
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
try { socket.close(1000, "Client cleanup"); }
catch (e) { console.warn("WS: Error closing socket during cleanup:", e); }
}
socket = null;
}
if (attemptReconnect) {
if (!serverReportedError) { // Don't retry if server explicitly said ERROR
scheduleReconnect();
} else {
console.warn("WS: Reconnect attempt skipped due to server-reported error.");
if (get().status !== 'ERROR') {
set({ status: "ERROR", userId: null, sessionKey: null, lastError: get().lastError || "Server error, stopping." });
}
}
}
};
const scheduleReconnect = () => {
if (get().status === "ERROR" && serverReportedError) { // Double check
console.log("WS: Reconnect inhibited by server-reported error state.");
return;
}
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.warn(`WS: Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached.`);
set({ status: "ERROR", lastError: "Reconnection failed after multiple attempts.", userId: null, sessionKey: null });
return;
}
const delay = Math.min(INITIAL_RECONNECT_DELAY * Math.pow(BACKOFF_FACTOR, reconnectAttempts), MAX_RECONNECT_DELAY);
reconnectAttempts++;
console.log(`WS: Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay / 1000}s...`);
set(state => ({ status: "RECONNECTING", lastError: state.lastError }));
reconnectTimeoutId = setTimeout(() => {
if (get().status !== "RECONNECTING") { // Status might have changed (e.g. explicit disconnect)
console.log("WS: Reconnect attempt cancelled (status changed).");
return;
}
_connect();
}, delay);
};
const _connect = async () => { // Internal connect, used for retries and initial
const callId = currentConnectCallId;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
console.warn(`WS (_connect [${callId}]): Called but WebSocket already open/connecting.`);
if (socket.readyState === WebSocket.OPEN && get().status === 'CONNECTED') resetConnectingFlag();
return;
}
if (isConnectingInProgress) {
console.warn(`WS (_connect [${callId}]): Aborted, another connection attempt is already in progress.`);
return;
}
// Critical: If serverReportedError is true, _connect (retry mechanism) should not proceed.
// Only an explicit call to the public `connect()` (which resets serverReportedError) should bypass this.
if (serverReportedError) {
resetConnectingFlag();
console.warn("WS: _connect aborted due to server-reported error. Explicit connect() needed to retry.");
if (get().status !== 'ERROR') set({ status: 'ERROR', lastError: get().lastError || 'Server reported an error.' });
return;
}
clearReconnectTimeout();
if (!getTokenFunc) {
console.error("WS: Cannot connect. Token getter missing.");
set({ status: "ERROR", lastError: "Token provider missing." }); // Terminal for this attempt cycle
return;
}
console.log(`WS (_connect [${callId}]): Attempting to connect to ${WEBSOCKET_URL}... (Overall Attempt ${reconnectAttempts + 1})`);
isConnectingInProgress = true;
try { // Pre-flight check for token availability
const token = await getTokenFunc();
if (!token) {
console.warn("WS: No token available. Aborting connection attempt cycle.");
set({ status: "DISCONNECTED", lastError: "Authentication token unavailable.", userId: null, sessionKey: null });
isIntentionalDisconnect = true; // Stop retrying if token is consistently unavailable
return;
}
} catch (error) {
console.error("WS: Error getting token before connecting:", error);
set({ status: "ERROR", lastError: "Failed to retrieve authentication token.", userId: null, sessionKey: null });
isIntentionalDisconnect = true; // Stop retrying for this
return;
}
console.log(`WS: Attempting to connect to ${WEBSOCKET_URL}... (Attempt ${reconnectAttempts + 1})`);
// Preserve lastError if we are RECONNECTING, otherwise clear it for a fresh CONNECTING attempt
set(state => ({ status: "CONNECTING", lastError: state.status === 'RECONNECTING' ? state.lastError : null }));
try {
socket = new WebSocket(WEBSOCKET_URL);
// Flags (isIntentionalDisconnect, serverReportedError) are reset by public `connect()`
socket.onopen = handleOpen;
socket.onmessage = handleMessage;
socket.onerror = handleError;
socket.onclose = handleClose;
} catch (error) {
console.error("WS: Failed to create WebSocket instance:", error);
set({ status: "ERROR", lastError: "Failed to initialize WebSocket connection." });
socket = null;
resetConnectingFlag();
cleanupConnection(!isIntentionalDisconnect && !serverReportedError); // Attempt reconnect if appropriate
}
};
return {
status: "IDLE",
userId: null,
sessionKey: null,
lastError: null,
connect: (newGetTokenFunc) => {
const localCallId = ++currentConnectCallId;
console.log(`WS: Explicit connect requested (Call ID: ${localCallId}). Current status: ${get().status}, InProgress: ${isConnectingInProgress}`);
// If already connected and socket is open, do nothing.
if (get().status === "CONNECTED" && socket?.readyState === WebSocket.OPEN) {
console.warn(`WS (connect [${localCallId}]): Already connected.`);
resetConnectingFlag(); // Ensure it's false if we are truly connected.
return;
}
// If a connection is actively being established by another call, log and return.
// Allow if status is IDLE/DISCONNECTED/ERROR, as these are states where a new attempt is valid.
if (isConnectingInProgress && !['IDLE', 'DISCONNECTED', 'ERROR', 'RECONNECTING'].includes(get().status)) {
console.warn(`WS (connect [${localCallId}]): Connect called while a connection attempt is already in progress and status is ${get().status}.`);
return;
}
// If status is CONNECTING/AUTHENTICATING but isConnectingInProgress is false (e.g., after a bug),
// allow cleanup and new attempt.
// Reset flags for a *new* explicit connection sequence
isIntentionalDisconnect = false;
serverReportedError = false;
reconnectAttempts = 0;
// isConnectingInProgress will be set by _connect if it proceeds.
// Resetting it here might be too early if _connect has checks that depend on previous state of isConnectingInProgress.
// Instead, ensure _connect and its outcomes (open, error, close) reliably reset it.
clearReconnectTimeout();
if (socket) { // If there's an old socket (e.g. from a failed/interrupted attempt)
console.warn(`WS (connect [${localCallId}]): Explicit connect called with existing socket (state: ${socket.readyState}). Cleaning up old one.`);
cleanupConnection(false); // This will call resetConnectingFlag()
}
// At this point, isConnectingInProgress should be false due to cleanupConnection or initial state.
getTokenFunc = newGetTokenFunc;
_connect(); // _connect will set isConnectingInProgress = true if it starts.
},
disconnect: (intentional = true) => {
const callId = currentConnectCallId; // For logging
console.log(`WS (disconnect [${callId}]): Disconnect requested. Intentional: ${intentional}, InProgress: ${isConnectingInProgress}`);
isIntentionalDisconnect = intentional;
if (intentional) {
serverReportedError = false;
}
// No matter what, a disconnect means any "connecting in progress" is now void.
resetConnectingFlag(); // Crucial: stop any perceived ongoing connection attempt.
reconnectAttempts = 0;
const currentLastError = get().lastError;
const wasServerError = serverReportedError || (currentLastError && currentLastError.startsWith("Server error"));
const wasAuthDenied = currentLastError === "Authentication denied by server.";
cleanupConnection(false); // Clean up, do not attempt reconnect
set({
status: "DISCONNECTED",
userId: null,
sessionKey: null,
// Preserve significant errors (server error, auth denied) even on user disconnect.
// Otherwise, set a generic disconnect message.
lastError: (wasServerError || wasAuthDenied) ? currentLastError : (intentional ? "User disconnected" : "Disconnected"),
});
},
sendVoiceStateUpdate: (payload: VoiceStateUpdateClientPayload) => {
return sendWebSocketMessage({ type: "VOICE_STATE_UPDATE", data: payload });
}
};
});
// Optional: Online/Offline listeners. Consider placing in a React component
// that can supply `getTokenFunc` more naturally upon reconnection.
if (typeof window !== 'undefined') {
const tryAutoConnectOnline = () => {
const { status, connect } = useGatewayWebSocketStore.getState();
console.log("WS: Browser online event detected.");
if (status === 'DISCONNECTED' || status === 'ERROR' || status === 'RECONNECTING') {
if (!serverReportedError) { // Don't auto-connect if last state was a server-reported fatal error
console.log("WS: Attempting to connect after coming online.");
if (getTokenFunc) { // Check if we still have a token getter
connect(getTokenFunc);
} else {
console.warn("WS: Cannot auto-connect on 'online': token getter not available. App needs to call connect() again.");
}
} else {
console.log("WS: Browser online, but previous server-reported error prevents auto-reconnect. Manual connect needed.");
}
}
};
window.addEventListener('online', tryAutoConnectOnline);
const handleAppClose = () => { // Best-effort for page unload
const { disconnect, status } = useGatewayWebSocketStore.getState();
if (status !== "IDLE" && status !== "DISCONNECTED") {
disconnect(true);
}
};
window.addEventListener('beforeunload', handleAppClose);
}

View File

@@ -1,14 +0,0 @@
import { create } from 'zustand'
import type { RecipientChannel } from '~/lib/api/types'
type PrivateChannelsStore = {
channels: RecipientChannel[]
setChannels: (channels: RecipientChannel[]) => void
}
export const usePrivateChannelsStore = create<PrivateChannelsStore>()(
(set, get) => ({
channels: [],
setChannels: (channels: RecipientChannel[]) => set({ channels }),
})
)

View File

@@ -1,20 +0,0 @@
// type ServerChannelListStore = {
// servers: Map<Uuid, Map<Uuid, Channel>>
// setServers: (newServers: Server[]) => void
// addServer: (server: Server) => void
// removeServer: (serverId: Uuid) => void
// }
// export const useServerListStore = create<ServerChannelListStore>(
// (set, get) => ({
// servers: new Map<Uuid, Server>(),
// setServers: (newServers: Server[]) => set({ servers: new Map(newServers.map(server => [server.id, server])) }),
// addServer: (server: Server) => set({ servers: new Map(get().servers.set(server.id, server)) }),
// removeServer: (serverId: Uuid) => set((state) => {
// const newServers = new Map(state.servers)
// newServers.delete(serverId)
// return { servers: newServers }
// }),
// })
// )

View File

@@ -1,22 +0,0 @@
import { create } from 'zustand'
import { type Server, type Uuid } from '~/lib/api/types'
type ServerListStore = {
servers: Map<Uuid, Server>
setServers: (newServers: Server[]) => void
addServer: (server: Server) => void
removeServer: (serverId: Uuid) => void
}
export const useServerListStore = create<ServerListStore>(
(set, get) => ({
servers: new Map<Uuid, Server>(),
setServers: (newServers: Server[]) => set({ servers: new Map(newServers.map(server => [server.id, server])) }),
addServer: (server: Server) => set({ servers: new Map(get().servers.set(server.id, server)) }),
removeServer: (serverId: Uuid) => set((state) => {
const newServers = new Map(state.servers)
newServers.delete(serverId)
return { servers: newServers }
}),
})
)

View File

@@ -1,14 +0,0 @@
import { create } from 'zustand'
import type { User } from '~/lib/api/types'
type UserStore = {
user?: User
setUser: (user: User) => void
}
export const useUserStore = create<UserStore>()(
(set, get) => ({
user: undefined,
setUser: (user: User) => set({ user }),
}),
)

View File

@@ -1,408 +0,0 @@
import { create } from 'zustand';
import type { SdpAnswerVoicePayload, VoiceClientMessage, VoiceServerMessage, WebSocketStatus } from '~/lib/websocket/voice.types';
// Callbacks for application-specific events
interface VoiceWebSocketCallbacks {
onSdpAnswer?: (payload: SdpAnswerVoicePayload) => void;
// onBrowserOnline?: () => void; // Optional: If app wants to be notified by 'online' event
}
// --- Store State ---
export interface VoiceWebSocketState {
status: WebSocketStatus;
lastError: string | null;
connect: (
webSocketUrl: string,
getToken: () => string | null | Promise<string | null>
) => void;
disconnect: (intentional?: boolean) => void;
sendSdpOffer: (sdp: RTCSessionDescriptionInit) => boolean;
setCallbacks: (callbacks: VoiceWebSocketCallbacks) => void;
}
// --- Module-level state (managed by Zustand store closure) ---
let socket: WebSocket | null = null;
let getTokenFunc: (() => string | null | Promise<string | null>) | null = null;
let currentWebSocketUrl: string | null = null;
let isIntentionalDisconnect = false;
let serverReportedError = false; // Flag: true if server sent an ERROR message
let isConnectingInProgress = false; // Flag to prevent multiple concurrent connect attempts
let currentConnectCallId = 0; // For debugging concurrent calls
let voiceCallbacks: VoiceWebSocketCallbacks = {};
export const useVoiceWebSocketStore = create<VoiceWebSocketState>((set, get) => {
const sendWebSocketMessage = (message: VoiceClientMessage): boolean => {
if (socket?.readyState === WebSocket.OPEN) {
console.debug("VoiceWS: Sending message:", message.type, message.data);
try {
socket.send(JSON.stringify(message));
return true;
} catch (error) {
console.error("VoiceWS: Error sending message:", error, message);
// This is a critical error during send, transition to ERROR
set({ status: "ERROR", lastError: "Failed to send message due to a WebSocket error." });
cleanupConnectionInternals(); // Clean up the broken socket
return false;
}
}
console.warn(`VoiceWS: Cannot send ${message.type}. WebSocket not open (state: ${socket?.readyState}).`);
return false;
};
const resetConnectingFlag = () => {
if (isConnectingInProgress) {
console.debug("VoiceWS: Resetting isConnectingInProgress flag.");
isConnectingInProgress = false;
}
};
// Cleans up socket event handlers and closes the socket if open/connecting.
// This function does NOT change the Zustand state directly, relying on callers or event handlers (like onclose) to do that.
const cleanupConnectionInternals = () => {
console.debug(`VoiceWS: Cleaning up connection internals.`);
if (socket) {
socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null;
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
try { socket.close(1000, "Client cleanup"); }
catch (e) { console.warn("VoiceWS: Error closing socket during cleanup:", e); }
}
socket = null;
}
};
const handleOpen = async () => {
console.log("VoiceWS: Connection established. Authenticating...");
set({ status: "AUTHENTICATING", lastError: null });
if (!getTokenFunc) {
console.error("VoiceWS: Auth failed. Token getter missing.");
resetConnectingFlag();
set({ status: "ERROR", lastError: "Token provider missing for authentication." });
cleanupConnectionInternals();
return;
}
try {
const token = await getTokenFunc();
if (!token) {
console.error("VoiceWS: Auth failed. No token from getter.");
isIntentionalDisconnect = true; // So handleClose doesn't treat as "unexpected"
resetConnectingFlag();
set({ status: "ERROR", lastError: "Authentication token not available." });
cleanupConnectionInternals();
return;
}
// If sendWebSocketMessage fails here, it will set state to ERROR and cleanup.
if (!sendWebSocketMessage({ type: "AUTHENTICATE", data: { token } })) {
// Error state already set by sendWebSocketMessage if it failed critically
resetConnectingFlag(); // Ensure flag is reset
}
} catch (error) {
console.error("VoiceWS: Error getting token for auth:", error);
isIntentionalDisconnect = true;
resetConnectingFlag();
set({ status: "ERROR", lastError: "Failed to retrieve authentication token." });
cleanupConnectionInternals();
}
};
const handleMessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data as string) as VoiceServerMessage;
console.debug("VoiceWS: Received message:", message.type, message.data);
switch (message.type) {
case "AUTHENTICATE_ACCEPTED":
resetConnectingFlag();
set({ status: "CONNECTED", lastError: null });
console.log(`VoiceWS: Authenticated successfully.`);
break;
case "AUTHENTICATE_DENIED":
resetConnectingFlag();
const reason = message.data?.reason || "No reason provided";
console.warn(`VoiceWS: Authentication denied by server. Reason: ${reason}`);
isIntentionalDisconnect = true; // Server is denying, so it's "intentional" from that PoV
serverReportedError = false; // This is an auth failure, not a general server runtime error
set({ status: "ERROR", lastError: `Authentication denied: ${reason}` });
// Server should close the connection. We can prompt it.
if (socket) socket.close(1000, "Authentication Denied");
// cleanupConnectionInternals() will be called by handleClose
break;
case "SDP_ANSWER":
if (voiceCallbacks.onSdpAnswer) {
voiceCallbacks.onSdpAnswer(message.data);
} else {
console.warn("VoiceWS: Received SDP_ANSWER but no handler is registered.");
}
break;
case "ERROR":
resetConnectingFlag();
const errCode = message.data.code;
const errMsg = message.data.message || "Unknown server error";
console.error(`VoiceWS: Server reported error. Code: ${errCode}, Message: "${errMsg}"`);
serverReportedError = true;
isIntentionalDisconnect = true; // Server error implies server wants to stop.
set({
status: "ERROR",
lastError: `Server error (${errCode}): ${errMsg}`,
});
// Server should close the connection. We can prompt it.
if (socket) socket.close(1000, "Server Reported Error");
// cleanupConnectionInternals() will be called by handleClose
break;
default:
const _exhaustiveCheck: never = message;
console.warn("VoiceWS: Received unknown server message type:", _exhaustiveCheck);
set({ status: "ERROR", lastError: "Received unknown message type from server." });
if (socket) socket.close(1000, "Unknown message type");
return _exhaustiveCheck;
}
} catch (error) {
console.error("VoiceWS: Failed to parse or handle server message:", error, "Raw data:", event.data);
resetConnectingFlag();
set({ status: "ERROR", lastError: "Failed to parse server message." });
if (socket) socket.close(1000, "Message parsing error");
// cleanupConnectionInternals() will be called by handleClose
}
};
const handleError = (event: Event) => {
console.error("VoiceWS: WebSocket error event occurred:", event);
// This event often precedes `onclose`.
// `isConnectingInProgress` should be reset by `onclose`.
// Set lastError if not already set by a more specific server message or a connection setup failure.
set(state => ({
lastError: state.lastError || `WebSocket error: ${event.type || 'Unknown error'}`
}));
// Do not change status to ERROR here; onclose will give the definitive closure reason.
// However, ensure isConnectingInProgress is reset if this is a terminal error before onclose.
if (socket?.readyState === WebSocket.CLOSING || socket?.readyState === WebSocket.CLOSED) {
resetConnectingFlag();
}
};
const handleClose = (event: CloseEvent) => {
const wasInProgress = isConnectingInProgress; // Capture before reset
resetConnectingFlag(); // Connection process is definitely over.
console.log(
`VoiceWS: Connection closed. Code: ${event.code}, Reason: "${event.reason || 'N/A'}", Clean: ${event.wasClean}, Intentional: ${isIntentionalDisconnect}, ServerError: ${serverReportedError}, WasInProgress: ${wasInProgress}`
);
cleanupConnectionInternals(); // Ensure all handlers are detached and socket is nulled.
const currentStatus = get().status;
const currentError = get().lastError;
// If status was already set to ERROR by handleMessage (e.g. AUTH_DENIED, server ERROR),
// or by a failure during connect/auth phases, preserve that specific error.
if (currentStatus === "ERROR") {
// If lastError is generic like "Failed to initialize...", update with close event info if more specific.
if (currentError === "Failed to initialize WebSocket connection." || !currentError) {
set({ lastError: `Connection closed: Code ${event.code}, Reason: "${event.reason || 'N/A'}"` });
}
// Otherwise, the specific error (like auth denial) is already set and should be kept.
return;
}
// If an explicit server error message was received and handled
if (serverReportedError) {
set({ status: "ERROR", lastError: currentError || `Server error led to closure (Code: ${event.code})` });
}
// If disconnect was called explicitly, or an auth denial occurred (isIntentionalDisconnect=true)
else if (isIntentionalDisconnect) {
set({
status: "DISCONNECTED",
lastError: currentError || (event.wasClean ? "Disconnected" : `Disconnected: Code ${event.code}, Reason: "${event.reason || 'N/A'}"`)
});
}
// Otherwise, it's an unexpected closure (e.g., network drop, server crash without specific ERROR message)
else {
set({
status: "ERROR",
lastError: `Connection lost unexpectedly: Code ${event.code}, Reason: "${event.reason || 'N/A'}"`
});
}
};
const _connect = async () => {
const callId = currentConnectCallId;
// This check is crucial: if a server error occurred, only a new explicit `connect()`
// (which resets serverReportedError via the public connect method) should allow a new attempt.
if (serverReportedError) {
console.warn("VoiceWS: _connect aborted due to prior server-reported error. Call public connect() to retry.");
// State should already be ERROR. Ensure connecting flag is false.
resetConnectingFlag();
return;
}
if (!currentWebSocketUrl) { // Should be set by public connect()
console.error("VoiceWS: Cannot connect. WebSocket URL missing.");
set({ status: "ERROR", lastError: "WebSocket URL not provided." });
resetConnectingFlag();
return;
}
if (!getTokenFunc) { // Should be set by public connect()
console.error("VoiceWS: Cannot connect. Token getter missing.");
set({ status: "ERROR", lastError: "Token provider missing." });
resetConnectingFlag();
return;
}
console.log(`VoiceWS (_connect [${callId}]): Attempting to connect to ${currentWebSocketUrl}...`);
isConnectingInProgress = true; // Set flag: connection attempt now in progress.
try {
const token = await getTokenFunc(); // Pre-flight check for token
if (!token) {
console.warn("VoiceWS: No token available during connection attempt. Aborting.");
set({ status: "ERROR", lastError: "Authentication token unavailable for connection." });
isIntentionalDisconnect = true; // To guide handleClose if something unexpected happens next
resetConnectingFlag();
return;
}
} catch (error) {
console.error("VoiceWS: Error getting token before connecting:", error);
set({ status: "ERROR", lastError: "Failed to retrieve authentication token before connecting." });
isIntentionalDisconnect = true;
resetConnectingFlag();
return;
}
set({ status: "CONNECTING", lastError: null }); // Fresh connection attempt
try {
socket = new WebSocket(currentWebSocketUrl);
socket.onopen = handleOpen;
socket.onmessage = handleMessage;
socket.onerror = handleError;
socket.onclose = handleClose;
} catch (error) {
console.error(`VoiceWS: Failed to create WebSocket instance for ${currentWebSocketUrl}:`, error);
set({ status: "ERROR", lastError: "Failed to initialize WebSocket connection." });
socket = null;
resetConnectingFlag(); // Reset before cleanup if WebSocket creation itself failed
// cleanupConnectionInternals(); // Not strictly needed as socket is null, but harmless.
}
};
return {
status: "IDLE",
lastError: null,
connect: (newWebSocketUrl, newGetTokenFunc) => {
const localCallId = ++currentConnectCallId;
const currentStatus = get().status;
console.log(`VoiceWS: Explicit connect requested (Call ID: ${localCallId}). URL: ${newWebSocketUrl}. Current status: ${currentStatus}, InProgress: ${isConnectingInProgress}`);
if (currentStatus === "CONNECTED" && socket?.readyState === WebSocket.OPEN && currentWebSocketUrl === newWebSocketUrl) {
console.warn(`VoiceWS (connect [${localCallId}]): Already connected to the same URL.`);
resetConnectingFlag(); // Ensure it's false if truly connected.
return;
}
// Prevent concurrent explicit `connect` calls if one is already in `CONNECTING` or `AUTHENTICATING`
if (isConnectingInProgress && (currentStatus === "CONNECTING" || currentStatus === "AUTHENTICATING")) {
console.warn(`VoiceWS (connect [${localCallId}]): Connect called while a connection attempt (${currentStatus}) is already in progress. Aborting new call.`);
return;
}
// Reset flags for a *new* explicit connection sequence
isIntentionalDisconnect = false;
serverReportedError = false;
// isConnectingInProgress will be set by _connect if it proceeds.
// Ensure it's reset if we are cleaning up an old connection attempt first.
resetConnectingFlag();
if (socket) { // If there's an old socket (e.g. from a failed/interrupted attempt)
console.warn(`VoiceWS (connect [${localCallId}]): Explicit connect called with existing socket (state: ${socket.readyState}). Cleaning up old one.`);
cleanupConnectionInternals(); // This will close the old socket and nullify it.
}
currentWebSocketUrl = newWebSocketUrl;
getTokenFunc = newGetTokenFunc;
_connect(); // Start the connection process
},
disconnect: (intentional = true) => {
const callId = currentConnectCallId;
console.log(`VoiceWS (disconnect [${callId}]): Disconnect requested. Intentional: ${intentional}, InProgress: ${isConnectingInProgress}`);
const previousStatus = get().status;
const previousError = get().lastError;
isIntentionalDisconnect = intentional;
if (intentional) {
// User action clears server error flag, allowing a future manual connect to proceed
// without being blocked by a previous serverReportedError.
serverReportedError = false;
}
resetConnectingFlag(); // Stop any perceived ongoing connection attempt.
cleanupConnectionInternals(); // This will close the socket if open, triggering onclose.
// Set final state. `handleClose` will also run and might refine `lastError`
// based on close event details if it was an unexpected close.
if (previousStatus === "ERROR" && !intentional) {
// If it was already an error and this disconnect is not user-initiated (e.g. internal call)
set({ status: "ERROR", lastError: previousError || "Disconnected due to an error." });
} else {
set({
status: "DISCONNECTED",
lastError: intentional ? "User disconnected" : (previousError || "Disconnected"),
});
}
},
sendSdpOffer: (sdp) => {
if (get().status !== "CONNECTED") {
console.warn("VoiceWS: Cannot send SDP_OFFER. Not connected.");
// Optionally set lastError if this is considered an application error
// set(state => ({ lastError: state.lastError || "Attempted to send offer while not connected." }));
return false;
}
return sendWebSocketMessage({ type: "SDP_OFFER", data: { sdp } });
},
setCallbacks: (newCallbacks) => {
voiceCallbacks = { ...voiceCallbacks, ...newCallbacks };
console.debug("VoiceWS: Callbacks updated.", voiceCallbacks);
},
};
});
// Optional: Online/Offline listeners
if (typeof window !== 'undefined') {
const handleBrowserOnline = () => {
const { status } = useVoiceWebSocketStore.getState();
console.log("VoiceWS: Browser online event detected.");
if (['DISCONNECTED', 'ERROR'].includes(status) && !isConnectingInProgress) {
console.log("VoiceWS: Browser is online. Application may re-attempt connection if desired by calling connect().");
// Example: Notify the application if it wants to handle this
// if (voiceCallbacks.onBrowserOnline) {
// voiceCallbacks.onBrowserOnline();
// }
// Or dispatch a global event:
// window.dispatchEvent(new CustomEvent('voiceWsBrowserOnline'));
} else {
console.log(`VoiceWS: Browser online, no action needed. Status: ${status}, ServerError: ${serverReportedError}, ConnectingInProgress: ${isConnectingInProgress}`);
}
};
window.addEventListener('online', handleBrowserOnline);
const handleAppClose = () => { // Best-effort for page unload
const { disconnect, status } = useVoiceWebSocketStore.getState();
if (status !== "IDLE" && status !== "DISCONNECTED") {
console.log("VoiceWS: Disconnecting due to page unload.");
disconnect(true); // Intentional disconnect
}
};
window.addEventListener('beforeunload', handleAppClose);
}

View File

@@ -1,332 +0,0 @@
import { create } from 'zustand';
import type { Uuid } from '~/lib/api/types';
import type { VoiceServerUpdateServerEvent } from '~/lib/websocket/gateway.types'; // Adjust path
import type { SdpAnswerVoicePayload } from '~/lib/websocket/voice.types';
import { useGatewayWebSocketStore } from './gateway-websocket'; // Adjust path
import { useVoiceWebSocketStore } from './voice-websocket'; // Adjust path
export type WebRTCStatus =
| "IDLE"
| "REQUESTING_VOICE_SERVER" // Waiting for Gateway WS to provide voice server info
| "CONNECTING_VOICE_WS" // Voice WS connection in progress
| "NEGOTIATING_SDP" // SDP Offer/Answer exchange in progress
| "ICE_GATHERING" // ICE candidates are being gathered
| "ICE_CONNECTING" // ICE connection in progress
| "CONNECTED" // WebRTC connection established, media flowing
| "DISCONNECTED"
| "FAILED";
interface WebRTCState {
status: WebRTCStatus;
peerConnection: RTCPeerConnection | null;
localStream: MediaStream | null;
remoteStream: MediaStream | null;
lastError: string | null;
currentChannelId: Uuid | null; // To track which channel we are in
_internalUnsubscribeVoiceWs: ReturnType<typeof useVoiceWebSocketStore.subscribe> | undefined;
// Actions
joinVoiceChannel: (serverId: Uuid, channelId: Uuid, localStream: MediaStream) => Promise<void>;
leaveVoiceChannel: () => void;
_handleVoiceConnectionInfo: (info: VoiceServerUpdateServerEvent) => void; // Internal, called by GatewayWS
// For ICE candidate handling (if your server supports trickle ICE via Voice WS)
// sendIceCandidate: (candidate: RTCIceCandidateInit) => void;
// _handleIceCandidate: (candidate: RTCIceCandidateInit) => void;
}
// Default ICE server configuration (replace with your own if needed)
const defaultIceServers: RTCIceServer[] = [];
let currentVoiceWsUrl: string | null = null;
let currentVoiceToken: string | null = null;
export const useWebRTCStore = create<WebRTCState>((set, get) => ({
status: "IDLE",
peerConnection: null,
localStream: null,
remoteStream: null,
lastError: null,
currentChannelId: null,
_internalUnsubscribeVoiceWs: undefined,
joinVoiceChannel: async (serverId, channelId, localStream) => {
const { status: gatewayStatus, sendVoiceStateUpdate } = useGatewayWebSocketStore.getState();
const currentWebRTCStatus = get().status;
if (currentWebRTCStatus !== "IDLE" && currentWebRTCStatus !== "DISCONNECTED" && currentWebRTCStatus !== "FAILED") {
console.warn(`WebRTC: Cannot join channel. Current status: ${currentWebRTCStatus}`);
set({ lastError: "WebRTC: Join attempt while already active or in progress." });
return;
}
if (gatewayStatus !== "CONNECTED") {
console.error("WebRTC: Gateway WebSocket not connected. Cannot send VOICE_STATE_UPDATE.");
set({ status: "FAILED", lastError: "Gateway WebSocket not connected." });
return;
}
set({
status: "REQUESTING_VOICE_SERVER",
localStream, // Store the local stream
lastError: null,
currentChannelId: channelId,
});
const payload = { serverId, channelId };
if (!sendVoiceStateUpdate(payload)) {
console.error("WebRTC: Failed to send VOICE_STATE_UPDATE via Gateway WS.");
set({ status: "FAILED", lastError: "Failed to send voice state update." });
// Revert localStream if needed, or leaveVoiceChannel will clean it up
} else {
console.log("WebRTC: VOICE_STATE_UPDATE sent. Waiting for VOICE_CONNECTION_INFO...");
}
},
_handleVoiceConnectionInfo: (info: VoiceServerUpdateServerEvent) => {
if (get().status !== "REQUESTING_VOICE_SERVER") {
console.warn("WebRTC: Received VOICE_CONNECTION_INFO in unexpected state:", get().status);
return; // Or handle error
}
console.log("WebRTC: Received voice connection info. Initializing PeerConnection and Voice WS.", info);
// currentVoiceWsUrl = info.voiceServerUrl;
currentVoiceWsUrl = "ws://localhost:12345/voice/ws";
currentVoiceToken = info.data.token;
const pc = new RTCPeerConnection({ iceServers: defaultIceServers });
// pc.onicecandidate = (event) => {
// if (event.candidate) {
// console.log("WebRTC: New ICE candidate generated:", event.candidate);
// // IMPORTANT: You need a way to send this to the server via Voice WS
// // Example: get().sendIceCandidate(event.candidate.toJSON());
// // This requires `sendIceCandidate` on useVoiceWebSocketStore and server support
// useVoiceWebSocketStore.getState().sendIceCandidate(event.candidate.toJSON()); // Assuming this exists
// } else {
// console.log("WebRTC: All ICE candidates have been gathered.");
// }
// };
pc.oniceconnectionstatechange = () => {
console.log("WebRTC: ICE connection state change:", pc.iceConnectionState);
switch (pc.iceConnectionState) {
case "connected":
case "completed":
set({ status: "CONNECTED", lastError: null });
break;
case "disconnected":
// Can sometimes recover, or might lead to "failed"
// For now, we might treat as a more final state or wait for "failed"
set({ status: "DISCONNECTED", lastError: "ICE connection disconnected." });
// Consider calling leaveVoiceChannel or attempting reconnection based on your strategy
get().leaveVoiceChannel(); // Simple cleanup on disconnect
break;
case "failed":
set({ status: "FAILED", lastError: "ICE connection failed." });
get().leaveVoiceChannel(); // Cleanup
break;
case "closed":
set(state => (state.status !== "IDLE" ? { status: "DISCONNECTED", lastError: state.lastError || "ICE connection closed." } : {}));
break;
case "new":
case "checking":
set(state => (state.status === "NEGOTIATING_SDP" || state.status === "ICE_GATHERING" ? { status: "ICE_CONNECTING" } : {}));
break;
}
};
pc.ontrack = (event) => {
console.log("WebRTC: Received remote track:", event.track, event.streams);
if (event.streams && event.streams[0]) {
set({ remoteStream: event.streams[0] });
} else {
// Fallback for older browsers if streams[0] is not available
const newStream = new MediaStream();
newStream.addTrack(event.track);
set({ remoteStream: newStream });
}
};
// Add local tracks to the peer connection
const localStream = get().localStream;
if (localStream) {
localStream.getTracks().forEach(track => {
try {
pc.addTrack(track, localStream);
console.log("WebRTC: Added local track:", track.kind);
} catch (e) {
console.error("WebRTC: Error adding local track:", e);
}
});
} else {
console.warn("WebRTC: No local stream available to add tracks from.");
// You might want to create a default offer without tracks or handle this case
// For voice, usually you expect a local audio track.
}
set({ peerConnection: pc, status: "CONNECTING_VOICE_WS" });
// Connect to Voice WebSocket
const { connect: connectVoiceWs, setCallbacks: setVoiceCallbacks, status: voiceWsStatus } = useVoiceWebSocketStore.getState();
// Define a handler for SDP Answer from Voice WS
const handleSdpAnswer = (payload: SdpAnswerVoicePayload) => {
const currentPc = get().peerConnection;
if (!currentPc || get().status !== "NEGOTIATING_SDP") {
console.warn("WebRTC: Received SDP Answer in unexpected state or no PC.", get().status);
return;
}
console.log("WebRTC: Received SDP Answer. Setting remote description.");
currentPc.setRemoteDescription(new RTCSessionDescription(payload.sdp))
.then(() => {
console.log("WebRTC: Remote description set successfully.");
// ICE gathering might already be in progress or starting now.
// The oniceconnectionstatechange will handle moving to CONNECTED.
set({ status: "ICE_GATHERING" }); // Or directly to ICE_CONNECTING if candidates start flowing
})
.catch(err => {
console.error("WebRTC: Failed to set remote description:", err);
set({ status: "FAILED", lastError: "Failed to set remote SDP answer." });
get().leaveVoiceChannel();
});
};
// Define a handler for ICE Candidates from Voice WS (if server sends them)
const handleVoiceWsIceCandidate = (candidate: RTCIceCandidateInit) => {
const currentPc = get().peerConnection;
if (!currentPc) {
console.warn("WebRTC: Received ICE candidate but no peer connection.");
return;
}
console.log("WebRTC: Received ICE candidate from VoiceWS, adding to PC:", candidate);
currentPc.addIceCandidate(new RTCIceCandidate(candidate)).catch(e => {
console.error("WebRTC: Error adding received ICE candidate:", e);
});
};
// Set callbacks for the Voice WS store
setVoiceCallbacks({
onSdpAnswer: handleSdpAnswer,
// onIceCandidate: handleVoiceWsIceCandidate, // Assuming Voice WS supports this
});
// Function to create and send offer
const createAndSendOffer = async () => {
const currentPc = get().peerConnection;
if (!currentPc || get().status !== "NEGOTIATING_SDP") { // Check should be before setting to NEGOTIATING_SDP
console.warn("WebRTC: Cannot create offer, PC not ready or wrong state.");
return;
}
console.log("WebRTC: Creating SDP Offer...");
try {
const offer = await currentPc.createOffer({
// Offer to receive audio/video based on what you expect
// For voice only:
offerToReceiveAudio: true,
offerToReceiveVideo: false, // Set to true if you expect video
});
await currentPc.setLocalDescription(offer);
console.log("WebRTC: Local description set. Sending offer via Voice WS.");
useVoiceWebSocketStore.getState().sendSdpOffer(offer as RTCSessionDescriptionInit); // Cast needed as createOffer returns RTCSessionDescriptionInit
} catch (err) {
console.error("WebRTC: Failed to create or send SDP offer:", err);
set({ status: "FAILED", lastError: "Failed to create/send SDP offer." });
get().leaveVoiceChannel();
}
};
// Subscribe to Voice WS status changes
// We need to wait for Voice WS to be 'CONNECTED' before sending offer
const unsubscribeVoiceWs = useVoiceWebSocketStore.subscribe(
(newVoiceStatus, oldVoiceStatus) => {
if (newVoiceStatus.status === "CONNECTED" && get().status === "CONNECTING_VOICE_WS") {
console.log("WebRTC: Voice WS connected. Proceeding to SDP negotiation.");
set({ status: "NEGOTIATING_SDP" });
createAndSendOffer(); // Now create and send offer
} else if (newVoiceStatus.status === "ERROR" || newVoiceStatus.status === "DISCONNECTED") {
if (get().status === "CONNECTING_VOICE_WS" || get().status === "NEGOTIATING_SDP" || get().status === "ICE_GATHERING" || get().status === "ICE_CONNECTING") {
console.error("WebRTC: Voice WS disconnected or errored during WebRTC setup.", useVoiceWebSocketStore.getState().lastError);
set({ status: "FAILED", lastError: `Voice WebSocket error: ${useVoiceWebSocketStore.getState().lastError || 'Disconnected'}` });
get().leaveVoiceChannel(); // Cleanup WebRTC part
unsubscribeVoiceWs(); // Clean up subscription
}
}
}
);
// Store unsubscribe function for cleanup in leaveVoiceChannel
set(state => ({ ...state, _internalUnsubscribeVoiceWs: unsubscribeVoiceWs }));
// Initiate Voice WS connection
if (currentVoiceWsUrl && currentVoiceToken) {
console.log(`WebRTC: Connecting to Voice WS: ${currentVoiceWsUrl}`);
connectVoiceWs(currentVoiceWsUrl, () => Promise.resolve(currentVoiceToken)); // Token is already a string
} else {
console.error("WebRTC: Voice WS URL or Token missing.");
set({ status: "FAILED", lastError: "Voice WS URL or Token missing." });
}
},
leaveVoiceChannel: () => {
console.log("WebRTC: Leaving voice channel.");
const { peerConnection, localStream, _internalUnsubscribeVoiceWs } = get();
if (_internalUnsubscribeVoiceWs) {
_internalUnsubscribeVoiceWs(); // Unsubscribe from Voice WS status
}
if (peerConnection) {
peerConnection.getSenders().forEach(sender => {
if (sender.track) {
sender.track.stop();
}
});
peerConnection.getReceivers().forEach(receiver => {
if (receiver.track) {
receiver.track.stop();
}
});
peerConnection.close();
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
// Disconnect Voice WebSocket
const { status: voiceStatus, disconnect: disconnectVoiceWs } = useVoiceWebSocketStore.getState();
if (voiceStatus !== "IDLE" && voiceStatus !== "DISCONNECTED") {
disconnectVoiceWs(true);
}
// Optionally, inform Gateway server if needed
// useGatewayWebSocketStore.getState().sendVoiceStateUpdate({ channelId: null, serverId: get().currentServerId }); // Example
set({
status: "IDLE", // Or "DISCONNECTED" if you prefer that as the terminal state after a session
peerConnection: null,
localStream: null,
remoteStream: null,
lastError: null,
currentChannelId: null,
_internalUnsubscribeVoiceWs: undefined,
});
currentVoiceWsUrl = null;
currentVoiceToken = null;
},
// Placeholder for sending ICE Candidate via Voice WebSocket
// sendIceCandidate: (candidate: RTCIceCandidateInit) => {
// useVoiceWebSocketStore.getState().sendIceCandidate(candidate); // You'll need to implement sendIceCandidate in useVoiceWebSocketStore
// },
// Placeholder for handling ICE Candidate from Voice WebSocket
// _handleIceCandidate: (candidate: RTCIceCandidateInit) => {
// const pc = get().peerConnection;
// if (pc && candidate) {
// pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(e => {
// console.error("WebRTC: Error adding received ICE candidate:", e);
// });
// }
// },
}));

View File

@@ -0,0 +1,44 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import type { ChannelId, UserId } from "~/lib/api/types";
interface UserVoiceState {
deaf: boolean;
muted: boolean;
}
interface ChannelVoiceState {
users: Record<UserId, UserVoiceState>;
}
interface ChannelsVoiceState {
channels: Record<ChannelId, ChannelVoiceState>;
addUser: (channelId: ChannelId, userId: UserId, userVoiceState: UserVoiceState) => void;
removeUser: (channelId: ChannelId, userId: UserId) => void;
removeChannel: (channelId: ChannelId) => void;
}
export const useChannelsVoiceStateStore = create<ChannelsVoiceState>()(
immer(
(set, get) => ({
channels: {},
addUser: (channelId, userId, userVoiceState) => set((state) => {
if (!state.channels[channelId]) {
state.channels[channelId] = {
users: {}
}
}
state.channels[channelId].users[userId] = userVoiceState;
}),
removeUser: (channelId, userId) => set((state) => {
if (state.channels[channelId]) {
delete state.channels[channelId].users[userId];
}
}),
removeChannel: (channelId) => set((state) => {
delete state.channels[channelId];
})
})
)
)

171
app/stores/gateway-store.ts Normal file
View File

@@ -0,0 +1,171 @@
import type { QueryClient } from '@tanstack/react-query';
import { create } from 'zustand';
import { messageSchema, type ChannelId, type Message, type MessageId, type ServerId } from '~/lib/api/types';
import { GatewayClient } from '~/lib/websocket/gateway/client';
import {
ConnectionState,
EventType,
type EventData,
type VoiceServerUpdateEvent
} from '~/lib/websocket/gateway/types';
import { useChannelsVoiceStateStore } from './channels-voice-state';
import { usePrivateChannelsStore } from './private-channels-store';
import { useServerChannelsStore } from './server-channels-store';
import { useServerListStore } from './server-list-store';
import { useUsersStore } from './users-store';
const GATEWAY_URL = 'ws://localhost:12345/gateway/ws';
const HANDLERS = {
[EventType.ADD_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER }>['data']) => {
useServerListStore.getState().addServer(data.server);
},
[EventType.REMOVE_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER }>['data']) => {
useServerListStore.getState().removeServer(data.serverId);
useServerChannelsStore.getState().removeServer(data.serverId);
useChannelsVoiceStateStore.getState().removeChannel(data.serverId);
},
[EventType.ADD_DM_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_DM_CHANNEL }>['data']) => {
usePrivateChannelsStore.getState().addChannel(data.channel);
},
[EventType.REMOVE_DM_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_DM_CHANNEL }>['data']) => {
usePrivateChannelsStore.getState().removeChannel(data.channelId);
useChannelsVoiceStateStore.getState().removeChannel(data.channelId);
},
[EventType.ADD_SERVER_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER_CHANNEL }>['data']) => {
useServerChannelsStore.getState().addChannel(data.channel);
},
[EventType.REMOVE_SERVER_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER_CHANNEL }>['data']) => {
useServerChannelsStore.getState().removeChannel(data.serverId, data.channelId);
useChannelsVoiceStateStore.getState().removeChannel(data.serverId);
},
[EventType.ADD_USER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_USER }>['data']) => {
useUsersStore.getState().addUser(data.user);
},
[EventType.REMOVE_USER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_USER }>['data']) => {
useUsersStore.getState().removeUser(data.userId);
},
[EventType.ADD_SERVER_MEMBER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER_MEMBER }>['data']) => {
useUsersStore.getState().addUser(data.user);
},
[EventType.REMOVE_SERVER_MEMBER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER_MEMBER }>['data']) => {
useUsersStore.getState().removeUser(data.userId);
},
[EventType.ADD_MESSAGE]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_MESSAGE }>['data']) => {
const message = messageSchema.parse(data.message)
if (self.queryClient) {
self.queryClient.setQueryData(['messages', message.channelId], (oldData: {
pages: Message[][],
pageParams: MessageId[]
}) => {
return {
pages: oldData?.pages ? [[message, ...oldData.pages[0]], ...oldData.pages.slice(1)] : [[message]],
pageParams: oldData?.pageParams ?? [undefined, message.id]
}
});
}
},
[EventType.REMOVE_MESSAGE]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_MESSAGE }>['data']) => {
if (self.queryClient) {
self.queryClient.setQueryData(['messages', data.channelId], (oldData: any) => {
if (!oldData) return [];
return oldData.filter((message: any) => message.id !== data.messageId);
});
}
},
[EventType.VOICE_CHANNEL_CONNECTED]: (self: GatewayState, data: Extract<EventData, { type: EventType.VOICE_CHANNEL_CONNECTED }>['data']) => {
useChannelsVoiceStateStore.getState().addUser(data.channelId, data.userId, {
deaf: false,
muted: false
});
},
[EventType.VOICE_CHANNEL_DISCONNECTED]: (self: GatewayState, data: Extract<EventData, { type: EventType.VOICE_CHANNEL_DISCONNECTED }>['data']) => {
useChannelsVoiceStateStore.getState().removeUser(data.channelId, data.userId);
},
}
interface GatewayState {
client: GatewayClient | null;
queryClient: QueryClient | null;
status: ConnectionState;
connect: (token: string) => void;
disconnect: () => void;
setQueryClient: (client: QueryClient) => void;
updateVoiceState: (serverId: ServerId, channelId: ChannelId) => void;
requestVoiceStates: (serverId: ServerId) => void;
onVoiceServerUpdate: (handler: (event: VoiceServerUpdateEvent['data']) => void | Promise<void>) => (() => void);
}
export const useGatewayStore = create<GatewayState>()((set, get) => {
const client = new GatewayClient(GATEWAY_URL);
const voiceHandlers = new Set<(event: VoiceServerUpdateEvent['data']) => void>();
client.onEvent(EventType.VOICE_SERVER_UPDATE, (event) => {
voiceHandlers.forEach(handler => handler(event));
});
for (const [type, handler] of Object.entries(HANDLERS)) {
client.onEvent(type, (data: any) => {
handler(get(), data);
});
}
return {
client,
queryClient: null,
status: ConnectionState.DISCONNECTED,
connect: (token) => {
client.connect(token);
set({ status: client.connectionState });
client.onControl('stateChange', (state) => {
set({ status: state });
});
},
disconnect: () => {
client.disconnect();
set({ status: ConnectionState.DISCONNECTED });
},
setQueryClient: (queryClient) => {
set({ queryClient });
},
updateVoiceState: (serverId, channelId) => {
client.updateVoiceState(serverId, channelId);
},
requestVoiceStates: (serverId) => {
client.requestVoiceStates(serverId);
},
onVoiceServerUpdate: (handler) => {
voiceHandlers.add(handler);
return () => {
console.log("removing voice server update handler", handler);
voiceHandlers.delete(handler);
};
}
};
});

49
app/stores/modal-store.ts Normal file
View File

@@ -0,0 +1,49 @@
import { create } from "zustand";
import type { ServerId } from "~/lib/api/types";
export enum ModalType {
CREATE_SERVER = "CREATE_SERVER",
CREATE_SERVER_CHANNEL = "CREATE_CHANNEL",
CREATE_SERVER_INVITE = "CREATE_SERVER_INVITE",
DELETE_SERVER_CONFIRM = "DELETE_SERVER_CONFIRM",
UPDATE_PROFILE = "UPDATE_PROFILE",
}
export type CreateServerInviteModalData = {
type: ModalType.CREATE_SERVER_INVITE;
data: {
serverId: ServerId;
};
};
export type DeleteServerConfirmModalData = {
type: ModalType.CREATE_SERVER_CHANNEL;
data: {
serverId: ServerId;
}
};
export type CreateServerChannelModalData = {
type: ModalType.CREATE_SERVER_CHANNEL;
data: {
serverId: ServerId;
}
};
export type ModalData = CreateServerChannelModalData | CreateServerInviteModalData | DeleteServerConfirmModalData;
interface ModalState {
type: ModalType | null;
data?: ModalData['data'];
isOpen: boolean;
onOpen: (type: ModalType, data?: ModalData['data']) => void;
onClose: () => void;
}
export const useModalStore = create<ModalState>()((set) => ({
type: null,
data: undefined,
isOpen: false,
onOpen: (type, data) => set({ type, data, isOpen: true }),
onClose: () => set({ type: null, isOpen: false }),
}));

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { ChannelId, RecipientChannel } from '~/lib/api/types'
type PrivateChannelsStore = {
channels: Record<ChannelId, RecipientChannel>
addChannels: (channels: RecipientChannel[]) => void
addChannel: (channel: RecipientChannel) => void
removeChannel: (channelId: ChannelId) => void
}
export const usePrivateChannelsStore = create<PrivateChannelsStore>()(
immer(
(set) => ({
channels: {},
addChannels: (channels: RecipientChannel[]) => set((state) => {
for (const channel of channels) {
state.channels[channel.id] = channel
}
}),
addChannel: (channel: RecipientChannel) => set((state) => { state.channels[channel.id] = channel }),
removeChannel: (channelId: ChannelId) => set((state) => { delete state.channels[channelId] }),
})
)
)

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { ChannelId, ServerChannel, ServerId } from "~/lib/api/types"
type ServerChannelsStore = {
channels: Record<ServerId, Record<ChannelId, ServerChannel>>
addServer: (serverId: ServerId) => void
addChannel: (channel: ServerChannel) => void
addChannels: (channels: ServerChannel[]) => void
removeChannel: (serverId: ServerId, channelId: ChannelId) => void
removeServer: (serverId: ServerId) => void
}
export const useServerChannelsStore = create<ServerChannelsStore>()(
immer(
(set, get) => ({
channels: {},
addServer: (serverId) => set((state) => {
state.channels[serverId] = {}
}),
addChannel: (channel) => set((state) => {
if (state.channels[channel.serverId] === undefined) {
state.channels[channel.serverId] = {}
}
state.channels[channel.serverId][channel.id] = channel
}),
addChannels: (channels) => set((state) => {
for (const channel of channels) {
if (state.channels[channel.serverId] === undefined) {
state.channels[channel.serverId] = {}
}
state.channels[channel.serverId][channel.id] = channel
}
}),
removeChannel: (serverId, channelId) => set((state) => { delete state.channels[serverId][channelId] }),
removeServer: (serverId) => set((state) => { delete state.channels[serverId] }),
})
)
)

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { Server, ServerId, Uuid } from '~/lib/api/types'
type ServerListStore = {
servers: Record<ServerId, Server>
addServers: (newServers: Server[]) => void
addServer: (server: Server) => void
removeServer: (serverId: Uuid) => void
}
export const useServerListStore = create<ServerListStore>()(
immer(
(set) => ({
servers: {},
addServers: (servers: Server[]) => set((state) => {
for (const server of servers) {
state.servers[server.id] = server
}
}),
addServer: (server: Server) => set((state) => { state.servers[server.id] = server }),
removeServer: (serverId: Uuid) => set((state) => { delete state.servers[serverId] }),
})
)
)

View File

@@ -0,0 +1,92 @@
import { useQuery } from "@tanstack/react-query"
import { create as batshitCreate, keyResolver } from "@yornaath/batshit"
import { create } from "zustand"
import { immer } from "zustand/middleware/immer"
import { getUser } from "~/lib/api/client/user"
import type { FullUser, PartialUser, UserId } from "~/lib/api/types"
type UsersStore = {
users: Record<UserId, PartialUser>
currentUserId: UserId | undefined
fetchUsersIfNotPresent: (userIds: UserId[]) => Promise<void>
addUser: (user: PartialUser) => void
removeUser: (userId: UserId) => void
setCurrentUserId: (userId: UserId) => void
getCurrentUser: () => FullUser | undefined
}
const usersFetcher = batshitCreate({
fetcher: async (userIds: UserId[]) => {
let users = []
for (const userId of userIds) {
users.push(getUser(userId))
}
return await Promise.all(users)
},
resolver: keyResolver("id")
})
export const useUserQuery = (userId: UserId) => useQuery(
{
queryKey: ["users", userId],
queryFn: async () => {
const user = await getUser(userId)
return user
},
select: (data) => {
useUsersStore.getState().addUser(data)
return data
}
}
)
export const useUsersStore = create<UsersStore>()(
immer(
(set, get) => ({
users: {},
currentUserId: undefined,
fetchUsersIfNotPresent: async (userIds) => {
let userPromises: Promise<PartialUser>[] = []
for (const userId of userIds) {
const user = get().users[userId]
if (!user) {
userPromises.push(usersFetcher.fetch(userId))
}
}
const users = await Promise.all(userPromises)
const activeUsers = users.filter(Boolean)
set((state) => {
for (const user of activeUsers) {
if (user?.id)
state.users[user.id] = user
}
})
},
addUser: (user) => set((state) => {
if (user.id !== get().currentUserId)
state.users[user.id] = user
else {
const currentUser = get().users[user.id]
if (currentUser)
state.users[user.id] = { ...currentUser, ...user }
else
state.users[user.id] = user
}
}),
removeUser: (userId) => set((state) => {
delete state.users[userId]
}),
setCurrentUserId: (userId) => set((state) => {
state.currentUserId = userId
}),
getCurrentUser: () => !!get().currentUserId ? get().users[get().currentUserId!] as FullUser : undefined
}),
)
)

View File

@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { useWebRTCStore } from './webrtc-store';
interface VoiceState {
activeChannel: { serverId: string; channelId: string } | null;
error: string | null;
// Actions
joinVoiceChannel: (serverId: string, channelId: string) => void;
leaveVoiceChannel: () => void;
setError: (error: string) => void;
resetError: () => void;
}
export const useVoiceStateStore = create<VoiceState>()((set, get) => {
return {
activeChannel: null,
error: null,
joinVoiceChannel: (serverId, channelId) => {
set({
activeChannel: { serverId, channelId },
error: null
});
},
leaveVoiceChannel: () => {
const currentState = get();
if (currentState.activeChannel) {
useWebRTCStore.getState().disconnect();
set({
activeChannel: null,
});
}
},
setError: (error) => {
set({ error });
},
resetError: () => {
set({ error: null });
}
};
});

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import { WebRTCClient } from '~/lib/websocket/voice/client';
import { ConnectionState } from '~/lib/websocket/voice/types';
import { useVoiceStateStore } from './voice-state-store';
const VOICE_GATEWAY_URL = 'ws://localhost:12345/voice/ws';
interface WebRTCState {
client: WebRTCClient | null;
status: ConnectionState;
remoteStream: MediaStream | null;
error: string | null;
connect: (token: string) => Promise<void>;
disconnect: () => void;
createOffer: (localStream: MediaStream) => Promise<void>;
}
export const useWebRTCStore = create<WebRTCState>()((set, get) => {
const client = new WebRTCClient(
VOICE_GATEWAY_URL,
(state) => set({ status: state }),
(error) => {
set({
status: ConnectionState.ERROR,
error: error.message
});
useVoiceStateStore.getState().setError(error.message);
},
(stream) => set({ remoteStream: stream })
);
return {
client,
status: ConnectionState.DISCONNECTED,
remoteStream: null,
error: null,
connect: async (token) => {
await client.connect(token);
},
disconnect: () => {
client.disconnect();
set({
status: ConnectionState.DISCONNECTED,
remoteStream: null
});
},
createOffer: async (localStream) => {
await client.createOffer(localStream);
}
};
});

118
bun.lock
View File

@@ -5,41 +5,45 @@
"name": "diplom",
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"@react-router/node": "^7.6.0",
"@react-router/serve": "^7.6.0",
"@tanstack/react-query": "^5.76.1",
"@yornaath/batshit": "^0.10.1",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^10.1.1",
"isbot": "^5.1.27",
"lucide-react": "^0.508.0",
"isbot": "^5.1.28",
"lucide-react": "^0.510.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.3",
"react-router": "^7.5.3",
"tailwind-merge": "^3.2.0",
"react-router": "^7.6.0",
"tailwind-merge": "^3.3.0",
"zod": "^3.24.4",
"zustand": "^5.0.4",
},
"devDependencies": {
"@react-router/dev": "^7.5.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"@react-router/dev": "^7.6.0",
"@tailwindcss/vite": "^4.1.6",
"@types/node": "^22.15.18",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"tailwindcss": "^4.1.6",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
},
},
@@ -165,6 +169,8 @@
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
@@ -191,6 +197,8 @@
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2JMfHJf/eVnwq+2dewT3C0acmCWD3XiVA1Da+jTDqo342UlU13WvXtqHhG+yJw5JeQmu4ue2eMy6gcEArLBlcw=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cZvNiIKqWQjf3DsQk1+wktF3DD73kUbWQ2E/XSh8m2IcpFGwg4IiIvGlVNdovxuozK/9+4QXd2zVlzUMiexSDg=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.9", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-10tQokfvZdFvnvDkcOJPjm2pWiP8A0R4T83MoD7tb15bC/k2GU7B1YBuzJi8lNQ8V1QqhP8ocNqp27ByZaNagQ=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.6", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ=="],
@@ -229,6 +237,8 @@
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-K5h1RkYA6M0Sn61BV5LQs686zqBsSC0sGzL4/Gw4mNnjzrQcGSc6YXfC6CRFNaGydSdv5+M8cb0eNsOGo0OXtQ=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.4", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.6", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.6", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.6", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/OOm58Gil4Ev5zT8LyVzqfBcij4dTHYdeyuF5lMHZ2bIp0Lk9oETocYiJ5QC0dHekEQnK6L/FNJCceeb4AkZ6Q=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Izof3lPpbCfTM7WDta+LRkz31jem890VjEvpVRoWQNKpDUMMVffuyq854XPGP1KYGWWmjmYvHvPFeocWhFCy1w=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
@@ -249,6 +259,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
@@ -257,13 +269,13 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-router/dev": ["@react-router/dev@7.5.3", "", { "dependencies": { "@babel/core": "^7.21.8", "@babel/generator": "^7.21.5", "@babel/parser": "^7.21.8", "@babel/plugin-syntax-decorators": "^7.22.10", "@babel/plugin-syntax-jsx": "^7.21.4", "@babel/preset-typescript": "^7.21.5", "@babel/traverse": "^7.23.2", "@babel/types": "^7.22.5", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.5.3", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "fs-extra": "^10.0.0", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "valibot": "^0.41.0", "vite-node": "3.0.0-beta.2" }, "peerDependencies": { "@react-router/serve": "^7.5.3", "react-router": "^7.5.3", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-U+n8JYAREKg6eHIAXCjazsYlwPo/vcAbShpqePnDBUdDnePBwZ2JmoqhWV+7tIhyHvvHGQKlw6BcrSZtF549WQ=="],
"@react-router/dev": ["@react-router/dev@7.6.0", "", { "dependencies": { "@babel/core": "^7.21.8", "@babel/generator": "^7.21.5", "@babel/parser": "^7.21.8", "@babel/plugin-syntax-decorators": "^7.22.10", "@babel/plugin-syntax-jsx": "^7.21.4", "@babel/preset-typescript": "^7.21.5", "@babel/traverse": "^7.23.2", "@babel/types": "^7.22.5", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.6.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "fs-extra": "^10.0.0", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "valibot": "^0.41.0", "vite-node": "3.0.0-beta.2" }, "peerDependencies": { "@react-router/serve": "^7.6.0", "react-router": "^7.6.0", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-XSxEslex0ddJPxNNgdU1Eqmc9lsY/lhcLNCcRLAtlrOPyOz3Y8kIPpAf5T/U2AG3HGXFVBa9f8aQ7wXU3wTJSw=="],
"@react-router/express": ["@react-router/express@7.5.3", "", { "dependencies": { "@react-router/node": "7.5.3" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.5.3", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-/fPrmeJQME7nL71FyUAMRGZk1PsrW1+hPMs7caIATmHKvE8hArd2BrpdsHEbGNvirnyO+qABQJmRFdNtCU/axQ=="],
"@react-router/express": ["@react-router/express@7.6.0", "", { "dependencies": { "@react-router/node": "7.6.0" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.6.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-nxSTCcTsVx94bXOI9JjG7Cg338myi8EdQWTOjA97v2ApX35wZm/ZDYos5MbrvZiMi0aB4KgAD62o4byNqF9Z1A=="],
"@react-router/node": ["@react-router/node@7.5.3", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.19.2" }, "peerDependencies": { "react-router": "7.5.3", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-w85YL6UngvhxGmgTBL3qwlVLFiA1EzNyG5S55qGcnhDikfb9z84gqMlTU4UqlZm8PjQ021HPTTN6I3kd7QE5sg=="],
"@react-router/node": ["@react-router/node@7.6.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.19.2" }, "peerDependencies": { "react-router": "7.6.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-agjDPUzisLdGJ7Q2lx/Z3OfdS2t1k6qv/nTvA45iahGsQJCMDvMqVoIi7iIULKQJwrn4HWjM9jqEp75+WsMOXg=="],
"@react-router/serve": ["@react-router/serve@7.5.3", "", { "dependencies": { "@react-router/express": "7.5.3", "@react-router/node": "7.5.3", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.5.3" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-0YkoVAJWAMtYPKQVXKb1RoSfy3EbjyzEeaFCL4twB4eB5Rj4MSE9uH0Zb9caNQXpq/8MDzjbe3SuvQ8uqwyqkQ=="],
"@react-router/serve": ["@react-router/serve@7.6.0", "", { "dependencies": { "@react-router/express": "7.6.0", "@react-router/node": "7.6.0", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.6.0" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-2O8ALEYgJfimvEdNRqMpnZb2N+DQ5UK/SKo9Xo3mTkt3no0rNTcNxzmhzD2tm92Q/HI7kHmMY1nBegNB2i1abA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="],
@@ -307,43 +319,51 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.5", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.5" } }, "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.6", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.6" } }, "sha512-ed6zQbgmKsjsVvodAS1q1Ld2BolEuxJOSyyNc+vhkjdmfNUDCmQnlXBfQkHrlzNmslxHsQU/bFmzcEbv4xXsLg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.5", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.5", "@tailwindcss/oxide-darwin-arm64": "4.1.5", "@tailwindcss/oxide-darwin-x64": "4.1.5", "@tailwindcss/oxide-freebsd-x64": "4.1.5", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", "@tailwindcss/oxide-linux-x64-musl": "4.1.5", "@tailwindcss/oxide-wasm32-wasi": "4.1.5", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" } }, "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.6", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.6", "@tailwindcss/oxide-darwin-arm64": "4.1.6", "@tailwindcss/oxide-darwin-x64": "4.1.6", "@tailwindcss/oxide-freebsd-x64": "4.1.6", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.6", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.6", "@tailwindcss/oxide-linux-arm64-musl": "4.1.6", "@tailwindcss/oxide-linux-x64-gnu": "4.1.6", "@tailwindcss/oxide-linux-x64-musl": "4.1.6", "@tailwindcss/oxide-wasm32-wasi": "4.1.6", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.6", "@tailwindcss/oxide-win32-x64-msvc": "4.1.6" } }, "sha512-0bpEBQiGx+227fW4G0fLQ8vuvyy5rsB1YIYNapTq3aRsJ9taF3f5cCaovDjN5pUGKKzcpMrZst/mhNaKAPOHOA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.6", "", { "os": "android", "cpu": "arm64" }, "sha512-VHwwPiwXtdIvOvqT/0/FLH/pizTVu78FOnI9jQo64kSAikFSZT7K4pjyzoDpSMaveJTGyAKvDjuhxJxKfmvjiQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-weINOCcqv1HVBIGptNrk7c6lWgSFFiQMcCpKM4tnVi5x8OY2v1FrV76jwLukfT6pL1hyajc06tyVmZFYXoxvhQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-3FzekhHG0ww1zQjQ1lPoq0wPrAIVXAbUkWdWM8u5BnYFZgb9ja5ejBqyTgjpo5mfy0hFOoMnMuVDI+7CXhXZaQ=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4m5F5lpkBZhVQJq53oe5XgJ+aFYWdrgkMwViHjRsES3KEu2m1udR21B1I77RUqie0ZYNscFzY1v9aDssMBZ/1w=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.6", "", { "os": "linux", "cpu": "arm" }, "sha512-qU0rHnA9P/ZoaDKouU1oGPxPWzDKtIfX7eOGi5jOWJKdxieUJdVV+CxWZOpDWlYTd4N3sFQvcnVLJWJ1cLP5TA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-jXy3TSTrbfgyd3UxPQeXC3wm8DAgmigzar99Km9Sf6L2OFfn/k+u3VqmpgHQw5QNfCpPe43em6Q7V76Wx7ogIQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-8kjivE5xW0qAQ9HX9reVFmZj3t+VmljDLVRJpVBEoTR+3bKMnvC7iLcoSGNIUJGOZy1mLVq7x/gerVg0T+IsYw=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-A4spQhwnWVpjWDLXnOW9PSinO2PTKJQNRmL/aIl2U/O+RARls8doDfs6R41+DAXK0ccacvRyDpR46aVQJJCoCg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YRee+6ZqdzgiQAHVSLfl3RYmqeeaWVCk796MhXhLQu2kJu2COHBkqlqsqKYx3p8Hmk5pGCQd2jTAoMWWFeyG2A=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.5", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.9", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.6", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.9", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-qAp4ooTYrBQ5pk5jgg54/U1rCJ/9FLYOkkQ/nTE+bVMseMfB6O7J8zb19YTpWuu4UdfRf5zzOrNKfl6T64MNrQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-nqpDWk0Xr8ELO/nfRUDjk1pc9wDJ3ObeDdNMHLaymc4PJBWj11gdPCWZFKSK2AVKjJQC7J2EfmSmf47GN7OuLg=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-5k9xF33xkfKpo9wCvYcegQ21VwIBU1/qEbYlVukfEIyQbEA47uK8AAwS7NVjNE3vHzcmxMYwd0l6L4pPjjm1rQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.5", "", { "dependencies": { "@tailwindcss/node": "4.1.5", "@tailwindcss/oxide": "4.1.5", "tailwindcss": "4.1.5" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-FE1stRoqdHSb7RxesMfCXE8icwI1W6zGE/512ae3ZDrpkQYTTYeSyUJPRCjZd8CwVAhpDUbi1YR8pcZioFJQ/w=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.6", "", { "dependencies": { "@tailwindcss/node": "4.1.6", "@tailwindcss/oxide": "4.1.6", "tailwindcss": "4.1.6" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-zjtqjDeY1w3g2beYQtrMAf51n5G7o+UwmyOjtsDMP7t6XyoRMOidcoKP32ps7AkNOHIXEOK0bhIC05dj8oJp4w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.76.0", "", {}, "sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.76.1", "", { "dependencies": { "@tanstack/query-core": "5.76.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/node": ["@types/node@20.17.45", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-vO9+E1smq+149wsmmLdM8SKVW7gRzLjfo0mU7kiykhV6rL+GEUhUmW7VywJNSxJHQzt9QBIHEo+3SG4MrFTqbA=="],
"@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="],
"@types/react": ["@types/react@19.1.3", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ=="],
"@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
"@types/react-dom": ["@types/react-dom@19.1.3", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg=="],
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
"@yornaath/batshit": ["@yornaath/batshit@0.10.1", "", { "dependencies": { "@yornaath/batshit-devtools": "^1.7.1" } }, "sha512-WGZ1WNoiVN6CLf28O73+6SCf+2lUn4U7TLGM9f4zOad0pn9mdoXIq8cwu3Kpf7N2OTYgWGK4eQPTflwFlduDGA=="],
"@yornaath/batshit-devtools": ["@yornaath/batshit-devtools@1.7.1", "", {}, "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
@@ -387,6 +407,8 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -529,7 +551,7 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"isbot": ["isbot@5.1.27", "", {}, "sha512-V3W56Hnztt4Wdh3VUlAMbdNicX/tOM38eChW3a2ixP6KEBJAeehxzYzTD59JrU5NCTgBZwRt9lRWr8D7eMZVYQ=="],
"isbot": ["isbot@5.1.28", "", {}, "sha512-qrOp4g3xj8YNse4biorv6O5ZShwsJM0trsoda4y7j/Su7ZtTTfVXFzbKkpgcSoDrHS8FcTuUwcU04YimZlZOxw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -573,7 +595,9 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.508.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gcP16PnexqtOFrTtv98kVsGzTfnbPekzZiQfByi2S89xfk7E/4uKE1USZqccIp58v42LqkO7MuwpCqshwSrJCg=="],
"lucide-react": ["lucide-react@0.510.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-p8SQRAMVh7NhsAIETokSqDrc5CHnDLbV29mMnzaXx+Vc/hnqQzwI2r0FMWCcoTXnbw2KEjy48xwpGdEL+ck06Q=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -593,6 +617,10 @@
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"morgan": ["morgan@1.10.0", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.0.2" } }, "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -667,7 +695,7 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.5.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw=="],
"react-router": ["react-router@7.6.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
@@ -733,12 +761,14 @@
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
"tailwindcss": ["tailwindcss@4.1.6", "", {}, "sha512-j0cGLTreM6u4OWzBeLBpycK0WIh8w7kSwcUsQZoGLHZ7xDTdM69lN64AgoIEEwFi0tnhs4wSykUa5YWxAzgFYg=="],
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -747,8 +777,6 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
"tw-animate-css": ["tw-animate-css@1.2.9", "", {}, "sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
@@ -757,7 +785,7 @@
"undici": ["undici@6.21.2", "", {}, "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g=="],
"undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@@ -793,7 +821,7 @@
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
@@ -837,6 +865,8 @@
"hosted-git-info/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],

3895
frontend.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,41 +10,45 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"@react-router/node": "^7.6.0",
"@react-router/serve": "^7.6.0",
"@tanstack/react-query": "^5.76.1",
"@yornaath/batshit": "^0.10.1",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^10.1.1",
"isbot": "^5.1.27",
"lucide-react": "^0.508.0",
"isbot": "^5.1.28",
"lucide-react": "^0.510.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.3",
"react-router": "^7.5.3",
"tailwind-merge": "^3.2.0",
"react-router": "^7.6.0",
"tailwind-merge": "^3.3.0",
"zod": "^3.24.4",
"zustand": "^5.0.4"
},
"devDependencies": {
"@react-router/dev": "^7.5.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^20",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4",
"@react-router/dev": "^7.6.0",
"@tailwindcss/vite": "^4.1.6",
"@types/node": "^22.15.18",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"tailwindcss": "^4.1.6",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"vite": "^6.3.3",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4"
}
}