.
This commit is contained in:
@@ -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, _) => (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,5 +32,5 @@ export function GatewayWebSocketConnectionManager() {
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return <>{null}</>;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
46
app/hooks/use-fetch-user.ts
Normal file
46
app/hooks/use-fetch-user.ts
Normal 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;
|
||||
};
|
||||
@@ -125,7 +125,7 @@ export interface RecipientChannel {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
lastMessageId?: MessageId;
|
||||
recipients: PartialUser[];
|
||||
recipients: UserId[];
|
||||
}
|
||||
|
||||
export interface PartialUser {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user