mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat: chat
This commit is contained in:
@@ -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",
|
||||
|
||||
64
src/app/(protected)/api/stream/chat/route.ts
Normal file
64
src/app/(protected)/api/stream/chat/route.ts
Normal 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>);
|
||||
}
|
||||
71
src/components/app/ChatPanel/ChatPanel.livekit.tsx
Normal file
71
src/components/app/ChatPanel/ChatPanel.livekit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default function LiveStream(props: Props) {
|
||||
<StreamPlayer />
|
||||
<UserInfoCard streamInfo={props.streamInfo} />
|
||||
</div>
|
||||
{/*<ChatPanel />*/}
|
||||
<ChatPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
19
yarn.lock
19
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"
|
||||
|
||||
Reference in New Issue
Block a user