diff --git a/src/app/api/server/[id]/route.ts b/src/app/api/server/[id]/route.ts index f695cac..edbe085 100644 --- a/src/app/api/server/[id]/route.ts +++ b/src/app/api/server/[id]/route.ts @@ -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(); diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index db8929c..340d749 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -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(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 ( - + (m.channel_id === id && m.from === app.profile?.id) || @@ -51,6 +52,6 @@ export default function DMs() { ); }} /> - + ); } diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index a35b042..600e224 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -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 ( - +
-
+ ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index e7fa34a..e5f9f70 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {
{/* Header */}
- + > + +
- - {servers.map( - ([ip, name]) => - ip && ( - - ) - )} - - - - - - - - - Add server - - Add a new server to your server list. - - - -
-
- - - setNewServer((prev) => ({ - ...prev, - name: e.target.value, - })) - } - placeholder="Voxa Server" - /> -
-
- - - setNewServer((prev) => ({ - ...prev, - ip: e.target.value, - })) - } - placeholder="192.168.1.5" - /> -
-
- - - - - - - - - -
-
- -
- -
-
- - {/* Right column */} -
- {server ? ( -
-

{server.name}

-
- ) : ( -
-
- setQuery(e.target.value)} - /> - -
-
- )} - -
- {server ? ( - <> - {server.channels.map((channel) => ( - - ))} - - ) : ( - <> - {app.dms.map((user) => ( - - ))} - {app.dms.length === 0 && query.trim() !== "" ? ( -
- -
- ) : null} - - )} -
-
- -
- -
-
-
- {app.sidebarOpen && isMobile ? ( -
app.setSidebarOpen(false)} - > -
- {children} -
-
- ) : ( - children - )} - - ); -} - -function DMItem({ - name, - id, - avatar, - status, - settings, - app, -}: { - name: string; - id: string; - avatar: string; - status: string; - settings?: boolean; - app: App; -}) { - return ( - - -
- {name} - {status} -
- {settings && ( - - )} - - ); -} - -function ChannelProp(channel: Channel) { - return ( - - - {channel.name} - - ); -} - -function ChannelIcon({ kind }: { kind: "text" | "voice" }) { - switch (kind) { - case "text": - return ; - case "voice": - return ; - default: - return null; - } -} diff --git a/src/components/app/AppLayout.tsx b/src/components/app/AppLayout.tsx new file mode 100644 index 0000000..c65f969 --- /dev/null +++ b/src/components/app/AppLayout.tsx @@ -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 ( +
+
+ {/* Left column — servers */} + + + {/* Right column — DMs or server channels */} + {server ? ( + + ) : ( + + )} +
+ + {app.sidebarOpen && isMobile ? ( +
app.setSidebarOpen(false)} + > +
+ {children} +
+
+ ) : ( + children + )} +
+ ); +} diff --git a/src/components/app/DMItem.tsx b/src/components/app/DMItem.tsx new file mode 100644 index 0000000..535efbf --- /dev/null +++ b/src/components/app/DMItem.tsx @@ -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 ( + + +
+ {name} + {status} +
+ {settings && ( + + )} + + ); +} diff --git a/src/components/app/SidebarChannels.tsx b/src/components/app/SidebarChannels.tsx new file mode 100644 index 0000000..875740e --- /dev/null +++ b/src/components/app/SidebarChannels.tsx @@ -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 ; + case "voice": + return ; + default: + return null; + } +} + +export function ChannelItem({ channel }: { channel: Channel }) { + return ( + + + {channel.name} + + ); +} + +export default function SidebarChannels({ server }: { server: Server }) { + return ( +
+
+

{server.name}

+
+ +
+ {server.channels.map((channel: Channel) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/app/SidebarDMs.tsx b/src/components/app/SidebarDMs.tsx new file mode 100644 index 0000000..fd9832e --- /dev/null +++ b/src/components/app/SidebarDMs.tsx @@ -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 ( +
+
+
+ setQuery(e.target.value)} + /> + +
+
+ + +
+ {app.dms.map((user) => ( + + ))} + + {app.dms.length === 0 && query.trim() !== "" && ( +
+ +
+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/src/components/app/SidebarServers.tsx b/src/components/app/SidebarServers.tsx new file mode 100644 index 0000000..3b4b0e8 --- /dev/null +++ b/src/components/app/SidebarServers.tsx @@ -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 ( +
+ + + {Object.values(app.servers).map( + (srv) => + srv && ( + + + + ) + )} + + + + + + + + Join a server + + +
+ + setNewServer(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> +
+ + + + + + + + + +
+
+ +
+ +
+
+ ); +} diff --git a/src/hooks/use-app.ts b/src/hooks/use-app.ts index ec226ec..b90180d 100644 --- a/src/hooks/use-app.ts +++ b/src/hooks/use-app.ts @@ -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(null); @@ -14,8 +17,27 @@ export default function useApp(): App { const profile = useUser(); const [clientSettings, setClientSettings] = useClientSettings(); const [dms, setDms] = useState([]); + const [servers, setServers] = useState>({}); + + 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, diff --git a/src/hooks/use-async.ts b/src/hooks/use-async.ts new file mode 100644 index 0000000..7742870 --- /dev/null +++ b/src/hooks/use-async.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +export default function useAsync(func: () => Promise) { + const [loading, setLoading] = useState(true); + const [value, setValue] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + func() + .then(setValue) + .catch(setError) + .finally(() => setLoading(false)); + }, []); + + return { value, loading, error }; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9e330bc..5b7bafa 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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); diff --git a/src/types/app.ts b/src/types/app.ts index 2624abb..45f7ceb 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -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 = Dispatch>; @@ -13,6 +13,9 @@ export default interface App { clientSettings: ClientSettings; setClientSettings: SetState; + servers: StringMap; + setServers: SetState>; + profiles: StringMap; setProfiles: SetState>; diff --git a/src/types/types.ts b/src/types/types.ts index 9bb3c32..6301fa3 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -15,5 +15,6 @@ export interface Message { export interface Server { id: string; name: string; + icon_url: string; channels: Channel[]; }