chat fix
This commit is contained in:
parent
8069cb2925
commit
92b9760d0a
@ -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));
|
||||||
|
|||||||
173
frontend/package-lock.json
generated
173
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
193
frontend/src/components/MessageCard.tsx
Normal file
193
frontend/src/components/MessageCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
|||||||
46
frontend/src/components/ui/popover.tsx
Normal file
46
frontend/src/components/ui/popover.tsx
Normal 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 }
|
||||||
@ -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 }) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user