From 1aaa85da19bccbac2e4e13b01fad00f77c27154e Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Fri, 14 Mar 2025 18:21:36 +0100 Subject: [PATCH] feat: chat --- package.json | 1 + src/app/(protected)/api/stream/chat/route.ts | 64 ++++++++++ .../app/ChatPanel/ChatPanel.livekit.tsx | 71 +++++++++++ src/components/app/ChatPanel/ChatPanel.tsx | 112 +++++++++++++----- src/components/app/Livestream/Livestream.tsx | 2 +- src/lib/db/resolve.ts | 17 ++- yarn.lock | 19 +++ 7 files changed, 257 insertions(+), 29 deletions(-) create mode 100644 src/app/(protected)/api/stream/chat/route.ts create mode 100644 src/components/app/ChatPanel/ChatPanel.livekit.tsx diff --git a/package.json b/package.json index d8c5155..ea49e47 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/ws": "^8.18.0", "eslint": "^8", "eslint-config-next": "15.1.3", "postcss": "^8", diff --git a/src/app/(protected)/api/stream/chat/route.ts b/src/app/(protected)/api/stream/chat/route.ts new file mode 100644 index 0000000..d9558a9 --- /dev/null +++ b/src/app/(protected)/api/stream/chat/route.ts @@ -0,0 +1,64 @@ +import { lucia } from '@/lib/auth'; +import { resolveUserPersonalChannel } from '@/lib/db/resolve'; + +export async function SOCKET( + client: import('ws').WebSocket, + request: import('http').IncomingMessage, + server: import('ws').WebSocketServer +) { + console.log('A client connected'); + const cookies = parseCookieString(request.headers.cookie!); + const { user } = await lucia.validateSession(cookies.auth_session); + if (!user) { + client.close(); + return; + } + + const personalChannel = await resolveUserPersonalChannel(user.id); + if (!personalChannel) { + client.close(); + return; + } + + client.on('message', (message) => { + const msg = message.toString(); + server.clients.forEach((client) => { + if (client.readyState === client.OPEN) { + client.send( + JSON.stringify({ + user: { + id: user.id, + username: personalChannel.name, + pfpUrl: user.pfpUrl, + }, + message: msg, + }) + ); + /* if (msg === 'BOMB') { + for (let i = 0; i < 10000; i++) { + client.send(JSON.stringify({ + user: { + id: user.id, + username: personalChannel.name, + pfpUrl: user.pfpUrl, + }, + message: 'HIIIII', + })); + } + } */ + } + }); + }); + + client.on('close', () => { + console.log('A client disconnected'); + }); +} + +function parseCookieString(cookie: string) { + return cookie.split(';').reduce((acc, cookie) => { + const [key, value] = cookie.split('='); + acc[key.trim()] = value; + return acc; + }, {} as Record); +} diff --git a/src/components/app/ChatPanel/ChatPanel.livekit.tsx b/src/components/app/ChatPanel/ChatPanel.livekit.tsx new file mode 100644 index 0000000..c89463b --- /dev/null +++ b/src/components/app/ChatPanel/ChatPanel.livekit.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { Send } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useChat } from '@livekit/components-react'; + +export default function ChatPanel() { + const [message, setMessage] = useState(''); + const chat = useChat(); + const scrollRef = useRef(null); + + // auto scroll to bottom when messages change + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [chat.chatMessages]); + + return ( +
+
+
+ {chat.chatMessages.map((msg, i) => { + const splitName = msg.from?.name?.split('-'); + const name = splitName?.slice(0, -1).join('-'); + return ( + // jank asf, but works (thanks claude) +
+
{name}
+
+ {msg.message} +
+
+ ); + })} +
+
+
+
+ setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + chat.send(message); + setMessage(''); + } + }} + placeholder="Type a message" + className="flex-1 bg-transparent focus-visible:ring-offset-0" + /> + +
+
+
+ ); +} diff --git a/src/components/app/ChatPanel/ChatPanel.tsx b/src/components/app/ChatPanel/ChatPanel.tsx index c89463b..4f654e7 100644 --- a/src/components/app/ChatPanel/ChatPanel.tsx +++ b/src/components/app/ChatPanel/ChatPanel.tsx @@ -4,40 +4,102 @@ import { useState, useRef, useEffect } from 'react'; import { Send } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { useChat } from '@livekit/components-react'; + +interface User { + id: string; + username: string; + pfpUrl: string; +} + +interface ChatMessage { + user: User; + message: string; +} export default function ChatPanel() { const [message, setMessage] = useState(''); - const chat = useChat(); + const [chatMessages, setChatMessages] = useState([]); const scrollRef = useRef(null); - - // auto scroll to bottom when messages change + const socketRef = useRef(null); + + // Setup WebSocket connection + useEffect(() => { + const socket = new WebSocket('ws://localhost:3000/api/stream/chat'); + socketRef.current = socket; + + socket.onopen = () => { + console.log('WebSocket connected'); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + setChatMessages(prev => [...prev, data]); + } catch (e) { + // Handle plaintext responses (when sending messages) + console.log('Received message confirmation:', event.data); + } + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + socket.onclose = () => { + console.log('WebSocket closed'); + }; + + // Cleanup WebSocket on unmount + return () => { + socket.close(); + }; + }, []); + + // Auto scroll to bottom when messages change useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [chat.chatMessages]); + if (chatMessages.length > 100) { + setChatMessages(prev => prev.slice(chatMessages.length - 100)); + } + }, [chatMessages]); + + // Function to send a message + const sendMessage = () => { + if (!message.trim()) return; + + // Use existing socket connection if available, otherwise create a new one + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send(message); + setMessage(''); + } else { + // Fallback to creating a new connection + const socket = new WebSocket('ws://localhost:3000/api/stream/chat'); + socket.onopen = () => { + socket.send(message); + setMessage(''); + }; + } + }; return (
- {chat.chatMessages.map((msg, i) => { - const splitName = msg.from?.name?.split('-'); - const name = splitName?.slice(0, -1).join('-'); - return ( - // jank asf, but works (thanks claude) -
-
{name}
-
- {msg.message} -
+ {chatMessages.map((msg, i) => ( +
+
+
{msg.user.username}
- ); - })} +
+ {msg.message} +
+
+ ))}
@@ -47,8 +109,7 @@ export default function ChatPanel() { onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { - chat.send(message); - setMessage(''); + sendMessage(); } }} placeholder="Type a message" @@ -57,10 +118,7 @@ export default function ChatPanel() { @@ -68,4 +126,4 @@ export default function ChatPanel() {
); -} +} \ No newline at end of file diff --git a/src/components/app/Livestream/Livestream.tsx b/src/components/app/Livestream/Livestream.tsx index 0ce0f1a..5ce6c62 100644 --- a/src/components/app/Livestream/Livestream.tsx +++ b/src/components/app/Livestream/Livestream.tsx @@ -13,7 +13,7 @@ export default function LiveStream(props: Props) {
- {/**/} + ); } diff --git a/src/lib/db/resolve.ts b/src/lib/db/resolve.ts index 87b6add..7fd41ad 100644 --- a/src/lib/db/resolve.ts +++ b/src/lib/db/resolve.ts @@ -1,4 +1,3 @@ -'use server' import db from '@/lib/db'; export async function resolveChannelNameId(channelName: string) { @@ -13,4 +12,20 @@ export async function resolveChannelNameId(channelName: string) { } return channel.id; +} + +export async function resolveUserPersonalChannel(userId: string) { + const channel = await db.channel.findFirst({ + where: { + personalFor: { + id: userId, + }, + }, + }); + + if (!channel) { + return null; + } + + return channel; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ef80952..3253f00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1349,6 +1349,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/node@*": + version "22.13.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4" + integrity sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw== + dependencies: + undici-types "~6.20.0" + "@types/node@^20": version "20.17.12" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.12.tgz#ee3b7d25a522fd95608c1b3f02921c97b93fcbd6" @@ -1374,6 +1381,13 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/ws@^8.18.0": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.0.tgz#8a2ec491d6f0685ceaab9a9b7ff44146236993b5" + integrity sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0": version "8.19.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz#5f26c0a833b27bcb1aa402b82e76d3b8dda0b247" @@ -4777,6 +4791,11 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"