+
-
-
-
-
-
-
-
-
-
-
-
- {user.displayName || user.username}
-
-
@{user.username}
-
-
-
-
-
+
diff --git a/app/components/channel-area.tsx b/app/components/channel-area.tsx
new file mode 100644
index 0000000..b1437fc
--- /dev/null
+++ b/app/components/channel-area.tsx
@@ -0,0 +1,84 @@
+import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query"
+import type { Channel, MessageId } from "~/lib/api/types"
+import ChatMessage from "./chat-message"
+import MessageBox from "./message-box"
+import VisibleTrigger from "./visible-trigger"
+
+interface ChannelAreaProps {
+ channel: Channel
+}
+
+export default function ChannelArea(
+ { channel }: ChannelAreaProps
+) {
+ const channelId = channel.id
+
+ const fetchMessages = async ({ pageParam }: QueryFunctionContext) => {
+ return await import("~/lib/api/client/channel").then(m => m.default.paginatedMessages(channelId, 50, pageParam as MessageId | undefined))
+ }
+
+
+
+ const {
+ data,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetching,
+ isFetchingNextPage,
+ isPending,
+ status,
+ } = useInfiniteQuery({
+ queryKey: ["messages", channelId],
+ initialPageParam: undefined,
+ queryFn: fetchMessages,
+ getNextPageParam: (lastPage) => lastPage.length < 50 ? undefined : lastPage[lastPage.length - 1]?.id,
+ staleTime: Infinity,
+ })
+
+ const fetchNextPageVisible = () => {
+ if (!isFetchingNextPage && hasNextPage)
+ fetchNextPage()
+ }
+
+ let messageArea = null
+
+ if (isPending) {
+ messageArea =
+ Loading...
+
+ } else {
+ messageArea = <>
+
+
+ {
+ status === "success" && data.pages.map((page, i) => (
+ page.map((message) => (
+
+
+
+ ))
+ )
+ )
+ }
+
+
+ >
+ }
+
+ return (
+ <>
+
+
+ {channel?.name}
+
+
+ {messageArea}
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/app/components/channel-list-item.tsx b/app/components/channel-list-item.tsx
deleted file mode 100644
index a2f5f58..0000000
--- a/app/components/channel-list-item.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { ChevronDown, Hash, Volume2 } from "lucide-react"
-import { Button } from "./ui/button"
-
-interface Channel {
- id: string
- name: string
- type: "text" | "voice" | "category"
-}
-
-interface ChannelListItemProps {
- channel: Channel
-}
-
-export default function ChannelListItem({ channel }: ChannelListItemProps) {
- if (channel.type === "category") {
- return (
-
- )
- }
-
- return (
- <>
-
- >
- )
-}
diff --git a/app/components/chat-message-attachment.tsx b/app/components/chat-message-attachment.tsx
new file mode 100644
index 0000000..b9279ee
--- /dev/null
+++ b/app/components/chat-message-attachment.tsx
@@ -0,0 +1,136 @@
+import {
+ Download,
+ ExternalLink,
+ Maximize
+} from "lucide-react";
+import { AspectRatio } from "~/components/ui/aspect-ratio"; // Shadcn UI AspectRatio
+import { Button } from "~/components/ui/button"; // Shadcn UI Button
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog"; // Shadcn UI Dialog
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "~/components/ui/tooltip"; // Shadcn UI Tooltip
+import type { UploadedFile } from "~/lib/api/types"; // Adjust path as needed
+import { formatFileSize } from "~/lib/utils"; // Adjust path
+import { FileIcon } from "./file-icon";
+
+interface ChatMessageAttachmentProps {
+ file: UploadedFile;
+}
+
+export default function ChatMessageAttachment({ file }: ChatMessageAttachmentProps) {
+ if (file.contentType.startsWith("image/")) {
+ return
;
+ }
+ return
;
+}
+
+function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
+ return (
+
+
+
+
+
+
+
+ {file.filename}
+
+
+ {formatFileSize(file.size)}
+
+
+
+
+
+ );
+}
+
+function ImageAttachment({ file }: ChatMessageAttachmentProps) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/app/components/chat-message.tsx b/app/components/chat-message.tsx
new file mode 100644
index 0000000..7ba9a4e
--- /dev/null
+++ b/app/components/chat-message.tsx
@@ -0,0 +1,86 @@
+import { Clock } from "lucide-react"
+import React from "react"
+import { useShallow } from "zustand/react/shallow"
+import type { Message } from "~/lib/api/types"
+import { useUsersStore } from "~/stores/users-store"
+import ChatMessageAttachment from "./chat-message-attachment"
+import UserAvatar from "./user-avatar"
+
+interface ChatMessageProps {
+ message: Message
+}
+
+export default function ChatMessage(
+ { message }: ChatMessageProps
+) {
+ const { user, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({
+ user: state.users[message.authorId],
+ fetchUsersIfNotPresent: state.fetchUsersIfNotPresent
+ })))
+
+ React.useEffect(() => {
+ fetchUsersIfNotPresent([message.authorId])
+ }, [])
+
+ const formatMessageDate = (date: Date) => {
+ const now = new Date()
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+ const yesterday = new Date(today)
+ yesterday.setDate(yesterday.getDate() - 1)
+
+ const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
+
+ // Get localized time string
+ const timeString = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
+ const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
+
+ if (messageDate.getTime() === today.getTime()) {
+ // Use Intl.RelativeTimeFormat for localized "Today"
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+ return `${(capitalize(rtf.format(0, 'day')))}, ${timeString}`
+ } else if (messageDate.getTime() === yesterday.getTime()) {
+ // Use Intl.RelativeTimeFormat for localized "Yesterday"
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+ return `${capitalize(rtf.format(-1, 'day'))}, ${timeString}`
+ } else {
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+ })
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {user?.displayName || user?.username}
+
+
+
+
+ {formatMessageDate(message.createdAt)}
+
+
+
+
+
+ {message.content}
+
+
+ {
+ message.attachments.map((file, i) => (
+
+
))
+ }
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/custom-ui/channel-list-item.tsx b/app/components/custom-ui/channel-list-item.tsx
new file mode 100644
index 0000000..4836611
--- /dev/null
+++ b/app/components/custom-ui/channel-list-item.tsx
@@ -0,0 +1,114 @@
+import { ChevronDown, Hash, Volume2 } from "lucide-react"
+import React from "react"
+import { NavLink } from "react-router"
+import { useShallow } from "zustand/react/shallow"
+import { ChannelType, type ServerChannel } from "~/lib/api/types"
+import { cn } from "~/lib/utils"
+import { useChannelsVoiceStateStore } from "~/stores/channels-voice-state"
+import { useGatewayStore } from "~/stores/gateway-store"
+import { useUsersStore } from "~/stores/users-store"
+import { Button } from "../ui/button"
+import UserAvatar from "../user-avatar"
+
+interface ChannelListItemProps {
+ channel: ServerChannel
+}
+
+function ServerCategory({ channel }: ChannelListItemProps) {
+ return (
+
+ )
+}
+
+function ServerVoice({ channel }: ChannelListItemProps) {
+ const updateVoiceState = useGatewayStore(state => state.updateVoiceState)
+ const channelVoiceState = useChannelsVoiceStateStore(state => state.channels[channel.id]) || {}
+ const userIds = Object.keys(channelVoiceState.users ?? {})
+
+ const { users, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({
+ users: state.users,
+ fetchUsersIfNotPresent: state.fetchUsersIfNotPresent
+ })))
+
+ const channelUsers = React.useMemo(() => userIds.map(userId => users[userId]).filter(Boolean), [userIds, users])
+
+ React.useEffect(() => {
+ fetchUsersIfNotPresent(userIds)
+ }, [userIds])
+
+ const onClick = () => {
+ updateVoiceState(channel.serverId, channel.id)
+ }
+
+ return (
+ <>
+
+ {channelUsers.length > 0 &&
+
+ {
+ channelUsers
+ .map(user => (
+
+
+ {user.displayName || user.username}
+
+ ))
+ }
+
}
+ >
+ )
+}
+
+function ServerText({ channel }: ChannelListItemProps) {
+ return (
+
+ {({ isActive }) => (
+
+ )
+ }
+
+ )
+}
+
+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
+ }
+}
diff --git a/app/components/custom-ui/create-server-button.tsx b/app/components/custom-ui/create-server-button.tsx
new file mode 100644
index 0000000..7dba44c
--- /dev/null
+++ b/app/components/custom-ui/create-server-button.tsx
@@ -0,0 +1,15 @@
+import { CirclePlus } from "lucide-react";
+
+
+import { Button } from "~/components/ui/button";
+import { ModalType, useModalStore } from "~/stores/modal-store";
+
+export function CreateServerButton() {
+ const onOpen = useModalStore(state => state.onOpen)
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/custom-ui/home-button.tsx b/app/components/custom-ui/home-button.tsx
new file mode 100644
index 0000000..bec5cc0
--- /dev/null
+++ b/app/components/custom-ui/home-button.tsx
@@ -0,0 +1,21 @@
+import { NavLink } from "react-router";
+import Discord from "../icons/Discord";
+import { Button } from "../ui/button";
+
+export function HomeButton() {
+ return (
+
+ {
+ ({ isActive }) => (
+
+ )
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/online-status.tsx b/app/components/custom-ui/online-status.tsx
similarity index 88%
rename from app/components/online-status.tsx
rename to app/components/custom-ui/online-status.tsx
index 88c5cee..d2d9040 100644
--- a/app/components/online-status.tsx
+++ b/app/components/custom-ui/online-status.tsx
@@ -9,7 +9,7 @@ export function OnlineStatus({
return (
-
+
{status === "online" &&
}
{status === "dnd" &&
}
{status === "idle" &&
}
diff --git a/app/components/password-input.tsx b/app/components/custom-ui/password-input.tsx
similarity index 94%
rename from app/components/password-input.tsx
rename to app/components/custom-ui/password-input.tsx
index bfd5254..b4c6235 100644
--- a/app/components/password-input.tsx
+++ b/app/components/custom-ui/password-input.tsx
@@ -1,7 +1,7 @@
import { EyeIcon, EyeOffIcon } from "lucide-react"
import React from "react"
-import { Button } from "./ui/button"
-import { Input } from "./ui/input"
+import { Button } from "../ui/button"
+import { Input } from "../ui/input"
export function PasswordInput(props: React.ComponentProps<"input">) {
const [showPassword, setShowPassword] = React.useState(false)
diff --git a/app/components/custom-ui/private-channel-list-item.tsx b/app/components/custom-ui/private-channel-list-item.tsx
new file mode 100644
index 0000000..b4e60e3
--- /dev/null
+++ b/app/components/custom-ui/private-channel-list-item.tsx
@@ -0,0 +1,48 @@
+import { Check } from "lucide-react"
+import { NavLink } from "react-router"
+import type { RecipientChannel } from "~/lib/api/types"
+import { cn } from "~/lib/utils"
+import { useUsersStore } from "~/stores/users-store"
+import { Badge } from "../ui/badge"
+import { Button } from "../ui/button"
+import UserAvatar from "../user-avatar"
+import { OnlineStatus } from "./online-status"
+
+interface PrivateChannelListItemProps {
+ channel: RecipientChannel
+}
+
+export default function PrivateChannelListItem({ channel }: PrivateChannelListItemProps) {
+ const currentUserId = useUsersStore(state => state.currentUserId)
+ const recipients = channel.recipients.filter(recipient => recipient.id !== currentUserId);
+ const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
+
+ return (
+ <>
+
+ {
+ ({ isActive }) => (
+
+ )
+ }
+
+ >
+ )
+}
diff --git a/app/components/custom-ui/server-button.tsx b/app/components/custom-ui/server-button.tsx
new file mode 100644
index 0000000..e081d70
--- /dev/null
+++ b/app/components/custom-ui/server-button.tsx
@@ -0,0 +1,36 @@
+import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"
+import { NavLink } from "react-router"
+import type { Server } from "~/lib/api/types"
+import { getFirstLetters } from "~/lib/utils"
+import { Button } from "../ui/button"
+
+export interface ServerButtonProps {
+ server: Server
+}
+
+export function ServerButton(
+ { server }: ServerButtonProps
+) {
+ return (
+
+ {
+ ({ isActive }) => (
+
+ )
+ }
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/custom-ui/settings-button.tsx b/app/components/custom-ui/settings-button.tsx
new file mode 100644
index 0000000..5fdb142
--- /dev/null
+++ b/app/components/custom-ui/settings-button.tsx
@@ -0,0 +1,38 @@
+import { Settings } from "lucide-react";
+import { ModalType, useModalStore } from "~/stores/modal-store";
+import { useTokenStore } from "~/stores/token-store";
+import { Button } from "../ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
+
+export function SettingsButton() {
+ const setToken = useTokenStore(state => state.setToken)
+ const onOpen = useModalStore(state => state.onOpen)
+
+ const onUpdateProfile = () => {
+ onOpen(ModalType.UPDATE_PROFILE)
+ }
+
+ const onLogout = () => {
+ setToken(undefined)
+ window.location.reload()
+ }
+
+ return (
+
+
+
+
+
+
+ Update profile
+
+
+
+ Logout
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/text-box.tsx b/app/components/custom-ui/text-box.tsx
similarity index 94%
rename from app/components/text-box.tsx
rename to app/components/custom-ui/text-box.tsx
index c4758bb..c8115fa 100644
--- a/app/components/text-box.tsx
+++ b/app/components/custom-ui/text-box.tsx
@@ -86,8 +86,7 @@ export const TextBox = forwardRef
(
return (
localRef.current?.focus()}
diff --git a/app/components/custom-ui/user-status.tsx b/app/components/custom-ui/user-status.tsx
new file mode 100644
index 0000000..d3ad5ee
--- /dev/null
+++ b/app/components/custom-ui/user-status.tsx
@@ -0,0 +1,80 @@
+import { PhoneMissed, Signal } from "lucide-react";
+import { useServerChannelsStore } from "~/stores/server-channels-store";
+import { useServerListStore } from "~/stores/server-list-store";
+import { useUsersStore } from "~/stores/users-store";
+import { useVoiceStateStore } from "~/stores/voice-state-store";
+import { ThemeToggle } from "../theme/theme-toggle";
+import { Button } from "../ui/button";
+import { Separator } from "../ui/separator";
+import UserAvatar from "../user-avatar";
+import { OnlineStatus } from "./online-status";
+import { SettingsButton } from "./settings-button";
+
+function VoiceStatus({
+ voiceState
+}: { voiceState: { serverId: string; channelId: string } }) {
+ // const webrtcState = useWebRTCStore(state => state.status)
+
+ const leaveVoiceChannel = () => {
+ useVoiceStateStore.getState().leaveVoiceChannel()
+ }
+
+ const channel = useServerChannelsStore(state => state.channels[voiceState.serverId]?.[voiceState.channelId])
+ const server = useServerListStore(state => state.servers[voiceState.serverId])
+
+ return (
+
+
+
+
+ {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}
+
+
+
+
+
+
+ {/* */}
+ {/* */}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/file-icon.tsx b/app/components/file-icon.tsx
new file mode 100644
index 0000000..9b7d522
--- /dev/null
+++ b/app/components/file-icon.tsx
@@ -0,0 +1,62 @@
+import {
+ FileArchive,
+ FileAudio,
+ FileQuestion,
+ FileText,
+ FileVideo,
+ ImageIcon,
+ type LucideProps,
+} from "lucide-react";
+
+interface FileIconProps extends LucideProps {
+ contentType: string;
+ className?: string;
+}
+
+export function FileIcon({ contentType, className, ...props }: FileIconProps) {
+ const commonProps = { className: className ?? "h-5 w-5", ...props };
+
+ if (contentType.startsWith("image/")) {
+ return
;
+ }
+ 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
+}
\ No newline at end of file
diff --git a/app/components/global-webrtc-audio-player.tsx b/app/components/global-webrtc-audio-player.tsx
deleted file mode 100644
index 73ea373..0000000
--- a/app/components/global-webrtc-audio-player.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-// ~/components/global-audio-player.tsx
-import { useEffect, useRef } from 'react';
-import { useWebRTCStore } from '~/store/webrtc';
-
-export function GlobalWebRTCAudioPlayer() {
- const audioRef = useRef
(null);
- const remoteStream = useWebRTCStore(state => state.remoteStream);
- const webRTCStatus = useWebRTCStore(state => state.status);
-
- useEffect(() => {
- const audioElement = audioRef.current;
- if (audioElement) {
- if (remoteStream && webRTCStatus === 'CONNECTED') {
- console.log('GlobalAudioPlayer: Setting remote stream to audio element.');
- if (audioElement.srcObject !== remoteStream) { // Avoid unnecessary re-assignments
- audioElement.srcObject = remoteStream;
- audioElement.play().catch(error => {
- // Autoplay policy might prevent play without user interaction.
- // You might need a UI element for the user to click to start playback.
- console.warn('GlobalAudioPlayer: Error trying to play audio automatically:', error);
- // A common pattern is to mute the element initially and then allow unmuting by user action
- // audioElement.muted = true; // Then provide an unmute button
- });
- }
- } else {
- // If stream is null or not connected, clear the srcObject
- // This also handles the case when leaving a channel.
- if (audioElement.srcObject) {
- console.log('GlobalAudioPlayer: Clearing remote stream from audio element.');
- audioElement.srcObject = null;
- }
- }
- }
- }, [remoteStream, webRTCStatus]); // Re-run when remoteStream or connection status changes
-
- // This component renders the audio element.
- // It's generally good practice for