diff --git a/app/components/chat-message-attachment.tsx b/app/components/chat-message-attachment.tsx index ed751b2..3be8f97 100644 --- a/app/components/chat-message-attachment.tsx +++ b/app/components/chat-message-attachment.tsx @@ -10,7 +10,7 @@ import { DialogTitle, DialogTrigger, } from "~/components/ui/dialog"; // Shadcn UI Dialog -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; // Shadcn UI Tooltip +import { Tooltip, TooltipContent, 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"; @@ -28,68 +28,67 @@ export default function ChatMessageAttachment({ file }: ChatMessageAttachmentPro function GenericFileAttachment({ file }: ChatMessageAttachmentProps) { return ( - -
-
- -
-
-

{file.filename}

-

{formatFileSize(file.size)}

-
-
- - - - - Download - - - - - - Open in new tab - -
+
+
+
- +
+

{file.filename}

+

{formatFileSize(file.size)}

+
+
+ + + + + Download + + + + + + Open in new tab + +
+
); } function ImageAttachment({ file }: ChatMessageAttachmentProps) { return ( - -
- - - {file.filename} -
- -
-
-
-
-

- {file.filename} ({formatFileSize(file.size)}) -

-
+
+ + + {file.filename} +
+ +
+
+
+
+

+ {file.filename} ({formatFileSize(file.size)}) +

- +
diff --git a/app/components/custom-ui/server-button.tsx b/app/components/custom-ui/server-button.tsx index 9ca21db..7834489 100644 --- a/app/components/custom-ui/server-button.tsx +++ b/app/components/custom-ui/server-button.tsx @@ -3,6 +3,7 @@ import { NavLink } from "react-router"; import type { Server } from "~/lib/api/types"; import { cn, getFirstLetters } from "~/lib/utils"; import { Button } from "../ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export interface ServerButtonProps { server: Server; @@ -12,14 +13,25 @@ export function ServerButton({ server }: ServerButtonProps) { return ( {({ isActive }) => ( - + + + + + +

{server.name}

+
+
)}
); diff --git a/app/components/manager/webrtc-connection-manager.tsx b/app/components/manager/webrtc-connection-manager.tsx index 116361e..ebd607d 100644 --- a/app/components/manager/webrtc-connection-manager.tsx +++ b/app/components/manager/webrtc-connection-manager.tsx @@ -35,7 +35,7 @@ export function WebRTCConnectionManager() { voiceState.leaveVoiceChannel(); unsubscribe(); }; - }); + }, []); useEffect(() => { if (webrtc.status === ConnectionState.DISCONNECTED) { diff --git a/app/components/message-box.tsx b/app/components/message-box.tsx index 7b5cd9a..4b901c4 100644 --- a/app/components/message-box.tsx +++ b/app/components/message-box.tsx @@ -7,7 +7,7 @@ import type { Uuid } from "~/lib/api/types"; // Adjust path import { cn, formatFileSize } from "~/lib/utils"; // Adjust path import TextBox from "./custom-ui/text-box"; // Adjust path, assuming TextBox is in ./custom-ui/ import { Button } from "./ui/button"; // Adjust path -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; // Adjust path +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; // Adjust path export interface MessageBoxProps { channelId: string; @@ -96,28 +96,26 @@ export default function MessageBox({ channelId }: MessageBoxProps) { >
-

{file.name}

+

{file.name}

{formatFileSize(file.size)}

- - - - - - -

Remove attachment

-
-
-
+ + + + + +

Remove attachment

+
+
))}
@@ -133,40 +131,38 @@ export default function MessageBox({ channelId }: MessageBoxProps) { gridTemplateRows: "auto 1fr", }} > - - - -
- - -
-
- - {attachments.length >= 10 ? ( -

Maximum 10 attachments

- ) : ( -

Add attachment ({attachmentsRemaining} remaining)

- )} -
-
-
+ + +
+ + +
+
+ + {attachments.length >= 10 ? ( +

Maximum 10 attachments

+ ) : ( +

Add attachment ({attachmentsRemaining} remaining)

+ )} +
+
{ - await import("~/lib/api/client/server").then((m) => m.default.delet((data as { serverId: string }).serverId)); + await import("~/lib/api/client/server").then((m) => m.default.deleteServer((data as { serverId: string }).serverId)); onClose(); }; diff --git a/app/hooks/use-fetch-user.ts b/app/hooks/use-fetch-user.ts index f95e138..8b61115 100644 --- a/app/hooks/use-fetch-user.ts +++ b/app/hooks/use-fetch-user.ts @@ -3,6 +3,25 @@ import { getUser } from "~/lib/api/client/user"; import type { UserId } from "~/lib/api/types"; import { useUsersStore } from "~/stores/users-store"; +async function fetchCurrentUser() { + const { setCurrentUserId, addUser } = useUsersStore.getState(); + + const user = await import("~/lib/api/client/user").then((m) => m.default.me()); + setCurrentUserId(user.id); + addUser(user); + + return null; +} + +export const useFetchCurrentUser = () => { + const query = useQuery({ + queryKey: ["users", "@me"], + queryFn: fetchCurrentUser, + }); + + return query; +}; + export const useFetchUser = (userId: UserId) => { const query = useQuery({ queryKey: ["users", userId], diff --git a/app/lib/api/client/server.ts b/app/lib/api/client/server.ts index 2b7511d..c503ac8 100644 --- a/app/lib/api/client/server.ts +++ b/app/lib/api/client/server.ts @@ -29,7 +29,7 @@ export async function get(serverId: ServerId) { return response.data as Server; } -export async function delet(serverId: ServerId) { +export async function deleteServer(serverId: ServerId) { const response = await axios.delete(`/servers/${serverId}`); return response.data as Server; @@ -76,7 +76,7 @@ export default { list, listChannels, get, - delet, + deleteServer, createChannel, getChannel, deleteChannel, diff --git a/app/lib/consts.ts b/app/lib/consts.ts index 2769b70..5078c79 100644 --- a/app/lib/consts.ts +++ b/app/lib/consts.ts @@ -1 +1,3 @@ -export const API_URL = "http://localhost:12345/api/v1"; +export const API_URL = "http://192.168.0.133:12345/api/v1"; +export const GATEWAY_URL = "ws://192.168.0.133:12345/gateway/ws"; +export const VOICE_GATEWAY_URL = "ws://192.168.0.133:12345/voice/ws"; diff --git a/app/routes/app/current-user-provider.tsx b/app/routes/app/current-user-provider.tsx new file mode 100644 index 0000000..fb69afc --- /dev/null +++ b/app/routes/app/current-user-provider.tsx @@ -0,0 +1,11 @@ +import { useFetchCurrentUser } from "~/hooks/use-fetch-user"; + +interface WrapperProps { + children: React.ReactNode; +} + +export default function CurrentUserProvider({ children }: WrapperProps) { + useFetchCurrentUser(); + + return <>{children}; +} diff --git a/app/routes/app/layout.tsx b/app/routes/app/layout.tsx index 1b17bfb..43adda0 100644 --- a/app/routes/app/layout.tsx +++ b/app/routes/app/layout.tsx @@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { Outlet } from "react-router"; import AppLayout from "~/components/app-layout"; import { useServerListStore } from "~/stores/server-list-store"; -import { useUsersStore } from "~/stores/users-store"; async function fetchServers() { const { addServers } = useServerListStore.getState(); @@ -13,27 +12,12 @@ async function fetchServers() { return null; } -async function fetchCurrentUser() { - const { setCurrentUserId, addUser } = useUsersStore.getState(); - - const user = await import("~/lib/api/client/user").then((m) => m.default.me()); - setCurrentUserId(user.id); - addUser(user); - - return null; -} - export default function Layout() { useQuery({ queryKey: ["servers"], queryFn: fetchServers, }); - useQuery({ - queryKey: ["users", "@me"], - queryFn: fetchCurrentUser, - }); - return ( diff --git a/app/routes/app/providers.tsx b/app/routes/app/providers.tsx index bd5deed..4580c44 100644 --- a/app/routes/app/providers.tsx +++ b/app/routes/app/providers.tsx @@ -4,6 +4,7 @@ import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway- import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager"; import ModalProvider from "~/components/providers/modal-provider"; import { useTokenStore } from "~/stores/token-store"; +import CurrentUserProvider from "./current-user-provider"; export async function clientLoader() { const token = useTokenStore.getState().token; @@ -29,10 +30,12 @@ export default function Layout() { return ( <> - - - - + + + + + + ); diff --git a/app/stores/gateway-store.ts b/app/stores/gateway-store.ts index 5ea0d0c..2c10dec 100644 --- a/app/stores/gateway-store.ts +++ b/app/stores/gateway-store.ts @@ -1,6 +1,7 @@ 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 { GATEWAY_URL } from "~/lib/consts"; 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"; @@ -9,8 +10,6 @@ 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["data"]) => { useServerListStore.getState().addServer(data.server); diff --git a/app/stores/private-channels-store.ts b/app/stores/private-channels-store.ts index 5e268af..02d818c 100644 --- a/app/stores/private-channels-store.ts +++ b/app/stores/private-channels-store.ts @@ -16,7 +16,6 @@ export const usePrivateChannelsStore = create()( set((state) => { for (const channel of channels) { state.channels[channel.id] = channel; - console.log("add channel", channel); } }), addChannel: (channel: RecipientChannel) => diff --git a/app/stores/users-store.tsx b/app/stores/users-store.tsx index 0f218dd..fa4abfd 100644 --- a/app/stores/users-store.tsx +++ b/app/stores/users-store.tsx @@ -1,54 +1,20 @@ -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; currentUserId: UserId | undefined; - fetchUsersIfNotPresent: (userIds: UserId[]) => Promise; addUser: (user: PartialUser) => void; removeUser: (userId: UserId) => void; setCurrentUserId: (userId: UserId) => void; getCurrentUser: () => FullUser | undefined; }; -const usersFetcher = batshitCreate({ - fetcher: async (userIds: UserId[]) => { - const users = []; - - for (const userId of userIds) { - users.push(getUser(userId)); - } - - return await Promise.all(users); - }, - resolver: keyResolver("id"), -}); - export const useUsersStore = create()( immer((set, get) => ({ users: {}, currentUserId: undefined, - fetchUsersIfNotPresent: async (userIds) => { - const userPromises: Promise[] = []; - 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; @@ -68,6 +34,9 @@ export const useUsersStore = create()( state.currentUserId = userId; }), - getCurrentUser: () => (get().currentUserId ? (get().users[get().currentUserId!] as FullUser) : undefined), + getCurrentUser: () => { + const currentUserId = get().currentUserId; + return currentUserId ? (get().users[currentUserId] as FullUser) : undefined; + }, })), ); diff --git a/app/stores/webrtc-store.ts b/app/stores/webrtc-store.ts index 33ef6a7..974dd35 100644 --- a/app/stores/webrtc-store.ts +++ b/app/stores/webrtc-store.ts @@ -1,9 +1,9 @@ import { create } from "zustand"; +import { VOICE_GATEWAY_URL } from "~/lib/consts"; 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; diff --git a/eslint.config.js b/eslint.config.js index 892462f..d483b1a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,4 @@ -import js from "@eslint/js"; +import eslint from "@eslint/js"; import pluginReact from "eslint-plugin-react"; import reactCompiler from "eslint-plugin-react-compiler"; import reactHooks from "eslint-plugin-react-hooks"; @@ -7,19 +7,22 @@ import globals from "globals"; import tseslint from "typescript-eslint"; export default defineConfig([ - { - files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - plugins: { js }, - extends: ["js/recommended"], - }, + eslint.configs.recommended, { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], languageOptions: { globals: globals.browser }, }, - ...tseslint.configs.recommended, + tseslint.configs.recommended, pluginReact.configs.flat.recommended, reactHooks.configs["recommended-latest"], reactCompiler.configs.recommended, + { + settings: { + react: { + version: "detect", + }, + }, + }, { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], rules: { diff --git a/frontend.txt b/frontend.txt new file mode 100644 index 0000000..0629b21 --- /dev/null +++ b/frontend.txt @@ -0,0 +1,6737 @@ + +// file: app/components/app-layout.tsx +import React from "react"; +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"; + +interface AppLayoutProps { + children: React.ReactNode; +} + +export default function AppLayout({ children }: AppLayoutProps) { + const servers = Object.values(useServerListStore(useShallow((state) => state.servers))); + + const matches = useMatches(); + + const list = React.useMemo(() => { + return matches + .map( + (match) => + ( + match.handle as { + listComponent?: React.ReactNode; + } + )?.listComponent, + ) + .reverse() + .find((component) => !!component); + }, [matches]); + + return ( +
+
+
+ + + +
+
{list}
+ +
+ +
+
+ +
{children}
+
+ ); +} + +// file: app/components/channel-area.tsx +import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query"; +import type { Channel, Message, MessageId } from "~/lib/api/types"; +import ChatMessage from "./chat-message"; +import MessageBox from "./message-box"; +import { Separator } from "./ui/separator"; +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, fetchNextPage, hasNextPage, 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 = ( +
+ Loading... +
+ ); + } else { + messageArea = ( + <> +
+
+ {status === "success" && renderMessages(data.pages)} + +
+ + ); + } + + return ( + <> +
+
{channel?.name}
+
{messageArea}
+
+ +
+
+ + ); +} + +function renderMessages(pages: Message[][]) { + const messageElements: React.ReactNode[] = []; + let lastDate: string | null = null; + + 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()); + + const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + + if (messageDate.getTime() === today.getTime()) { + const rtf = new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", + }); + return capitalize(rtf.format(0, "day")); + } else if (messageDate.getTime() === yesterday.getTime()) { + const rtf = new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", + }); + return capitalize(rtf.format(-1, "day")); + } else { + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + } + }; + + pages.forEach((page) => { + page.forEach((message) => { + const messageDate = message.createdAt.toDateString(); + if (messageDate != lastDate) { + if (lastDate) + messageElements.push( +
+ + + {formatMessageDate(new Date(lastDate))} + + +
, + ); + lastDate = messageDate; + } + + messageElements.push( +
+ +
, + ); + }); + }); + + return messageElements; +} + +// file: app/components/chat-message-attachment.tsx +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, 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 ; + } + return ; +} + +function GenericFileAttachment({ file }: ChatMessageAttachmentProps) { + return ( +
+
+ +
+
+

{file.filename}

+

{formatFileSize(file.size)}

+
+
+ + + + + Download + + + + + + Open in new tab + +
+
+ ); +} + +function ImageAttachment({ file }: ChatMessageAttachmentProps) { + return ( + +
+ + + {file.filename} +
+ +
+
+
+
+

+ {file.filename} ({formatFileSize(file.size)}) +

+
+
+ + + + {file.filename} + +
+ {file.filename} +
+ + + + + + + +
+
+ ); +} + +// file: app/components/chat-message.tsx +import { Clock } from "lucide-react"; +import { useShallow } from "zustand/react/shallow"; +import { useFetchUser } from "~/hooks/use-fetch-user"; +import type { Message } from "~/lib/api/types"; +import { useUsersStore } from "~/stores/users-store"; +import ChatMessageAttachment from "./chat-message-attachment"; +import UserAvatar from "./user-avatar"; +import UserContextMenu from "./user-context-menu"; + +interface ChatMessageProps { + message: Message; +} + +export default function ChatMessage({ message }: ChatMessageProps) { + const { user } = useUsersStore( + useShallow((state) => ({ + user: state.users[message.authorId], + })), + ); + + useFetchUser(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 ( +
+
+ + + +
+
+ + +
{user?.displayName || user?.username}
+
+
+
+ + {formatMessageDate(message.createdAt)} +
+
+
+
{message.content}
+
+ {message.attachments.map((file) => ( +
+ +
+ ))} +
+
+
+ ); +} + +// file: app/components/custom-ui/channel-list-item.tsx +import { ChevronDown, Hash, Volume2 } from "lucide-react"; +import React from "react"; +import { NavLink } from "react-router"; +import { useShallow } from "zustand/react/shallow"; +import { useFetchUsers } from "~/hooks/use-fetch-user"; +import { deleteChannel } from "~/lib/api/client/server"; +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 { useVoiceStateStore } from "~/stores/voice-state-store"; +import { Button } from "../ui/button"; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "../ui/context-menu"; +import UserAvatar from "../user-avatar"; +import UserContextMenu from "../user-context-menu"; + +interface ChannelListItemProps { + channel: ServerChannel; +} + +const onDeleteChannel = async (channel: ServerChannel) => { + await deleteChannel(channel.serverId, channel.id); +}; + +interface ChannelItemWrapperProps { + channel: ServerChannel; + icon: React.ReactNode; + onButtonClick?: () => void; // For direct button clicks (e.g., join voice) + navTo?: string; // If provided, the button becomes a NavLink + isExplicitlyActive?: boolean; // To set active state externally (e.g., current voice channel) +} + +function ChannelItemWrapper({ channel, icon, onButtonClick, navTo, isExplicitlyActive }: ChannelItemWrapperProps) { + const buttonInnerContent = ( +
+
{icon}
+
{channel.name}
+
+ ); + + const renderButton = (isNavLinkActive?: boolean) => { + // Active state priority: explicit prop > NavLink state > default (false) + const isActive = isExplicitlyActive ?? isNavLinkActive ?? false; + return ( + + ); + }; + + let triggerContent: React.ReactNode; + if (navTo) { + triggerContent = ( + + {({ isActive: navLinkIsActive }) => renderButton(navLinkIsActive)} + + ); + } else { + // For non-NavLink cases (like voice channel), active state is determined by isExplicitlyActive + triggerContent = renderButton(); + } + + return ( + + {triggerContent} + + onDeleteChannel(channel)}> + Delete + + + + ); +} + +function ServerCategory({ channel }: ChannelListItemProps) { + return ( +
+
+
+ {channel.name} + +
+
+
+ ); +} + +function ServerVoice({ channel }: ChannelListItemProps) { + const updateVoiceState = useGatewayStore((state) => state.updateVoiceState); + const voiceStateChannel = useVoiceStateStore((state) => state.activeChannel); + const channelVoiceState = useChannelsVoiceStateStore((state) => state.channels[channel.id]) || {}; + const userIds = Object.keys(channelVoiceState.users ?? {}); + useFetchUsers(userIds); + + const users = useUsersStore(useShallow((state) => state.users)); + + const channelUsers = React.useMemo(() => userIds.map((userId) => users[userId]).filter(Boolean), [userIds, users]); + + const isCurrentVoiceChannel = + voiceStateChannel?.serverId === channel.serverId && voiceStateChannel.channelId === channel.id; + + const handleJoinVoiceChannel = () => { + if (isCurrentVoiceChannel) return; + updateVoiceState(channel.serverId, channel.id); + }; + + return ( + <> + } + onButtonClick={handleJoinVoiceChannel} + isExplicitlyActive={isCurrentVoiceChannel} + /> + {channelUsers.length > 0 && ( +
+ {" "} + {/* Added py-1 for spacing */} + {channelUsers.map((user) => ( + + +
+ {" "} + {/* Added padding and hover for better UX */} + + {user.displayName || user.username} +
+
+
+ ))} +
+ )} + + ); +} + +function ServerText({ channel }: ChannelListItemProps) { + return ( + } navTo={`/app/server/${channel.serverId}/${channel.id}`} /> + ); +} + +export default function ServerChannelListItem({ channel }: ChannelListItemProps) { + switch (channel.type) { + case ChannelType.SERVER_CATEGORY: + return ; + case ChannelType.SERVER_VOICE: + return ; + case ChannelType.SERVER_TEXT: + return ; + default: + return null; + } +} + +// file: app/components/custom-ui/create-server-button.tsx +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 ( + + ); +} + +// file: app/components/custom-ui/home-button.tsx +import { NavLink } from "react-router"; +import Discord from "../icons/Discord"; +import { Button } from "../ui/button"; + +export function HomeButton() { + return ( + + {({ isActive }) => ( + + )} + + ); +} + +// file: app/components/custom-ui/online-status.tsx +import { Circle, MinusCircle, Moon } from "lucide-react"; +import { cn } from "~/lib/utils"; // Assuming you have a cn utility + +// Define status type for better type safety if used elsewhere +export type UserStatusType = "online" | "dnd" | "idle" | "offline"; + +interface OnlineStatusProps extends React.ComponentProps<"div"> { + status: UserStatusType; +} + +export function OnlineStatus({ status, className, ...rest }: OnlineStatusProps) { + const statusTitle = status.charAt(0).toUpperCase() + status.slice(1); + + let statusIndicatorIcon: React.ReactNode = null; + + switch (status) { + case "online": + statusIndicatorIcon = ; + break; + case "dnd": + statusIndicatorIcon = ( + + ); + break; + case "idle": + statusIndicatorIcon = ; + break; + case "offline": + statusIndicatorIcon = ; + break; + } + + return ( +
+
+
+ {statusIndicatorIcon} +
+
+ ); +} + +// file: app/components/custom-ui/password-input.tsx +import { EyeIcon, EyeOffIcon } from "lucide-react"; +import React from "react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export function PasswordInput(props: React.ComponentProps<"input">) { + const [showPassword, setShowPassword] = React.useState(false); + const disabled = props.value === "" || props.value === undefined || props.disabled; + + return ( +
+ + + +
+ ); +} + +// file: app/components/custom-ui/private-channel-list-item.tsx +import { Check } from "lucide-react"; +import { NavLink } from "react-router"; +import { useShallow } from "zustand/react/shallow"; +import { useFetchUsers } from "~/hooks/use-fetch-user"; +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 !== currentUserId); + + useFetchUsers(recipients); + + const recipientsUsers = useUsersStore( + useShallow((state) => recipients.map((recipient) => state.users[recipient]).filter(Boolean)), + ); + + const renderSystemBadge = recipientsUsers.some((recipient) => recipient.system) && recipients.length === 1; + + return ( + <> + + {({ isActive }) => ( + + )} + + + ); +} + +// file: app/components/custom-ui/server-button.tsx +import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; +import { NavLink } from "react-router"; +import type { Server } from "~/lib/api/types"; +import { cn, getFirstLetters } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; + +export interface ServerButtonProps { + server: Server; +} + +export function ServerButton({ server }: ServerButtonProps) { + return ( + + {({ isActive }) => ( + + + + + +

{server.name}

+
+
+ )} +
+ ); +} + +// file: app/components/custom-ui/settings-button.tsx +import { Settings } from "lucide-react"; +import { useNavigate } from "react-router"; +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 navigate = useNavigate(); + + const onOpenSettings = () => { + navigate("/app/settings"); + }; + + const onLogout = () => { + setToken(undefined); + window.location.reload(); + }; + + return ( + + + + + + Settings + + + Logout + + + + ); +} + +// file: app/components/custom-ui/text-box.tsx +import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { cn } from "~/lib/utils"; + +export interface TextBoxProps extends Omit, "onChange" | "value"> { + value: string; + onChange: (value: string) => void; + placeholder?: string; + wrapperClassName?: string; + inputClassName?: string; + disabled?: boolean; + autoFocus?: boolean; + spellCheck?: boolean; +} + +export const TextBox = forwardRef( + ( + { + value, + onChange, + placeholder, + wrapperClassName, + inputClassName, + disabled = false, + autoFocus = false, + spellCheck = true, + onInput, + onBlur, + onFocus, + ...rest + }, + ref, + ) => { + const localRef = useRef(null); + useImperativeHandle(ref, () => localRef.current as HTMLDivElement); + + // Function to handle DOM updates + const updateDOM = (newValue: string) => { + if (localRef.current) { + // Only update if different to avoid selection issues + if (localRef.current.textContent !== newValue) { + localRef.current.textContent = newValue; + + // Clear any
elements if the content is empty + if (!newValue && localRef.current.innerHTML.includes("
")) { + localRef.current.innerHTML = ""; + } + } + } + }; + + // Update DOM when value prop changes + useEffect(() => { + updateDOM(value); + }, [value]); + + useEffect(() => { + if (autoFocus && localRef.current) { + localRef.current.focus(); + } + }, [autoFocus]); + + const handleInput = (event: React.FormEvent) => { + const newValue = event.currentTarget.textContent || ""; + + // Handle the case where the content is empty but contains a
+ if (!newValue && event.currentTarget.innerHTML.includes("
")) { + event.currentTarget.innerHTML = ""; + } + + onChange(newValue); + onInput?.(event); + }; + + const handlePaste = (event: React.ClipboardEvent) => { + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + + // Use document.execCommand to maintain undo stack + document.execCommand("insertText", false, text); + + // Manually trigger input event + const inputEvent = new Event("input", { bubbles: true }); + event.currentTarget.dispatchEvent(inputEvent); + }; + + return ( +
localRef.current?.focus()} + > +
+
+ ); + }, +); + +TextBox.displayName = "TextBox"; + +export default TextBox; + +// file: app/components/custom-ui/user-status.tsx +import { PhoneMissed, Signal } from "lucide-react"; +import { useShallow } from "zustand/react/shallow"; +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 leaveVoiceChannel = () => { + useVoiceStateStore.getState().leaveVoiceChannel(); + }; + + const channel = useServerChannelsStore( + useShallow((state) => state.channels[voiceState.serverId]?.[voiceState.channelId]), + ); + const server = useServerListStore(useShallow((state) => state.servers[voiceState.serverId])); + + return ( +
+
+ +
+ {channel?.name || "Unknown channel"} / {server?.name} +
+
+ + +
+ ); +} + +export default function UserStatus() { + const user = useUsersStore((state) => state.getCurrentUser()!); + const voiceState = useVoiceStateStore((state) => state.activeChannel); + + return ( +
+ {voiceState && ( + <> + + + + )} +
+
+ + + + +
+
{user?.displayName || user?.username || "Unknown user"}
+ @{user?.username} +
+
+
+
+ + + {/* */} + {/* */} +
+
+
+
+ ); +} + +// file: app/components/file-icon.tsx +import { + FileArchive, + FileAudio, + FileImage, + FileQuestion, + FileSpreadsheet, + FileText, + FileVideo, + 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 ; + } + if (contentType.startsWith("audio/")) { + return ; + } + if (contentType.startsWith("video/")) { + return ; + } + if (contentType === "application/pdf") { + return ; + } + if ( + contentType.startsWith("application/vnd.ms-excel") || + contentType.startsWith("application/vnd.openxmlformats-officedocument.spreadsheetml") + ) { + return ; // Could use a specific Excel icon if available/desired + } + if ( + contentType.startsWith("application/msword") || + contentType.startsWith("application/vnd.openxmlformats-officedocument.wordprocessingml") + ) { + return ; + } + if ( + contentType.startsWith("application/vnd.ms-powerpoint") || + contentType.startsWith("application/vnd.openxmlformats-officedocument.presentationml") + ) { + return ; + } + if ( + contentType === "application/zip" || + contentType === "application/x-rar-compressed" || + contentType === "application/x-7z-compressed" || + contentType === "application/gzip" || + contentType === "application/x-tar" + ) { + return ; + } + if (contentType.startsWith("text/")) { + return ; + } + return ; // Default for unknown types +} + +// file: app/components/icons/Discord.tsx +import type { SVGProps } from "react"; + +const Discord = (props: SVGProps) => ( + + + +); + +export default Discord; + +// file: app/components/manager/gateway-websocket-connection-manager.tsx +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { ConnectionState } from "~/lib/websocket/gateway/types"; +import { useGatewayStore } from "~/stores/gateway-store"; +import { useTokenStore } from "~/stores/token-store"; + +export function GatewayWebSocketConnectionManager() { + const token = useTokenStore((state) => state.token); + + const setQueryClient = useGatewayStore((state) => state.setQueryClient); + + const queryClient = useQueryClient(); + useEffect(() => { + setQueryClient(queryClient); + }, [queryClient]); + + useEffect(() => { + const { status, connect, disconnect } = useGatewayStore.getState(); + + if (token) { + connect(token); + } else { + if (status === ConnectionState.CONNECTED) { + disconnect(); + } + } + + return () => { + if (status === ConnectionState.CONNECTED) { + disconnect(); + } + }; + }, [token]); + + return null; +} + +// file: app/components/manager/webrtc-connection-manager.tsx +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() { + const gateway = useGatewayStore(); + const voiceState = useVoiceStateStore(); + const webrtc = useWebRTCStore(); + + const remoteStream = useWebRTCStore((state) => state.remoteStream); + const audioRef = useRef(null); + + if (audioRef.current) { + audioRef.current.srcObject = remoteStream; + } + + useEffect(() => { + 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); + }); + + return () => { + voiceState.leaveVoiceChannel(); + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (webrtc.status === ConnectionState.DISCONNECTED) { + voiceState.leaveVoiceChannel(); + } + }, [webrtc.status]); + + return ( + <> +