feat: chat

This commit is contained in:
2025-03-14 18:21:36 +01:00
parent f5ff1a486a
commit 1aaa85da19
7 changed files with 257 additions and 29 deletions

View File

@@ -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",

View File

@@ -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<string, string>);
}

View File

@@ -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<HTMLDivElement>(null);
// auto scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [chat.chatMessages]);
return (
<div className="border-l flex flex-col w-[350px] min-w-[350px] h-full">
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
<div className="space-y-4 flex-1">
{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)
<div key={i} className="flex space-x-2">
<div className="font-bold shrink-0">{name}</div>
<div
lang="en"
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
>
{msg.message}
</div>
</div>
);
})}
</div>
</div>
<div className="p-4 border-t">
<div className="flex space-x-2">
<Input
value={message}
onChange={(e) => 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"
/>
<Button
size="icon"
className="text-black transition-colors"
onClick={() => {
chat.send(message);
setMessage('');
}}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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<ChatMessage[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
// auto scroll to bottom when messages change
const socketRef = useRef<WebSocket | null>(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 (
<div className="border-l flex flex-col w-[350px] min-w-[350px] h-full">
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
<div className="space-y-4 flex-1">
{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)
<div key={i} className="flex space-x-2">
<div className="font-bold shrink-0">{name}</div>
<div
lang="en"
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
>
{msg.message}
</div>
{chatMessages.map((msg, i) => (
<div key={i} className="flex space-x-2">
<div className="flex items-center gap-2">
<div className="font-bold shrink-0">{msg.user.username}</div>
</div>
);
})}
<div
lang="en"
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
>
{msg.message}
</div>
</div>
))}
</div>
</div>
<div className="p-4 border-t">
@@ -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() {
<Button
size="icon"
className="text-black transition-colors"
onClick={() => {
chat.send(message);
setMessage('');
}}
onClick={sendMessage}
>
<Send className="h-4 w-4" />
</Button>
@@ -68,4 +126,4 @@ export default function ChatPanel() {
</div>
</div>
);
}
}

View File

@@ -13,7 +13,7 @@ export default function LiveStream(props: Props) {
<StreamPlayer />
<UserInfoCard streamInfo={props.streamInfo} />
</div>
{/*<ChatPanel />*/}
<ChatPanel />
</div>
);
}

View File

@@ -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;
}

View File

@@ -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"