diff --git a/backend/src/socket/socketHandlers.ts b/backend/src/socket/socketHandlers.ts index 170399b..b10ae52 100644 --- a/backend/src/socket/socketHandlers.ts +++ b/backend/src/socket/socketHandlers.ts @@ -10,6 +10,13 @@ export const handleConnection = async (io: Server, socket: AuthenticatedSocket) 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); socket.on("send_message", (data: SendMessageRequest) => handleSendMessage(io, socket, data)); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f5a8a17..0334e3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@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-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", @@ -623,6 +624,44 @@ "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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -799,6 +838,29 @@ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "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": { "version": "1.1.7", "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": { "version": "1.1.9", "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": { "version": "1.0.0-beta.11", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8234c2c..4b4b634 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@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-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", diff --git a/frontend/src/components/MessageCard.tsx b/frontend/src/components/MessageCard.tsx new file mode 100644 index 0000000..081819a --- /dev/null +++ b/frontend/src/components/MessageCard.tsx @@ -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(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 + ); + + return ( +
+ {!isCurrentUser && ( +
+ {message.user.username} +
+ )} + +
+ + +
+
+ {message.content} +
+
+
+ + + + + +
+
+ +
+ {formatTime(message.createdAt)} +
+ + {Object.keys(reactionGroups).length > 0 && ( +
+ {Object.entries(reactionGroups).map(([type, count]) => ( +
handleReaction(message.id, type)} + > + {type === 'like' && ( + + )} + {type === 'thumbs_up' && ( + + )} + {type === 'laugh' && ( + + )} + {count} +
+ ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index b505922..3a97f7a 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -1,13 +1,9 @@ -import { useSocket } from '@/hooks/useSocket'; import { chatAPI } from '@/lib/api'; -import { cn } from '@/lib/utils'; -import { useAuthStore } from '@/stores/authStore'; import { useChatStore } from '@/stores/chatStore'; -import type { Message, MessageReaction } from '@/types'; +import type { Message } from '@/types'; import { useQuery } from '@tanstack/react-query'; -import { Heart, Smile, ThumbsUp } from 'lucide-react'; import { useEffect, useRef, type FC } from 'react'; -import { Button } from './ui/button'; +import { MessageCard } from './MessageCard'; import { ScrollArea } from './ui/scroll-area'; type MessageListProps = { @@ -15,9 +11,7 @@ type MessageListProps = { }; export const MessageList: FC = ({ roomId }) => { - const { user } = useAuthStore(); const { messages, setMessages } = useChatStore(); - const { socket } = useSocket(); const messagesEndRef = useRef(null); const roomMessages = messages[roomId] ?? []; @@ -39,34 +33,10 @@ export const MessageList: FC = ({ roomId }) => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [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) { return (
-
Loading messages...
+
); } @@ -75,109 +45,7 @@ export const MessageList: FC = ({ roomId }) => {
{roomMessages.map((message: Message) => ( -
-
- {message.userId !== user?.id && ( -
- {message.user.username} -
- )} - -
{message.content}
- -
-
- {formatTime(message.createdAt)} -
- -
- - - - - -
-
-
-
+ ))}
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..6d51b6c --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -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) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts index 91c9953..219c35c 100644 --- a/frontend/src/lib/socket.ts +++ b/frontend/src/lib/socket.ts @@ -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( 'user_offline', (data: { userId: string; username: string }) => {