@@ -13,7 +13,7 @@ export async function GET(
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("servers")
|
||||
.select("id, created_at, owner, address")
|
||||
.select("id, name, created_at, owner, address, icon_url")
|
||||
.eq("id", id)
|
||||
.maybeSingle();
|
||||
|
||||
|
||||
+17
-16
@@ -1,39 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import MessageBox from "@/components/MessageBox";
|
||||
import AppSidebar from "@/components/Sidebar";
|
||||
import AppLayout from "@/components/app/AppLayout";
|
||||
import { useParams } from "next/navigation";
|
||||
import useApp from "@/hooks/use-app";
|
||||
import { useEffect, useRef } from "react";
|
||||
import auth from "@/lib/auth";
|
||||
import useAsync from "@/hooks/use-async";
|
||||
|
||||
export default function DMs() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const app = useApp();
|
||||
const targetNode = useRef<WebSocket | null>(null);
|
||||
const { value: target } = useAsync(() => app.getUserById(id));
|
||||
|
||||
useEffect(() => {
|
||||
async function connectTargetNode() {
|
||||
const target = await app.getUserById(id);
|
||||
if (target) {
|
||||
auth(
|
||||
target.node_address,
|
||||
targetNode,
|
||||
() => {},
|
||||
app.addMessage,
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
if (!target) return;
|
||||
auth(
|
||||
target.node_address,
|
||||
targetNode,
|
||||
() => {},
|
||||
app.addMessage,
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
connectTargetNode().then(() => {});
|
||||
}, [id]);
|
||||
connectTargetNode();
|
||||
}, [target]);
|
||||
|
||||
return (
|
||||
<AppSidebar app={app}>
|
||||
<AppLayout app={app}>
|
||||
<MessageBox
|
||||
app={app}
|
||||
channelName={app.profiles[id]?.display_name || id}
|
||||
channelName={target?.display_name || id}
|
||||
channelIcon={target?.avatar_url || "_"}
|
||||
messages={app.messages.filter(
|
||||
(m) =>
|
||||
(m.channel_id === id && m.from === app.profile?.id) ||
|
||||
@@ -51,6 +52,6 @@ export default function DMs() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AppSidebar>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import ProfilePicture from "@/components/ProfilePicture";
|
||||
import SettingsDialog from "@/components/settings/SettingsDialog";
|
||||
import AppSidebar from "@/components/Sidebar";
|
||||
import AppLayout from "@/components/app/AppLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -28,7 +28,7 @@ export default function ChatHub() {
|
||||
const activeServers: Server[] = [];
|
||||
|
||||
return (
|
||||
<AppSidebar app={app}>
|
||||
<AppLayout app={app}>
|
||||
<div className="h-svh w-full max-h-svh flex flex-col py-5 px-5 gap-5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -140,6 +140,6 @@ export default function ChatHub() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AppSidebar>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-10
@@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { MessageCircle, Users, Shield } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Logo from "@/components/Logo";
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,13 +15,14 @@ export default function LandingPage() {
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-8 py-6 border-b bg-background/60 backdrop-blur-md sticky top-0 z-50">
|
||||
<motion.img
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-2xl font-bold"
|
||||
src="/voxa.svg"
|
||||
/>
|
||||
>
|
||||
<Logo />
|
||||
</motion.div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="ghost" onClick={() => router.push("/login")}>
|
||||
Login
|
||||
@@ -35,13 +37,13 @@ export default function LandingPage() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-4xl md:text-6xl font-extrabold tracking-tight mb-6"
|
||||
className="text-4xl md:text-4xl font-extrabold tracking-tight mb-6"
|
||||
>
|
||||
Chat. Connect. Collaborate.
|
||||
Your Conversations, Your Privacy, Your Community.
|
||||
</motion.h2>
|
||||
<p className="text-lg text-muted-foreground max-w-xl mb-8">
|
||||
A modern chat app for teams and friends. Stay connected with real-time
|
||||
messaging, secure channels, and seamless collaboration.
|
||||
A modern chat app for teams, communities and friends. Stay connected
|
||||
with real-time messaging, secure channels, and seamless collaboration.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button size="lg" onClick={() => router.push("/signup")}>
|
||||
@@ -62,17 +64,17 @@ export default function LandingPage() {
|
||||
<FeatureCard
|
||||
icon={<MessageCircle className="h-10 w-10 text-primary" />}
|
||||
title="Real-Time Chat"
|
||||
description="Experience instant messaging with typing indicators, read receipts, and more with our self-hostable node system."
|
||||
description="Experience instant messaging with our self-hostable node / server system."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users className="h-10 w-10 text-primary" />}
|
||||
title="Group Channels"
|
||||
description="Organize conversations into channels for projects, teams, or friends by hosting your own server."
|
||||
description="Organize conversations into channels for projects, teams, or friends by hosting your own server with plugins."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Shield className="h-10 w-10 text-primary" />}
|
||||
title="Secure & Private"
|
||||
description="Your data is encrypted and protected, ensuring safe communication."
|
||||
description="Your messages are managed by your chosen node server, including your own self hosted node."
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import MessageBox from "@/components/MessageBox";
|
||||
import AppSidebar from "@/components/Sidebar";
|
||||
import AppLayout from "@/components/app/AppLayout";
|
||||
import { Server as ServerType } from "@/types/types";
|
||||
import auth from "@/lib/auth";
|
||||
import { useServerMessages } from "@/hooks/use-messages";
|
||||
@@ -35,7 +35,7 @@ export default function Server() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AppSidebar app={app} server={server}>
|
||||
<AppLayout app={app} server={server}>
|
||||
<MessageBox
|
||||
app={app}
|
||||
channelName="General"
|
||||
@@ -52,6 +52,6 @@ export default function Server() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AppSidebar>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = React.SVGProps<SVGSVGElement>;
|
||||
|
||||
export default function Logo(props: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={159}
|
||||
height={29}
|
||||
viewBox="0 0 159 29"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props["aria-hidden"] ?? true}
|
||||
role={props.role ?? "img"}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M12 13.96C12.6667 15.2133 13.0133 15.8667 13.04 15.92C13.4933 16.7467 13.5067 17.5733 13.08 18.4L11.6 21.12L0.6 0.479998H3.12C4.26667 0.479998 5.09333 0.959998 5.6 1.92C7.89333 6.21333 10.0267 10.2267 12 13.96ZM35.24 0.479998C29.8 10.72 25.32 19.12 21.8 25.68C20.9467 27.3067 20.5067 28.1333 20.48 28.16C20.1867 28.72 19.72 29 19.08 29H15.8C21.0533 19.16 25.8667 10.1333 30.24 1.92C30.7733 0.959998 31.6 0.479998 32.72 0.479998H35.24ZM71.8041 7.6C73.1374 9.78667 73.8041 12.1733 73.8041 14.76C73.8041 17.32 73.1374 19.7067 71.8041 21.92C70.4707 24.1067 68.6574 25.84 66.3641 27.12C64.0974 28.3733 61.6174 29 58.9241 29C56.2307 29 53.7374 28.3733 51.4441 27.12C49.1507 25.84 47.3374 24.1067 46.0041 21.92C44.6707 19.7067 44.0041 17.32 44.0041 14.76C44.0041 12.1733 44.6707 9.78667 46.0041 7.6C47.3374 5.41333 49.1507 3.68 51.4441 2.4C53.7374 1.12 56.2307 0.479998 58.9241 0.479998C61.6174 0.479998 64.0974 1.12 66.3641 2.4C68.6574 3.68 70.4707 5.41333 71.8041 7.6ZM66.8441 7.2C64.6574 5.09333 62.0041 4.04 58.8841 4.04C55.7907 4.04 53.1507 5.09333 50.9641 7.2C48.7774 9.28 47.6841 11.8 47.6841 14.76C47.6841 17.6933 48.7774 20.2133 50.9641 22.32C53.1507 24.4 55.7907 25.44 58.8841 25.44C62.0041 25.44 64.6574 24.4 66.8441 22.32C69.0307 20.2133 70.1241 17.6933 70.1241 14.76C70.1241 11.8 69.0307 9.28 66.8441 7.2ZM113.389 0.479998L99.7494 14.72L113.389 29H110.149C109.323 29 108.603 28.68 107.989 28.04L97.5094 17.08L87.0294 28.04C86.416 28.68 85.696 29 84.8694 29H81.3094C84.5894 25.56 93.376 16.3733 107.669 1.44C108.283 0.799998 109.003 0.479998 109.829 0.479998H113.389ZM92.5094 9.56C92.1894 9.88 91.816 10.04 91.3894 10.04C90.9627 10.04 90.5894 9.88 90.2694 9.56L81.6294 0.479998H84.8694C85.696 0.479998 86.416 0.799998 87.0294 1.44L93.6294 8.36L92.5094 9.56ZM140.693 4.52C131.413 20.2 126.679 28.1867 126.493 28.48C126.306 28.8267 126.013 29 125.613 29H122.053C129.279 16.6 134.479 7.65333 137.653 2.16C137.919 1.65333 138.306 1.25333 138.813 0.959999C139.346 0.639998 139.919 0.479998 140.533 0.479998H142.773C145.066 4.77333 150.146 14.28 158.013 29H155.093C154.213 29 153.559 28.6133 153.133 27.84C152.013 25.7067 150.453 22.7867 148.453 19.08L140.693 4.52Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -120,11 +120,13 @@ export default function MessageBox({
|
||||
app,
|
||||
sendMessage,
|
||||
channelName,
|
||||
channelIcon,
|
||||
messages,
|
||||
}: {
|
||||
app: App;
|
||||
sendMessage: (m: string) => void;
|
||||
channelName?: string;
|
||||
channelIcon?: string;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
@@ -136,10 +138,15 @@ export default function MessageBox({
|
||||
<header className="h-12 py-4 flex items-center border-b text-sm font-semibold">
|
||||
<span
|
||||
onClick={() => app.setSidebarOpen(true)}
|
||||
className="cursor-pointer flex"
|
||||
className="cursor-pointer flex gap-2"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
{channelName}
|
||||
<ChevronLeftIcon className="w-5 h-5 my-auto" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
{channelIcon && (
|
||||
<ProfilePicture name={channelName} url={channelIcon} className="size-7" />
|
||||
)}
|
||||
{channelName}
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
|
||||
export default function ProfilePicture({
|
||||
name,
|
||||
url,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
url: string | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Avatar className="h-8 w-8">
|
||||
<Avatar className={cn("h-8 w-8", className)}>
|
||||
<AvatarImage src={url} alt={name} />
|
||||
<AvatarFallback>{name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
HashIcon,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Volume2Icon,
|
||||
} from "lucide-react";
|
||||
import { Server, Channel, Message } from "@/types/types";
|
||||
import React, { useEffect, useMemo, 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 { UserProfile } from "@/hooks/get-user";
|
||||
import ProfilePicture from "./ProfilePicture";
|
||||
import auth from "@/lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import SettingsDialog from "./settings/SettingsDialog";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import App from "@/types/app";
|
||||
import { ProfileSettings } from "@/types/settings";
|
||||
import axios from "axios";
|
||||
import { useIsMobile } from "@/hooks/is-mobile";
|
||||
|
||||
export default function AppSidebar({
|
||||
children,
|
||||
chatWith,
|
||||
onNewMessage,
|
||||
server,
|
||||
app,
|
||||
}: Readonly<{
|
||||
onNewMessage?: (msg: Message) => void;
|
||||
chatWith?: string;
|
||||
children?: React.ReactNode;
|
||||
server?: Server | undefined;
|
||||
app: App;
|
||||
}>) {
|
||||
const [servers, setServers] = useState<[string, string][]>([]);
|
||||
const [newServer, setNewServer] = useState({ name: "", ip: "" });
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const dms = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
app.messages
|
||||
?.flatMap((item) => [item.from, item.channel_id])
|
||||
.filter((item) => item !== app.profile?.id) || []
|
||||
)
|
||||
);
|
||||
}, [app.messages, app.profile?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const users = await Promise.all(dms.map((id) => app.getUserById(id)));
|
||||
app.setDms(
|
||||
users.filter(
|
||||
(user) =>
|
||||
user !== null &&
|
||||
(user?.username.toLowerCase().includes(query.toLowerCase()) ||
|
||||
user?.id.toLowerCase().includes(query.toLowerCase()))
|
||||
) as UserProfile[]
|
||||
);
|
||||
};
|
||||
load();
|
||||
}, [dms, query]);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = Cookies.get("servers");
|
||||
if (!stored) return;
|
||||
const parsed = stored
|
||||
.split(",")
|
||||
.map((x) => x.trim().split("@"))
|
||||
.filter(([ip, name]) => ip && name) as [string, string][];
|
||||
setServers(parsed);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!app.profile?.node_address) return;
|
||||
auth(
|
||||
app.profile?.node_address,
|
||||
app.node,
|
||||
() => {},
|
||||
app.addMessage,
|
||||
(m) => {
|
||||
if (m.from !== chatWith && m.from !== app.profile?.id)
|
||||
toast(
|
||||
`New message from ${app.profiles[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);
|
||||
},
|
||||
app.messages ? app.messages[app.messages.length - 1]?.id : undefined
|
||||
);
|
||||
app.setProfiles((prev) => {
|
||||
if (app.profile) prev[app.profile.id] = app.profile;
|
||||
return prev;
|
||||
});
|
||||
}, [chatWith, app.profile]);
|
||||
|
||||
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(","));
|
||||
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",
|
||||
app.sidebarOpen && isMobile ? "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 profile={app.profile as ProfileSettings} />
|
||||
</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">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Find or start a conversation"
|
||||
className="pr-8 h-8"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 flex flex-col gap-2">
|
||||
{server ? (
|
||||
<>
|
||||
{server.channels.map((channel) => (
|
||||
<ChannelProp key={channel.id} {...channel} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{app.dms.map((user) => (
|
||||
<DMItem
|
||||
name={user.display_name}
|
||||
id={user.id}
|
||||
status="online"
|
||||
avatar={user.avatar_url}
|
||||
key={user.id}
|
||||
app={app}
|
||||
/>
|
||||
))}
|
||||
{app.dms.length === 0 && query.trim() !== "" ? (
|
||||
<div className="px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-sm"
|
||||
onClick={async () => {
|
||||
const response = await axios.get("/api/profile", {
|
||||
params: { username: query },
|
||||
});
|
||||
const user = response.data as UserProfile;
|
||||
router.push(`/chat/${user.id}`);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start new DM with “{query}”
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<footer className="mt-auto pb-3 pl-3 pr-3">
|
||||
<DMItem
|
||||
name={app.profile?.display_name || "Loading.."}
|
||||
id={app.profile?.id || "me"}
|
||||
avatar={app.profile?.avatar_url || ""}
|
||||
status="online"
|
||||
app={app}
|
||||
settings
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{app.sidebarOpen && isMobile ? (
|
||||
<div
|
||||
className="w-full h-full overflow-x-hidden"
|
||||
onClick={() => app.setSidebarOpen(false)}
|
||||
>
|
||||
<div className="overflow-x-hidden min-w-screen flex-shrink-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DMItem({
|
||||
name,
|
||||
id,
|
||||
avatar,
|
||||
status,
|
||||
settings,
|
||||
app,
|
||||
}: {
|
||||
name: string;
|
||||
id: string;
|
||||
avatar: string;
|
||||
status: string;
|
||||
settings?: boolean;
|
||||
app: App;
|
||||
}) {
|
||||
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"
|
||||
profile={app.profile as ProfileSettings}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { get } from "@/lib/request";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import auth from "@/lib/auth";
|
||||
import { useIsMobile } from "@/hooks/is-mobile";
|
||||
import App from "@/types/app";
|
||||
import { Server, Message } from "@/types/types";
|
||||
|
||||
import SidebarServers from "./SidebarServers";
|
||||
import SidebarDMs from "./SidebarDMs";
|
||||
import SidebarChannels from "./SidebarChannels";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
chatWith,
|
||||
onNewMessage,
|
||||
server,
|
||||
app,
|
||||
}: {
|
||||
onNewMessage?: (msg: Message) => void;
|
||||
chatWith?: string;
|
||||
children?: React.ReactNode;
|
||||
server?: Server;
|
||||
app: App;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (!app.profile?.node_address) return;
|
||||
|
||||
auth(
|
||||
app.profile.node_address,
|
||||
app.node,
|
||||
() => {},
|
||||
app.addMessage,
|
||||
(m) => {
|
||||
if (m.from !== chatWith && m.from !== app.profile?.id)
|
||||
toast(
|
||||
`New message from ${app.profiles[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);
|
||||
},
|
||||
app.messages?.[app.messages.length - 1]?.id
|
||||
);
|
||||
|
||||
app.setProfiles((prev) => {
|
||||
if (app.profile) prev[app.profile.id] = app.profile;
|
||||
return prev;
|
||||
});
|
||||
}, [chatWith, app.profile]);
|
||||
|
||||
return (
|
||||
<div className="flex h-svh md:h-screen">
|
||||
<div
|
||||
className={cn(
|
||||
"border-r transition-all duration-300 ease-in-out overflow-hidden flex",
|
||||
app.sidebarOpen && isMobile ? "w-5xl md:w-md" : "w-0 md:w-md"
|
||||
)}
|
||||
>
|
||||
{/* Left column — servers */}
|
||||
<SidebarServers app={app} />
|
||||
|
||||
{/* Right column — DMs or server channels */}
|
||||
{server ? (
|
||||
<SidebarChannels server={server} />
|
||||
) : (
|
||||
<SidebarDMs app={app} query={query} setQuery={setQuery} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{app.sidebarOpen && isMobile ? (
|
||||
<div
|
||||
className="w-full h-full overflow-x-hidden"
|
||||
onClick={() => app.setSidebarOpen(false)}
|
||||
>
|
||||
<div className="overflow-x-hidden min-w-screen flex-shrink-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ProfilePicture from "../ProfilePicture";
|
||||
import SettingsDialog from "../settings/SettingsDialog";
|
||||
import App from "@/types/app";
|
||||
import { ProfileSettings } from "@/types/settings";
|
||||
|
||||
export default function DMItem({
|
||||
name,
|
||||
id,
|
||||
avatar,
|
||||
status,
|
||||
settings,
|
||||
app,
|
||||
}: {
|
||||
name: string;
|
||||
id: string;
|
||||
avatar: string;
|
||||
status: string;
|
||||
settings?: boolean;
|
||||
app: App;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={settings ? "" : `/chat/${id}`}
|
||||
className="p-2 flex 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"
|
||||
profile={app.profile as ProfileSettings}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Server, Channel } from "@/types/types";
|
||||
import { Card } from "../ui/card";
|
||||
import { HashIcon, Volume2Icon } from "lucide-react";
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function ChannelItem({ channel }: { channel: Channel }) {
|
||||
return (
|
||||
<Card className="p-2 flex items-center gap-2 cursor-pointer hover:bg-accent">
|
||||
<ChannelIcon kind={channel.kind} />
|
||||
<span className="text-sm font-medium">{channel.name}</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidebarChannels({ server }: { server: Server }) {
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-card/50">
|
||||
<div className="p-3 border-b flex items-center gap-2">
|
||||
<h2>{server.name}</h2>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 flex flex-col gap-2">
|
||||
{server.channels.map((channel: Channel) => (
|
||||
<ChannelItem key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Button } from "../ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import axios from "axios";
|
||||
import DMItem from "./DMItem";
|
||||
import App from "@/types/app";
|
||||
import { UserProfile } from "@/hooks/get-user";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function SidebarDMs({
|
||||
app,
|
||||
query,
|
||||
setQuery,
|
||||
}: {
|
||||
app: App;
|
||||
query: string;
|
||||
setQuery: (v: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const users = await Promise.all(
|
||||
app.messages
|
||||
.flatMap((m) => [m.from, m.channel_id])
|
||||
.filter((id) => id !== app.profile?.id)
|
||||
.map((id) => app.getUserById(id))
|
||||
);
|
||||
app.setDms(
|
||||
users.filter(
|
||||
(user) =>
|
||||
user &&
|
||||
(user.username.toLowerCase().includes(query.toLowerCase()) ||
|
||||
user.id.toLowerCase().includes(query.toLowerCase()))
|
||||
) as UserProfile[]
|
||||
);
|
||||
};
|
||||
load();
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-card/50">
|
||||
<div className="p-3 border-b flex items-center">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Find or start a conversation"
|
||||
className="pr-8 h-8"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 flex flex-col gap-2">
|
||||
{app.dms.map((user) => (
|
||||
<DMItem
|
||||
key={user.id}
|
||||
name={user.display_name}
|
||||
id={user.id}
|
||||
avatar={user.avatar_url}
|
||||
status="online"
|
||||
app={app}
|
||||
/>
|
||||
))}
|
||||
|
||||
{app.dms.length === 0 && query.trim() !== "" && (
|
||||
<div className="px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-sm"
|
||||
onClick={async () => {
|
||||
const res = await axios.get("/api/profile", {
|
||||
params: { username: query },
|
||||
});
|
||||
const user = res.data as UserProfile;
|
||||
router.push(`/chat/${user.id}`);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start new DM with “{query}”
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<footer className="mt-auto pb-3 px-3">
|
||||
<DMItem
|
||||
name={app.profile?.display_name || "Loading.."}
|
||||
id={app.profile?.id || "me"}
|
||||
avatar={app.profile?.avatar_url || ""}
|
||||
status="online"
|
||||
app={app}
|
||||
settings
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, MessageCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Cookies from "js-cookie";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { get } from "@/lib/request";
|
||||
import { Server } from "@/types/types";
|
||||
import App from "@/types/app";
|
||||
import ProfilePicture from "../ProfilePicture";
|
||||
import SettingsDialog from "../settings/SettingsDialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SidebarServers({ app }: { app: App }) {
|
||||
const [newServer, setNewServer] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleAddServer = async () => {
|
||||
if (!newServer || Object.hasOwn(app.servers, newServer)) return;
|
||||
try {
|
||||
const server = (await get(`/api/server/${newServer}`)).data as Server;
|
||||
app.setServers((prev) => ({ ...prev, [server.id]: server }));
|
||||
Cookies.set("servers", Object.keys(app.servers).join(","));
|
||||
setNewServer("");
|
||||
} catch (e: any) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-16 flex flex-col items-center gap-4 py-4 border-r">
|
||||
<Button
|
||||
onClick={() => router.push(`/chat`)}
|
||||
className="bg-transparent text-accent-foreground hover:text-accent"
|
||||
>
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
{Object.values(app.servers).map(
|
||||
(srv) =>
|
||||
srv && (
|
||||
<Link href={`/server/${srv.id}`} key={srv.id}>
|
||||
<ProfilePicture name={srv.name} url={srv.icon_url} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Join a server</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label htmlFor="server-id">Server ID</Label>
|
||||
<Input
|
||||
id="server-id"
|
||||
value={newServer}
|
||||
onChange={(e) => setNewServer(e.target.value)}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={handleAddServer}>Join server</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<footer className="mt-auto">
|
||||
<SettingsDialog profile={app.profile!} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+23
-1
@@ -1,10 +1,13 @@
|
||||
import App from "@/types/app";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMessages } from "./use-messages";
|
||||
import { StringMap } from "@/types/typeUtils";
|
||||
import useUser, { UserProfile } from "./get-user";
|
||||
import { useClientSettings } from "./use-settings";
|
||||
import { get, Response } from "@/lib/request";
|
||||
import { Server } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function useApp(): App {
|
||||
const node = useRef<WebSocket | null>(null);
|
||||
@@ -14,8 +17,27 @@ export default function useApp(): App {
|
||||
const profile = useUser();
|
||||
const [clientSettings, setClientSettings] = useClientSettings();
|
||||
const [dms, setDms] = useState<UserProfile[]>([]);
|
||||
const [servers, setServers] = useState<StringMap<Server>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const stored = Cookies.get("servers");
|
||||
if (!stored) return;
|
||||
Promise.all(
|
||||
stored
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter((id) => id)
|
||||
.map((id) => get(`/api/server/${id}`))
|
||||
)
|
||||
.then((o) =>
|
||||
setServers(Object.fromEntries(o.map((i) => [i.data.id, i.data])))
|
||||
)
|
||||
.catch(toast.error);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
servers,
|
||||
setServers,
|
||||
dms,
|
||||
setDms,
|
||||
node,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useAsync<T, E = any>(func: () => Promise<T>) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [value, setValue] = useState<T>();
|
||||
const [error, setError] = useState<E>();
|
||||
|
||||
useEffect(() => {
|
||||
func()
|
||||
.then(setValue)
|
||||
.catch(setError)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { value, loading, error };
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export default async function auth(
|
||||
) {
|
||||
console.log("Authenticating with server id:", id);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const ip = ((await get(`/api/server/${id}`)).data as any).address as string;
|
||||
|
||||
console.log("Authenticating with server at:", ip);
|
||||
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
import { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { StringMap } from "./typeUtils";
|
||||
import { UserProfile } from "@/hooks/get-user";
|
||||
import { Message } from "./types";
|
||||
import { Message, Server } from "./types";
|
||||
import { ClientSettings } from "./settings";
|
||||
|
||||
export type SetState<T> = Dispatch<SetStateAction<T>>;
|
||||
@@ -13,6 +13,9 @@ export default interface App {
|
||||
clientSettings: ClientSettings;
|
||||
setClientSettings: SetState<ClientSettings>;
|
||||
|
||||
servers: StringMap<Server>;
|
||||
setServers: SetState<StringMap<Server>>;
|
||||
|
||||
profiles: StringMap<UserProfile | null>;
|
||||
setProfiles: SetState<StringMap<UserProfile | null>>;
|
||||
|
||||
|
||||
@@ -15,5 +15,6 @@ export interface Message {
|
||||
export interface Server {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_url: string;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user