366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
HashIcon,
|
|
MessageCircle,
|
|
Plus,
|
|
Search,
|
|
Volume2Icon,
|
|
} from "lucide-react";
|
|
import { Server, Channel, Message } from "@/types/types";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import Cookies from "js-cookie";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "./ui/dialog";
|
|
import { Input } from "./ui/input";
|
|
import { Label } from "./ui/label";
|
|
import { Button } from "./ui/button";
|
|
import { Card } from "./ui/card";
|
|
import { ScrollArea } from "./ui/scroll-area";
|
|
import { useRouter } from "next/navigation";
|
|
import useUser, { UserProfile } from "@/hooks/get-user";
|
|
import ProfilePicture from "./ProfilePicture";
|
|
import auth from "@/lib/auth";
|
|
import { toast } from "sonner";
|
|
import { StringMap } from "@/types/typeUtils";
|
|
import SettingsDialog from "./settings/SettingsDialog";
|
|
import Link from "next/link";
|
|
import axios from "axios";
|
|
import { get, Response } from "@/lib/request";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function AppSidebar({
|
|
children,
|
|
chatWith,
|
|
wsRef,
|
|
onNewMessage,
|
|
setUser,
|
|
server,
|
|
messages,
|
|
addMessage,
|
|
open,
|
|
setOpen,
|
|
}: Readonly<{
|
|
wsRef: React.RefObject<WebSocket | null>;
|
|
addMessage: (msg: Message) => void;
|
|
messages?: Message[];
|
|
onNewMessage?: (msg: Message) => void;
|
|
userList: StringMap<UserProfile>;
|
|
setUserList: React.Dispatch<React.SetStateAction<StringMap<UserProfile>>>;
|
|
chatWith?: string;
|
|
children?: React.ReactNode;
|
|
setUser?: React.Dispatch<React.SetStateAction<UserProfile | undefined>>;
|
|
server?: Server | undefined;
|
|
open: boolean;
|
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
}>) {
|
|
const [servers, setServers] = useState<[string, string][]>([]);
|
|
const [newServer, setNewServer] = useState({ name: "", ip: "" });
|
|
const router = useRouter();
|
|
const user = useUser();
|
|
const [userList, setUserList] = useState<StringMap<UserProfile>>({});
|
|
|
|
const dms = Array.from(
|
|
new Set(
|
|
messages
|
|
?.flatMap((item) => [item.from, item.channel_id])
|
|
.filter((item) => item !== user?.id) || []
|
|
)
|
|
);
|
|
|
|
useEffect(() => {
|
|
const s = Cookies.get("servers")?.split(",");
|
|
if (s) setServers(s.map((x) => x.trim().split("@") as [string, string]));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!user?.node_address) return;
|
|
if (setUser) setUser(user);
|
|
auth(
|
|
user?.node_address,
|
|
wsRef,
|
|
() => {},
|
|
addMessage,
|
|
(m) => {
|
|
if (m.from !== chatWith && m.from !== user.id)
|
|
toast(
|
|
`New message from ${userList[m.from]?.display_name || m.from}`,
|
|
{
|
|
description: m.contents.slice(0, 80),
|
|
action: {
|
|
label: "View",
|
|
onClick: () => router.push(`/chat/${m.from}`),
|
|
},
|
|
position: "top-right",
|
|
}
|
|
);
|
|
|
|
if (onNewMessage) onNewMessage(m);
|
|
},
|
|
messages ? messages[messages.length - 1]?.id : undefined
|
|
);
|
|
setUserList((prev) => {
|
|
prev[user.id] = user;
|
|
return prev;
|
|
});
|
|
}, [chatWith, user]);
|
|
|
|
const handleAddServer = () => {
|
|
if (!newServer.ip) return;
|
|
const updatedServers = [
|
|
...servers,
|
|
[newServer.ip, newServer.name] as [string, string],
|
|
];
|
|
|
|
setServers(updatedServers);
|
|
Cookies.set("servers", updatedServers.map((x) => x.join("@")).join(","), {
|
|
expires: 7,
|
|
});
|
|
setNewServer({ name: "", ip: "" });
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-svh md:h-screen">
|
|
<div
|
|
className={cn(
|
|
"border-r transition-all duration-300 ease-in-out overflow-hidden flex",
|
|
open ? "w-5xl md:w-md" : "w-0 md:w-md"
|
|
)}
|
|
>
|
|
<div className="w-16 flex flex-col items-center gap-4 py-4 bg-muted border-r">
|
|
<Button
|
|
onClick={() => router.push(`/chat`)}
|
|
className="bg-transparent text-accent-foreground hover:text-accent"
|
|
>
|
|
<MessageCircle className="h-6 w-6" />
|
|
</Button>
|
|
|
|
{servers.map(
|
|
([ip, name]) =>
|
|
ip && (
|
|
<Button
|
|
onClick={() => router.push(`/server/${ip}`)}
|
|
key={ip}
|
|
className="bg-transparent text-accent-foreground hover:text-accent"
|
|
>
|
|
<HashIcon className="h-6 w-6" />
|
|
</Button>
|
|
)
|
|
)}
|
|
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="icon">
|
|
<Plus className="h-5 w-5" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add server</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new server to your server list.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-3">
|
|
<Label htmlFor="name-1">Name</Label>
|
|
<Input
|
|
id="name-1"
|
|
name="name"
|
|
value={newServer.name}
|
|
onChange={(e) =>
|
|
setNewServer((prev) => ({
|
|
...prev,
|
|
name: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Voxa Server"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-3">
|
|
<Label htmlFor="ip-1">Server Ip</Label>
|
|
<Input
|
|
id="ip-1"
|
|
name="ip"
|
|
value={newServer.ip}
|
|
onChange={(e) =>
|
|
setNewServer((prev) => ({
|
|
...prev,
|
|
ip: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="192.168.1.5"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button variant="outline">Cancel</Button>
|
|
</DialogClose>
|
|
<DialogClose asChild>
|
|
<Button type="button" onClick={handleAddServer}>
|
|
Add server
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<footer className="mt-auto">
|
|
<SettingsDialog />
|
|
</footer>
|
|
</div>
|
|
|
|
{/* Right column */}
|
|
<div className="w-full flex flex-col bg-card">
|
|
{server ? (
|
|
<div className="p-3 border-b flex items-center gap-2">
|
|
<h2>{server.name}</h2>
|
|
</div>
|
|
) : (
|
|
<div className="p-3 border-b flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Find or start a conversation"
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
)}
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-2 flex flex-col gap-2">
|
|
{server ? (
|
|
<>
|
|
{server.channels.map((channel) => (
|
|
<ChannelProp key={channel.id} {...channel} />
|
|
))}
|
|
</>
|
|
) : (
|
|
<>
|
|
{dms.map((id) => (
|
|
<DMItem
|
|
name={
|
|
getUser(id, userList, setUserList)?.display_name || id
|
|
}
|
|
id={id}
|
|
status="online"
|
|
avatar={
|
|
getUser(id, userList, setUserList)?.avatar_url || ""
|
|
}
|
|
key={id}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<footer className="mt-auto pb-3 pl-3 pr-3">
|
|
<DMItem
|
|
name={user?.display_name || "Loading.."}
|
|
id={user?.id || "me"}
|
|
avatar={user?.avatar_url || ""}
|
|
status="online"
|
|
settings
|
|
/>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
{open ? (
|
|
<div
|
|
className="w-screen h-full overflow-x-hidden"
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<div className="overflow-x-hidden w-screen min-w-max flex-shrink-0">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
children
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DMItem({
|
|
name,
|
|
id,
|
|
avatar,
|
|
status,
|
|
settings,
|
|
}: {
|
|
name: string;
|
|
id: string;
|
|
avatar: string;
|
|
status: string;
|
|
settings?: boolean;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={settings ? "" : `/chat/${id}`}
|
|
className="p-2 flex flex-row items-center gap-2 cursor-pointer hover:bg-accent rounded-md"
|
|
>
|
|
<ProfilePicture name={name} url={avatar} />
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium">{name}</span>
|
|
<span className="text-xs text-muted-foreground">{status}</span>
|
|
</div>
|
|
{settings && (
|
|
<SettingsDialog
|
|
className="ml-auto w-7 h-7 p-0 flex items-center justify-center"
|
|
tab="profile"
|
|
/>
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function ChannelProp(channel: Channel) {
|
|
return (
|
|
<Card className="p-2 flex flex-row items-center gap-2 cursor-pointer hover:bg-accent">
|
|
<ChannelIcon kind={channel.kind} />
|
|
<span className="text-sm font-medium">{channel.name}</span>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ChannelIcon({ kind }: { kind: "text" | "voice" }) {
|
|
switch (kind) {
|
|
case "text":
|
|
return <HashIcon className="h-4 w-4" />;
|
|
case "voice":
|
|
return <Volume2Icon className="h-4 w-4" />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getUser(
|
|
id: string,
|
|
userList: StringMap<UserProfile>,
|
|
setUserList: React.Dispatch<React.SetStateAction<StringMap<UserProfile>>>
|
|
) {
|
|
if (userList[id]) return userList[id];
|
|
|
|
const onResponse = (res: Response<UserProfile>) => {
|
|
setUserList((prev) => {
|
|
prev[id] = res.data as UserProfile;
|
|
return prev;
|
|
});
|
|
};
|
|
|
|
get(`/api/profile/?id=${id}`, onResponse)
|
|
?.then(onResponse)
|
|
.catch((e) => {});
|
|
}
|