This commit is contained in:
Gal Podlipnik 2025-06-12 13:17:06 +02:00
parent 8069cb2925
commit 92b9760d0a
7 changed files with 432 additions and 136 deletions

View File

@ -10,6 +10,13 @@ export const handleConnection = async (io: Server, socket: AuthenticatedSocket)
await updateUserOnlineStatus(socket.userId, true); await updateUserOnlineStatus(socket.userId, true);
const onlineUsers = await prisma.user.findMany({
where: { isOnline: true },
select: { id: true, username: true },
});
socket.emit("online_users", onlineUsers);
await joinUserRooms(socket); await joinUserRooms(socket);
socket.on("send_message", (data: SendMessageRequest) => handleSendMessage(io, socket, data)); socket.on("send_message", (data: SendMessageRequest) => handleSendMessage(io, socket, data));

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
@ -623,6 +624,44 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz",
"integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -799,6 +838,29 @@
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@ -1014,6 +1076,75 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
"integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": { "node_modules/@radix-ui/react-portal": {
"version": "1.1.9", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@ -1280,6 +1411,48 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.11", "version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",

View File

@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",

View File

@ -0,0 +1,193 @@
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/authStore';
import { useSocketStore } from '@/stores/socketStore';
import type { Message, MessageReaction } from '@/types';
import { Heart, Smile, ThumbsUp } from 'lucide-react';
import { useRef, useState, type FC } from 'react';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
export const MessageCard: FC<{ message: Message }> = ({ message }) => {
const { user } = useAuthStore();
const { socket } = useSocketStore();
const [showReactions, setShowReactions] = useState(false);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isCurrentUser = message.userId === user?.id;
const handleReaction = (messageId: string, type: string) => {
if (socket) {
socket.emit('react_to_message', {
messageId,
type,
});
}
};
const hasUserReacted = (reactions: MessageReaction[], type: string) => {
return reactions.some((r) => r.type === type && r.userId === user?.id);
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
const handleMouseDown = () => {
longPressTimeoutRef.current = setTimeout(() => {
setShowReactions(true);
}, 500);
};
const handleMouseUp = () => {
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
}
};
const reactionGroups = message.reactions.reduce(
(acc, reaction) => {
if (!acc[reaction.type]) {
acc[reaction.type] = 0;
}
acc[reaction.type]++;
return acc;
},
{} as Record<string, number>
);
return (
<div
className={cn(
'flex flex-col gap-1 mb-3',
isCurrentUser ? 'items-end' : 'items-start'
)}
>
{!isCurrentUser && (
<div className="text-xs font-medium ml-4 text-gray-600">
{message.user.username}
</div>
)}
<div className="flex relative group">
<Popover open={showReactions} onOpenChange={setShowReactions}>
<PopoverTrigger asChild>
<div
className={cn(
'px-4 py-2 rounded-2xl relative cursor-pointer',
isCurrentUser
? 'bg-blue-500 text-white rounded-tr-none'
: 'bg-gray-100 text-gray-900 rounded-tl-none'
)}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchEnd={handleMouseUp}
>
<div className="break-words max-w-xs lg:max-w-md">
{message.content}
</div>
</div>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2 flex gap-2"
align={isCurrentUser ? 'end' : 'start'}
side="top"
sideOffset={5}
>
<Button
size="sm"
variant="ghost"
className="rounded-full p-2"
onClick={() => {
handleReaction(message.id, 'like');
setShowReactions(false);
}}
>
<Heart className="h-5 w-5 text-red-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-full p-2"
onClick={() => {
handleReaction(message.id, 'thumbs_up');
setShowReactions(false);
}}
>
<ThumbsUp className="h-5 w-5 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-full p-2"
onClick={() => {
handleReaction(message.id, 'laugh');
setShowReactions(false);
}}
>
<Smile className="h-5 w-5 text-yellow-500" />
</Button>
</PopoverContent>
</Popover>
</div>
<div className="text-xs text-gray-500 px-2">
{formatTime(message.createdAt)}
</div>
{Object.keys(reactionGroups).length > 0 && (
<div className="flex gap-1 mt-1">
{Object.entries(reactionGroups).map(([type, count]) => (
<div
key={type}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded-full',
hasUserReacted(message.reactions, type)
? type === 'like'
? 'bg-red-100'
: type === 'thumbs_up'
? 'bg-blue-100'
: 'bg-yellow-100'
: 'bg-gray-100'
)}
onClick={() => handleReaction(message.id, type)}
>
{type === 'like' && (
<Heart
className={cn(
'h-3 w-3',
hasUserReacted(message.reactions, type) &&
'fill-red-500 text-red-500'
)}
/>
)}
{type === 'thumbs_up' && (
<ThumbsUp
className={cn(
'h-3 w-3',
hasUserReacted(message.reactions, type) &&
'fill-blue-500 text-blue-500'
)}
/>
)}
{type === 'laugh' && (
<Smile
className={cn(
'h-3 w-3',
hasUserReacted(message.reactions, type) &&
'fill-yellow-500 text-yellow-500'
)}
/>
)}
<span>{count}</span>
</div>
))}
</div>
)}
</div>
);
};

View File

@ -1,13 +1,9 @@
import { useSocket } from '@/hooks/useSocket';
import { chatAPI } from '@/lib/api'; import { chatAPI } from '@/lib/api';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/authStore';
import { useChatStore } from '@/stores/chatStore'; import { useChatStore } from '@/stores/chatStore';
import type { Message, MessageReaction } from '@/types'; import type { Message } from '@/types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Heart, Smile, ThumbsUp } from 'lucide-react';
import { useEffect, useRef, type FC } from 'react'; import { useEffect, useRef, type FC } from 'react';
import { Button } from './ui/button'; import { MessageCard } from './MessageCard';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
type MessageListProps = { type MessageListProps = {
@ -15,9 +11,7 @@ type MessageListProps = {
}; };
export const MessageList: FC<MessageListProps> = ({ roomId }) => { export const MessageList: FC<MessageListProps> = ({ roomId }) => {
const { user } = useAuthStore();
const { messages, setMessages } = useChatStore(); const { messages, setMessages } = useChatStore();
const { socket } = useSocket();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const roomMessages = messages[roomId] ?? []; const roomMessages = messages[roomId] ?? [];
@ -39,34 +33,10 @@ export const MessageList: FC<MessageListProps> = ({ roomId }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [roomMessages]); }, [roomMessages]);
const handleReaction = (messageId: string, type: string) => {
if (socket) {
socket.emit('react_to_message', {
messageId,
type,
});
}
};
const getReactionCount = (reactions: MessageReaction[], type: string) => {
return reactions.filter((r) => r.type === type).length;
};
const hasUserReacted = (reactions: MessageReaction[], type: string) => {
return reactions.some((r) => r.type === type && r.userId === user?.id);
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="animate-pulse text-gray-500">Loading messages...</div> <div className="h-8 w-8 rounded-full border-4 border-gray-300 border-t-gray-500 animate-spin"></div>
</div> </div>
); );
} }
@ -75,109 +45,7 @@ export const MessageList: FC<MessageListProps> = ({ roomId }) => {
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 p-4">
<div className="space-y-4"> <div className="space-y-4">
{roomMessages.map((message: Message) => ( {roomMessages.map((message: Message) => (
<div <MessageCard key={message.id} message={message} />
key={message.id}
className={cn(
'flex',
message.userId === user?.id ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-xs lg:max-w-md px-4 py-2 rounded-lg',
message.userId === user?.id
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-900'
)}
>
{message.userId !== user?.id && (
<div className="text-xs font-medium mb-1 opacity-70">
{message.user.username}
</div>
)}
<div className="break-words">{message.content}</div>
<div className="flex items-center justify-between mt-2">
<div className="text-xs opacity-70">
{formatTime(message.createdAt)}
</div>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
className={cn(
'h-6 px-2 text-xs',
hasUserReacted(message.reactions, 'like') && 'bg-red-100'
)}
onClick={() => handleReaction(message.id, 'like')}
>
<Heart
className={cn(
'h-3 w-3',
hasUserReacted(message.reactions, 'like') &&
'fill-red-500 text-red-500'
)}
/>
{getReactionCount(message.reactions, 'like') > 0 && (
<span className="ml-1">
{getReactionCount(message.reactions, 'like')}
</span>
)}
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
'h-6 px-2 text-xs',
hasUserReacted(message.reactions, 'thumbs_up') &&
'bg-blue-100'
)}
onClick={() => handleReaction(message.id, 'thumbs_up')}
>
<ThumbsUp
className={cn(
'h-3 w-3',
hasUserReacted(message.reactions, 'thumbs_up') &&
'fill-blue-500 text-blue-500'
)}
/>
{getReactionCount(message.reactions, 'thumbs_up') > 0 && (
<span className="ml-1">
{getReactionCount(message.reactions, 'thumbs_up')}
</span>
)}
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
'h-6 px-2 text-xs',
hasUserReacted(message.reactions, 'laugh') &&
'bg-yellow-100'
)}
onClick={() => handleReaction(message.id, 'laugh')}
>
<Smile
className={cn(
'h-3 w-3',
hasUserReacted(message.reactions, 'laugh') &&
'fill-yellow-500 text-yellow-500'
)}
/>
{getReactionCount(message.reactions, 'laugh') > 0 && (
<span className="ml-1">
{getReactionCount(message.reactions, 'laugh')}
</span>
)}
</Button>
</div>
</div>
</div>
</div>
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -95,6 +95,14 @@ class SocketService {
} }
); );
this.socket.on(
'online_users',
(users: { id: string; username: string[] }[]) => {
const onlineUserIds = new Set(users.map((user) => user.id));
useChatStore.getState().setOnlineUsers(onlineUserIds);
}
);
this.socket.on( this.socket.on(
'user_offline', 'user_offline',
(data: { userId: string; username: string }) => { (data: { userId: string; username: string }) => {