This commit is contained in:
2025-05-21 16:56:54 +03:00
parent 4e5fca2402
commit 4419151510
15 changed files with 422 additions and 218 deletions

View File

@@ -43,7 +43,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
>
<div className="col-start-1 row-start-1 col-span-1 row-span-1 overflow-hidden border-r-2 min-w-fit">
<ScrollArea className="h-full px-1" scrollbarSize="none">
<aside className="flex flex-col gap-4 p-2 h-full">
<aside className="flex flex-col gap-2 p-2 h-full">
<HomeButton />
<Separator />
{servers.map((server, _) => (

View File

@@ -1,6 +1,6 @@
import { Clock } from "lucide-react";
import React from "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";
@@ -12,16 +12,13 @@ interface ChatMessageProps {
}
export default function ChatMessage({ message }: ChatMessageProps) {
const { user, fetchUsersIfNotPresent } = useUsersStore(
const { user } = useUsersStore(
useShallow((state) => ({
user: state.users[message.authorId],
fetchUsersIfNotPresent: state.fetchUsersIfNotPresent,
})),
);
React.useEffect(() => {
fetchUsersIfNotPresent([message.authorId]);
}, []);
useFetchUser(message.authorId);
const formatMessageDate = (date: Date) => {
const now = new Date();

View File

@@ -2,6 +2,7 @@ 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";
@@ -12,6 +13,7 @@ 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;
@@ -21,6 +23,64 @@ const onDeleteChannel = async (channel: ServerChannel) => {
const response = 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 = (
<div className="flex items-center gap-2 max-w-72">
<div>{icon}</div>
<div className="truncate">{channel.name}</div>
</div>
);
const renderButton = (isNavLinkActive?: boolean) => {
// Active state priority: explicit prop > NavLink state > default (false)
const isActive = isExplicitlyActive ?? isNavLinkActive ?? false;
return (
<Button
variant="secondary"
size="sm"
className={cn(
"justify-start w-full shadow-sm",
isActive ? "bg-accent hover:bg-accent" : "bg-secondary",
)}
onClick={!navTo ? onButtonClick : undefined}
>
{buttonInnerContent}
</Button>
);
};
let triggerContent: React.ReactNode;
if (navTo) {
triggerContent = (
<NavLink to={navTo} discover="none">
{({ isActive: navLinkIsActive }) => renderButton(navLinkIsActive)}
</NavLink>
);
} else {
// For non-NavLink cases (like voice channel), active state is determined by isExplicitlyActive
triggerContent = renderButton();
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{triggerContent}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem variant="destructive" onClick={() => onDeleteChannel(channel)}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function ServerCategory({ channel }: ChannelListItemProps) {
return (
<div className="text-xs flex flex-row justify-between mt-4">
@@ -39,52 +99,43 @@ function ServerVoice({ channel }: ChannelListItemProps) {
const voiceStateChannel = useVoiceStateStore((state) => state.activeChannel);
const channelVoiceState = useChannelsVoiceStateStore((state) => state.channels[channel.id]) || {};
const userIds = Object.keys(channelVoiceState.users ?? {});
useFetchUsers(userIds);
const { users, fetchUsersIfNotPresent } = useUsersStore(
useShallow((state) => ({
users: state.users,
fetchUsersIfNotPresent: state.fetchUsersIfNotPresent,
})),
);
const users = useUsersStore(useShallow((state) => state.users));
const channelUsers = React.useMemo(() => userIds.map((userId) => users[userId]).filter(Boolean), [userIds, users]);
React.useEffect(() => {
fetchUsersIfNotPresent(userIds);
}, [userIds]);
const onClick = () => {
if (voiceStateChannel?.serverId === channel.serverId && voiceStateChannel.channelId === channel.id) return;
const isCurrentVoiceChannel =
voiceStateChannel?.serverId === channel.serverId && voiceStateChannel.channelId === channel.id;
const handleJoinVoiceChannel = () => {
if (isCurrentVoiceChannel) return;
updateVoiceState(channel.serverId, channel.id);
};
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<Button variant="secondary" size="sm" className="justify-start" onClick={onClick}>
<div className="flex items-center gap-2 max-w-72">
<div>
<Volume2 />
</div>
<div className="truncate">{channel.name}</div>
</div>
</Button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem variant="destructive" onClick={() => onDeleteChannel(channel)}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<ChannelItemWrapper
channel={channel}
icon={<Volume2 />}
onButtonClick={handleJoinVoiceChannel}
isExplicitlyActive={isCurrentVoiceChannel}
/>
{channelUsers.length > 0 && (
<div className="ml-2 border-l-2 flex flex-col gap-1">
<div className="ml-2 border-l-2 flex flex-col gap-1 pl-4 py-1">
{" "}
{/* Added py-1 for spacing */}
{channelUsers.map((user) => (
<div key={user.id} className="flex items-center gap-2 max-w-72 pl-4">
<UserAvatar user={user} className="size-6" />
{user.displayName || user.username}
</div>
<React.Fragment key={user.id}>
<UserContextMenu userId={user.id}>
<div className="flex items-center gap-2 max-w-72 p-1 hover:bg-secondary/80 rounded">
{" "}
{/* Added padding and hover for better UX */}
<UserAvatar user={user} className="size-6" />
{user.displayName || user.username}
</div>
</UserContextMenu>
</React.Fragment>
))}
</div>
)}
@@ -94,34 +145,7 @@ function ServerVoice({ channel }: ChannelListItemProps) {
function ServerText({ channel }: ChannelListItemProps) {
return (
<NavLink to={`/app/server/${channel.serverId}/${channel.id}`} discover="none">
{({ isActive }) => (
<ContextMenu>
<ContextMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className={cn(
"justify-start w-full",
isActive ? "bg-accent hover:bg-accent" : "bg-secondary",
)}
>
<div className="flex items-center gap-2 max-w-72">
<div>
<Hash />
</div>
<div className="truncate">{channel.name}</div>
</div>
</Button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem variant="destructive" onClick={() => onDeleteChannel(channel)}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
</NavLink>
<ChannelItemWrapper channel={channel} icon={<Hash />} navTo={`/app/server/${channel.serverId}/${channel.id}`} />
);
}

View File

@@ -6,9 +6,9 @@ export function HomeButton() {
return (
<NavLink to={`/app/@me`}>
{({ isActive }) => (
<Button variant="outline" size="none" asChild className={isActive ? "bg-accent size-12" : "size-12"}>
<div>
<Discord className="size-full p-2" />
<Button variant="outline" size="none" className={isActive ? "bg-accent" : ""}>
<div className="size-12 p-2 flex items-center justify-center">
<Discord className="size-full" />
</div>
</Button>
)}

View File

@@ -1,19 +1,47 @@
import { Circle, CircleMinus, Moon } from "lucide-react";
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 = <Circle className="size-full fill-emerald-500 stroke-none" />;
break;
case "dnd":
statusIndicatorIcon = (
<MinusCircle className="size-full fill-red-500 stroke-background" strokeWidth={2.5} />
);
break;
case "idle":
statusIndicatorIcon = <Moon className="size-full fill-amber-400 stroke-amber-400" />;
break;
case "offline":
statusIndicatorIcon = <Circle className="size-full fill-transparent stroke-gray-500 stroke-[4px]" />;
break;
}
export function OnlineStatus({
status,
...props
}: React.ComponentProps<"div"> & {
status: "online" | "dnd" | "idle" | "offline";
}) {
return (
<div className="relative">
<div {...props}></div>
<div className="absolute bottom-0 right-0 bg-background rounded-full p-0.5 size-1/2">
{status === "online" && <Circle className="size-full stroke-emerald-400 fill-emerald-400" />}
{status === "dnd" && <CircleMinus className="size-full stroke-red-400 stroke-3" />}
{status === "idle" && <Moon className="size-full stroke-amber-400 fill-amber-400" />}
{status === "offline" && <Circle className="size-full stroke-gray-400 stroke-3" />}
<div className="relative inline-block align-middle">
<div className={className} {...rest} />
<div
title={statusTitle}
className={cn(
"absolute bottom-0 right-0 flex items-center justify-center",
"rounded-full bg-background",
"size-1/2 p-0.5",
)}
>
{statusIndicatorIcon}
</div>
</div>
);

View File

@@ -1,5 +1,7 @@
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";
@@ -14,8 +16,16 @@ interface PrivateChannelListItemProps {
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;
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 (
<>
@@ -26,20 +36,22 @@ export default function PrivateChannelListItem({ channel }: PrivateChannelListIt
size="none"
asChild
className={cn(
"w-full flex flex-row justify-start shadow-sm",
isActive ? "bg-accent hover:bg-accent" : "",
"w-full flex flex-row justify-start",
)}
>
<div className="flex items-center gap-2 max-w-72 p-2">
<div>
<OnlineStatus status="online">
<UserAvatar
user={channel.recipients.find((recipient) => recipient.id !== currentUserId)}
user={recipientsUsers.find((recipient) => recipient.id !== currentUserId)}
/>
</OnlineStatus>
</div>
<div className="truncate">
{recipients.map((recipient) => recipient.displayName || recipient.username).join(", ")}
{recipientsUsers
.map((recipient) => recipient.displayName || recipient.username)
.join(", ")}
</div>
{renderSystemBadge && (
<Badge variant="default">

View File

@@ -1,7 +1,7 @@
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 { cn, getFirstLetters } from "~/lib/utils";
import { Button } from "../ui/button";
export interface ServerButtonProps {
@@ -12,13 +12,11 @@ export function ServerButton({ server }: ServerButtonProps) {
return (
<NavLink to={`/app/server/${server.id}`}>
{({ isActive }) => (
<Button variant="outline" size="none" asChild className={isActive ? "bg-accent" : ""}>
<div>
<Avatar className="size-12 rounded-none flex items-center justify-center">
<Button variant="outline" size="none" className={cn("overflow-hidden", isActive ? "bg-accent" : "")}>
<div className="flex items-center justify-center size-12">
<Avatar className="rounded-none">
<AvatarImage src={server.iconUrl} className="rounded-none" />
<AvatarFallback>
<div>{getFirstLetters(server.name, 4)}</div>
</AvatarFallback>
<AvatarFallback>{getFirstLetters(server.name, 4)}</AvatarFallback>
</Avatar>
</div>
</Button>

View File

@@ -32,5 +32,5 @@ export function GatewayWebSocketConnectionManager() {
};
}, [token]);
return <>{null}</>;
return null;
}

View File

@@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {

View File

@@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { getUser } from "~/lib/api/client/user";
import type { UserId } from "~/lib/api/types";
import { useUsersStore } from "~/stores/users-store";
export const useFetchUser = (userId: UserId) => {
const query = useQuery({
queryKey: ["users", userId],
queryFn: async () => {
if (useUsersStore.getState().users[userId]) {
return null;
}
const response = await getUser(userId);
useUsersStore.getState().addUser(response);
return null;
},
});
return query;
};
export const useFetchUsers = (userIds: UserId[]) => {
const query = useQuery({
queryKey: ["users", userIds],
queryFn: async () => {
const userIdsToFetch = userIds.filter((userId) => !useUsersStore.getState().users[userId]);
if (userIdsToFetch.length === 0) {
return null;
}
const response = await Promise.all(userIds.map(getUser));
for (const user of response) {
useUsersStore.getState().addUser(user);
}
return null;
},
});
return query;
};

View File

@@ -125,7 +125,7 @@ export interface RecipientChannel {
name: string;
type: ChannelType;
lastMessageId?: MessageId;
recipients: PartialUser[];
recipients: UserId[];
}
export interface PartialUser {

View File

@@ -1,8 +1,17 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Outlet } from "react-router";
import { Outlet, redirect } from "react-router";
import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-websocket-connection-manager";
import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager";
import ModalProvider from "~/components/providers/modal-provider";
import { useTokenStore } from "~/stores/token-store";
export async function clientLoader() {
const token = useTokenStore.getState().token;
if (!token) {
return redirect("/login");
}
}
const queryClient = new QueryClient({
defaultOptions: {