Adds image sharing and message refactor to WRoom

This commit is contained in:
oiov
2025-04-09 20:26:18 +08:00
parent 9ec8203e34
commit aad6383ada
6 changed files with 545 additions and 249 deletions

View File

@@ -2,8 +2,8 @@ import { constructMetadata } from "@/lib/utils";
import ChatRoom from "@/components/chat/chat-room";
export const metadata = constructMetadata({
title: "ChatRoom",
description: "",
title: "WRoom",
description: "A temporary, peer-to-peer, and secure chat room",
});
export default async function Page() {

View File

@@ -1,13 +1,14 @@
// components/ChatRoom.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import randomName from "@scaleway/random-name";
import { is } from "cheerio/lib/api/traversing";
import {
Check,
Copy,
Image as ImageIcon,
Menu,
PanelLeftClose,
PanelRightClose,
@@ -30,7 +31,8 @@ import { Textarea } from "../ui/textarea";
type Message = {
id: string;
text: string;
text?: string;
image?: string;
isSelf: boolean;
timestamp: string;
username: string;
@@ -58,14 +60,6 @@ const generateGradientClasses = (seed: string) => {
"bg-gradient-to-br from-teal-400 to-green-500",
"bg-gradient-to-br from-orange-400 to-yellow-500",
"bg-gradient-to-br from-indigo-400 to-blue-500",
"bg-gradient-to-br from-pink-400 to-red-500",
"bg-gradient-to-br from-teal-400 to-green-500",
"bg-gradient-to-br from-orange-400 to-yellow-500",
"bg-gradient-to-br from-indigo-400 to-blue-500",
"bg-gradient-to-br from-pink-400 to-red-500",
"bg-gradient-to-br from-teal-400 to-green-500",
"bg-gradient-to-br from-orange-400 to-yellow-500",
"bg-gradient-to-br from-indigo-400 to-blue-500",
];
const hash = seed
.split("")
@@ -87,6 +81,7 @@ export default function ChatRoom() {
const [isSending, setIsSending] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [connectedCount, setConnectedCount] = useState(0);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(!isSm);
const peerInstance = useRef<Peer | null>(null);
@@ -95,6 +90,7 @@ export default function ChatRoom() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const searchParams = useSearchParams();
const [isInvited, setIsInvited] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isSm || isMobile) {
@@ -105,11 +101,9 @@ export default function ChatRoom() {
useEffect(() => {
if ((remotePeerId || !!searchParams.get("room")) && !isConnected) {
setIsInvited(true);
// setRemotePeerId(peerId);
}
}, [remotePeerId, setRemotePeerId, searchParams.get("room"), isConnected]);
}, [remotePeerId, searchParams, isConnected]);
// 在组件初始化时就设置用户名,避免未定义的用户名问题
useEffect(() => {
const newUsername = randomName("", ".");
setUsername(newUsername);
@@ -121,7 +115,6 @@ export default function ChatRoom() {
}
}, [searchParams]);
// 清理函数,组件卸载时清理连接
useEffect(() => {
return () => {
if (peerInstance.current) {
@@ -150,28 +143,28 @@ export default function ChatRoom() {
if (peerInstance.current) return;
try {
const peer = new Peer();
const peer = new Peer({
config: {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
},
});
peerInstance.current = peer;
peer.on("open", (id) => {
setPeerId(id);
// 初始化自己为用户列表的第一个
setUsers((prev) => {
const newUser = { peerId: id, username };
// 确保不重复添加
if (!prev.some((u) => u.peerId === id)) {
return [...prev, newUser];
}
return prev;
});
setConnectedCount(1); // 至少包括自己
if (isInvited && remotePeerId && !isConnected) {
// connectToPeer(); // 被邀请者自动连接, BUG: 自动连接可能会导致用户列表无法刷新
}
setConnectedCount(1);
});
// 只有非被邀请用户(中继服务器)监听新连接
if (!isInvited) {
peer.on("connection", (conn) => {
connections.current.push(conn);
@@ -193,7 +186,7 @@ export default function ChatRoom() {
console.error("Failed to initialize peer:", err);
toast.error("Failed to initialize peer connection");
}
}, [username, isInvited, remotePeerId, isConnected]);
}, [username, isInvited]);
useEffect(() => {
if (username) {
@@ -204,106 +197,108 @@ export default function ChatRoom() {
const handleConnection = (conn: any) => {
conn.on("open", () => {
setIsConnected(true);
// 更新连接计数
setConnectedCount((prev) => {
const newCount = prev + 1;
broadcastCount(newCount);
return newCount;
});
// 发送当前用户列表给新连接的客户端
setTimeout(() => {
const userList = JSON.stringify(users);
conn.send(`USERLIST:${userList}`);
}, 100); // 延迟发送,确保状态已更新
conn.send({ type: "USERLIST", data: userList });
}, 100);
});
conn.on("data", (data: string) => {
if (data.startsWith("JOIN:")) {
const [_, joiningUsername, joiningPeerId] = data.split(":");
const joinMessage = {
id: crypto.randomUUID(),
text: `[${joiningUsername}] entered the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, joinMessage]);
// 更新用户列表,确保不重复添加
setUsers((prev) => {
if (!prev.some((u) => u.peerId === joiningPeerId)) {
const updatedUsers = [
...prev,
{ peerId: joiningPeerId, username: joiningUsername },
];
setTimeout(() => {
broadcastUserList(updatedUsers);
broadcastCount(updatedUsers.length);
}, 100);
return updatedUsers;
}
return prev;
});
broadcastMessage(joinMessage, conn);
} else if (data.startsWith("COUNT:")) {
const count = parseInt(data.split("COUNT:")[1], 10);
setConnectedCount(count);
} else if (data.startsWith("USERLIST:")) {
try {
const userList = JSON.parse(data.split("USERLIST:")[1]);
conn.on("data", (data: any) => {
if (typeof data === "object" && data.type) {
if (data.type === "JOIN") {
const { username: joiningUsername, peerId: joiningPeerId } =
data.data;
const joinMessage = {
id: crypto.randomUUID(),
text: `[${joiningUsername}] entered the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, joinMessage]);
setUsers((prev) => {
const mergedUsers = [...userList];
if (!mergedUsers.some((u) => u.peerId === peerId)) {
mergedUsers.push({ peerId, username });
if (!prev.some((u) => u.peerId === joiningPeerId)) {
const updatedUsers = [
...prev,
{ peerId: joiningPeerId, username: joiningUsername },
];
setTimeout(() => {
broadcastUserList(updatedUsers);
broadcastCount(updatedUsers.length);
}, 100);
return updatedUsers;
}
// 去除重复项
return mergedUsers.filter(
(user, index, self) =>
index === self.findIndex((u) => u.peerId === user.peerId),
);
return prev;
});
} catch (err) {
console.error("Error parsing user list:", err);
broadcastMessage(joinMessage, conn);
} else if (data.type === "COUNT") {
setConnectedCount(data.data);
} else if (data.type === "USERLIST") {
try {
const userList = JSON.parse(data.data);
setUsers((prev) => {
const mergedUsers = [...userList];
if (!mergedUsers.some((u) => u.peerId === peerId)) {
mergedUsers.push({ peerId, username });
}
return mergedUsers.filter(
(user, index, self) =>
index === self.findIndex((u) => u.peerId === user.peerId),
);
});
} catch (err) {
console.error("Error parsing user list:", err);
}
} else if (data.type === "LEAVE") {
const { username: leavingUsername, peerId: leavingPeerId } =
data.data;
const leaveMessage = {
id: crypto.randomUUID(),
text: `[${leavingUsername}] left the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, leaveMessage]);
broadcastMessage(leaveMessage, conn);
} else if (data.type === "IMAGE") {
const { username: senderUsername, image } = data.data;
const newMessage = {
id: crypto.randomUUID(),
image,
isSelf: false,
timestamp: formatTime(new Date()),
username: senderUsername,
};
setMessages((prev) => [...prev, newMessage]);
broadcastMessage(newMessage, conn);
} else if (data.type === "TEXT") {
const { username: senderUsername, text } = data.data;
const newMessage = {
id: crypto.randomUUID(),
text,
isSelf: false,
timestamp: formatTime(new Date()),
username: senderUsername,
};
setMessages((prev) => [...prev, newMessage]);
broadcastMessage(newMessage, conn);
}
} else if (data.startsWith("LEAVE:")) {
const [_, leavingUsername, leavingPeerId] = data.split(":");
const leaveMessage = {
id: crypto.randomUUID(),
text: `[${leavingUsername}] left the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, leaveMessage]);
broadcastMessage(leaveMessage, conn);
} else {
const [receivedUsername, ...messageParts] = data.split(": ");
const newMessage = {
id: crypto.randomUUID(),
text: messageParts.join(": "),
isSelf: false,
timestamp: formatTime(new Date()),
username: receivedUsername,
};
setMessages((prev) => [...prev, newMessage]);
broadcastMessage(newMessage, conn);
}
});
conn.on("close", () => {
connections.current = connections.current.filter((c) => c !== conn);
// 找到断开连接的用户
const disconnectedUser = users.find((user) => user.peerId === conn.peer);
if (disconnectedUser) {
// 创建离开的系统消息
const leaveMessage = {
id: crypto.randomUUID(),
text: `[${disconnectedUser.username}] left the room`,
@@ -313,8 +308,6 @@ export default function ChatRoom() {
isSystem: true,
};
setMessages((prev) => [...prev, leaveMessage]);
// 更新用户列表
setUsers((prev) => {
const updatedUsers = prev.filter((user) => user.peerId !== conn.peer);
setTimeout(() => {
@@ -324,8 +317,6 @@ export default function ChatRoom() {
return updatedUsers;
});
}
// 更新连接状态
if (!connRef.current && connections.current.length === 0) {
setIsConnected(false);
}
@@ -333,11 +324,20 @@ export default function ChatRoom() {
};
const broadcastMessage = (message: Message, senderConn: any) => {
// 确保只广播到相关的连接
connections.current.forEach((conn) => {
if (conn !== senderConn && conn.open) {
try {
conn.send(`${message.username}: ${message.text}`);
if (message.image) {
conn.send({
type: "IMAGE",
data: { username: message.username, image: message.image },
});
} else if (message.text) {
conn.send({
type: "TEXT",
data: { username: message.username, text: message.text },
});
}
} catch (err) {
console.error("Error broadcasting message:", err);
}
@@ -349,7 +349,7 @@ export default function ChatRoom() {
connections.current.forEach((conn) => {
if (conn.open) {
try {
conn.send(`COUNT:${count}`);
conn.send({ type: "COUNT", data: count });
} catch (err) {
console.error("Error broadcasting count:", err);
}
@@ -363,7 +363,7 @@ export default function ChatRoom() {
connections.current.forEach((conn) => {
if (conn.open) {
try {
conn.send(`USERLIST:${userList}`);
conn.send({ type: "USERLIST", data: userList });
} catch (err) {
console.error("Error broadcasting user list:", err);
}
@@ -383,88 +383,94 @@ export default function ChatRoom() {
connRef.current = conn;
conn.on("open", () => {
conn.send(`JOIN:${username}:${peerId}`);
conn.send({ type: "JOIN", data: { username, peerId } });
setIsConnected(true);
});
conn.on("data", (data: string) => {
if (data.startsWith("JOIN:")) {
const [_, joiningUsername, joiningPeerId] = data.split(":");
const joinMessage = {
id: crypto.randomUUID(),
text: `[${joiningUsername}] entered the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, joinMessage]);
// 更新用户列表,确保不重复添加
setUsers((prev) => {
if (!prev.some((u) => u.peerId === joiningPeerId)) {
return [
...prev,
{ peerId: joiningPeerId, username: joiningUsername },
];
}
return prev;
});
} else if (data.startsWith("COUNT:")) {
const count = parseInt(data.split("COUNT:")[1], 10);
setConnectedCount(count);
} else if (data.startsWith("USERLIST:")) {
try {
const userList = JSON.parse(data.split("USERLIST:")[1]);
conn.on("data", (data: any) => {
if (typeof data === "object" && data.type) {
if (data.type === "JOIN") {
const { username: joiningUsername, peerId: joiningPeerId } =
data.data;
const joinMessage = {
id: crypto.randomUUID(),
text: `[${joiningUsername}] entered the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, joinMessage]);
setUsers((prev) => {
const mergedUsers = [...userList];
if (!mergedUsers.some((u) => u.peerId === peerId)) {
mergedUsers.push({ peerId, username });
if (!prev.some((u) => u.peerId === joiningPeerId)) {
return [
...prev,
{ peerId: joiningPeerId, username: joiningUsername },
];
}
// 去除重复项
return mergedUsers.filter(
(user, index, self) =>
index === self.findIndex((u) => u.peerId === user.peerId),
);
return prev;
});
} catch (err) {
console.error("Error parsing user list:", err);
} else if (data.type === "COUNT") {
setConnectedCount(data.data);
} else if (data.type === "USERLIST") {
try {
const userList = JSON.parse(data.data);
setUsers((prev) => {
const mergedUsers = [...userList];
if (!mergedUsers.some((u) => u.peerId === peerId)) {
mergedUsers.push({ peerId, username });
}
return mergedUsers.filter(
(user, index, self) =>
index === self.findIndex((u) => u.peerId === user.peerId),
);
});
} catch (err) {
console.error("Error parsing user list:", err);
}
} else if (data.type === "LEAVE") {
const { username: leavingUsername, peerId: leavingPeerId } =
data.data;
const leaveMessage = {
id: crypto.randomUUID(),
text: `[${leavingUsername}] left the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, leaveMessage]);
} else if (data.type === "IMAGE") {
const { username: senderUsername, image } = data.data;
const newMessage = {
id: crypto.randomUUID(),
image,
isSelf: false,
timestamp: formatTime(new Date()),
username: senderUsername,
};
setMessages((prev) => [...prev, newMessage]);
} else if (data.type === "TEXT") {
const { username: senderUsername, text } = data.data;
const newMessage = {
id: crypto.randomUUID(),
text,
isSelf: false,
timestamp: formatTime(new Date()),
username: senderUsername,
};
setMessages((prev) => [...prev, newMessage]);
}
} else if (data.startsWith("LEAVE:")) {
const [_, leavingUsername, leavingPeerId] = data.split(":");
const leaveMessage = {
id: crypto.randomUUID(),
text: `[${leavingUsername}] left the room`,
isSelf: false,
timestamp: formatTime(new Date()),
username: "System",
isSystem: true,
};
setMessages((prev) => [...prev, leaveMessage]);
} else {
const [receivedUsername, ...messageParts] = data.split(": ");
const newMessage = {
id: crypto.randomUUID(),
text: messageParts.join(": "),
isSelf: false,
timestamp: formatTime(new Date()),
username: receivedUsername,
};
setMessages((prev) => [...prev, newMessage]);
}
});
conn.on("close", () => {
setIsConnected(false);
connRef.current = null;
// 找到断开连接的用户
const disconnectedUser = users.find(
(user) => user.peerId === remotePeerId,
);
if (disconnectedUser) {
// 创建离开的系统消息
const leaveMessage = {
id: crypto.randomUUID(),
text: `[${disconnectedUser.username}] left the room`,
@@ -475,8 +481,6 @@ export default function ChatRoom() {
};
setMessages((prev) => [...prev, leaveMessage]);
}
// 更新用户列表
setUsers((prev) => {
const updatedUsers = prev.filter(
(user) => user.peerId !== remotePeerId,
@@ -500,11 +504,67 @@ export default function ChatRoom() {
}
}, [remotePeerId, username, peerId, users]);
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
toast.error("Image size should not exceed 5MB");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setSelectedImage(file);
sendImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const sendImage = (base64Image: string) => {
if (!base64Image || isSending) return;
setIsSending(true);
const newMessage = {
id: crypto.randomUUID(),
image: base64Image,
isSelf: true,
timestamp: formatTime(new Date()),
username,
};
setMessages((prev) => [...prev, newMessage]);
try {
if (connRef.current && connRef.current.open) {
connRef.current.send({
type: "IMAGE",
data: { username, image: base64Image },
});
} else if (!isInvited && connections.current.length > 0) {
connections.current.forEach((conn) => {
if (conn.open) {
conn.send({
type: "IMAGE",
data: { username, image: base64Image },
});
}
});
}
} catch (err) {
console.error("Error sending image:", err);
toast.error("Failed to send image");
}
setSelectedImage(null);
setIsSending(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const sendMessage = useCallback(() => {
if (!message || isSending) return;
setIsSending(true);
const formattedMessage = `${username}: ${message}`;
const newMessage = {
id: crypto.randomUUID(),
text: message,
@@ -516,12 +576,14 @@ export default function ChatRoom() {
try {
if (connRef.current && connRef.current.open) {
connRef.current.send(formattedMessage);
connRef.current.send({
type: "TEXT",
data: { username, text: message },
});
} else if (!isInvited && connections.current.length > 0) {
// 只广播到当前房间的连接
connections.current.forEach((conn) => {
if (conn.open) {
conn.send(formattedMessage);
conn.send({ type: "TEXT", data: { username, text: message } });
}
});
}
@@ -569,12 +631,11 @@ export default function ChatRoom() {
const createNewRoom = () => {
if (peerInstance.current) {
// 通知其他用户我要离开
if (username && peerId) {
connections.current.forEach((conn) => {
if (conn.open) {
try {
conn.send(`LEAVE:${username}:${peerId}`);
conn.send({ type: "LEAVE", data: { username, peerId } });
} catch (err) {
console.error("Error sending leave notification:", err);
}
@@ -582,7 +643,6 @@ export default function ChatRoom() {
});
}
// 清理当前连接
peerInstance.current.destroy();
peerInstance.current = null;
connections.current.forEach((conn) => {
@@ -594,7 +654,6 @@ export default function ChatRoom() {
}
connRef.current = null;
// 重置状态
setPeerId("");
setRemotePeerId("");
setMessages([]);
@@ -602,7 +661,6 @@ export default function ChatRoom() {
setIsConnected(false);
setConnectedCount(0);
// 重新初始化
setTimeout(() => {
initializePeer();
}, 500);
@@ -611,7 +669,6 @@ export default function ChatRoom() {
return (
<div className="flex min-h-screen bg-gradient-to-br from-blue-50 to-neutral-100 dark:from-neutral-900 dark:to-neutral-800">
{/* 侧边栏 */}
{(isMobile && isSidebarOpen) || (!isMobile && isSidebarOpen) ? (
<div
className={`${
@@ -627,7 +684,7 @@ export default function ChatRoom() {
<Button
variant={"ghost"}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-0 text-neutral-600 hover:text-blue-500 dark:text-neutral-400 dark:hover:text-blue-400"
className="h-5 p-0 text-neutral-600 hover:text-blue-500 dark:text-neutral-400 dark:hover:text-blue-400"
>
{!isSidebarOpen ? (
<PanelRightClose size={20} />
@@ -659,53 +716,63 @@ export default function ChatRoom() {
))
)}
</div>
{!isInvited && (
<Button
onClick={createNewRoom}
variant={"outline"}
className="mt-auto flex items-center justify-center gap-1"
>
<Plus size={20} />
New Room
</Button>
)}
<Button
onClick={createNewRoom}
variant={"outline"}
className="mt-auto flex items-center justify-center gap-1"
>
<Plus size={20} />
New Room
</Button>
</div>
) : null}
{/* 聊天框 */}
<div
className={`flex flex-1 flex-col bg-white px-4 pb-1 pt-3 transition-all duration-300 dark:bg-neutral-800 ${
className={`grids grids-dark flex flex-1 flex-col bg-white px-4 pb-1 pt-3 transition-all duration-300 dark:bg-neutral-800 ${
isMobile || !isSidebarOpen ? "w-full" : ""
}`}
>
<div className="mb-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="mr-auto flex items-center gap-2">
<Button
variant={"ghost"}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-0 text-neutral-600 hover:text-blue-500 dark:text-neutral-400 dark:hover:text-blue-400"
className="h-5 p-0 text-neutral-600 hover:text-blue-500 dark:text-neutral-400 dark:hover:text-blue-400"
>
{!isSidebarOpen && <PanelRightClose size={20} />}
</Button>
<h1 className="text-2xl font-bold text-neutral-800 dark:text-neutral-100">
Chat Room
</h1>
<Link
href={"/chat"}
className="text-2xl font-bold text-neutral-800 dark:text-neutral-100"
style={{ fontFamily: "Bahamas Bold" }}
>
WRoom
</Link>
<Badge>Beta</Badge>
</div>
<div className="ml-auto flex items-center gap-2">
{!isMobile && (
<>
<Link
className="text-sm hover:underline"
href={"/dashboard"}
target="_blank"
>
Dashboard
</Link>
<Link
className="text-sm hover:underline"
href={"/docs/wroom"}
target="_blank"
>
About
</Link>
</>
)}
<div className="flex items-center gap-2">
<Badge className="flex items-center gap-1 text-xs text-green-300 dark:text-green-700">
<Icons.users className="size-3" /> Online: {connectedCount}
</Badge>
{/* <span
className={`rounded-full px-2 py-1 text-xs ${
isConnected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
}`}
>
{isConnected ? "Connected" : "Disconnected"}
</span> */}
</div>
<ModeToggle />
</div>
@@ -713,7 +780,7 @@ export default function ChatRoom() {
<div className="mb-4 space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">
Your&nbsp; &nbsp;ID:
Your&nbsp;&nbsp;&nbsp;ID:
</span>
<Input
type="text"
@@ -745,13 +812,12 @@ export default function ChatRoom() {
Room ID:
</span>
{!isInvited && isConnected ? (
// <Badge>Room owner</Badge>
<Input
type="text"
placeholder="Your are the room owner"
placeholder="You are the room owner"
readOnly
disabled
className="flex-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder-neutral-400"
className="flex-1 rounded border bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder-neutral-400"
/>
) : (
<Input
@@ -761,14 +827,13 @@ export default function ChatRoom() {
placeholder="Enter a room id"
readOnly={isConnected}
disabled={isConnected}
className="flex-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder-neutral-400"
className="flex-1 rounded border bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder-neutral-400"
/>
)}
<Button
variant={"secondary"}
onClick={connectToPeer}
disabled={!remotePeerId || isConnected}
disabled={!peerId || !remotePeerId || isConnected}
size={"sm"}
className={cn(
"flex items-center gap-2 rounded bg-blue-500 text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-neutral-400 dark:bg-blue-600 dark:hover:bg-blue-700",
@@ -782,7 +847,7 @@ export default function ChatRoom() {
</div>
</div>
<div className="h-full min-h-[100px] overflow-y-auto rounded-md rounded-b-none border border-neutral-200 p-4 dark:border-neutral-600 dark:bg-neutral-700">
<div className="h-full min-h-[100px] overflow-y-auto rounded-md rounded-b-none border border-neutral-200 bg-white p-4 dark:border-neutral-600 dark:bg-neutral-700">
{messages.map((msg) => (
<div
key={msg.id}
@@ -820,7 +885,15 @@ export default function ChatRoom() {
: "bg-neutral-200 text-neutral-800 dark:bg-neutral-600 dark:text-neutral-200"
}`}
>
<p className="text-sm">{msg.text}</p>
{msg.text && <p className="text-sm">{msg.text}</p>}
{msg.image && (
<img
src={msg.image}
alt="Sent image"
className="max-w-full rounded-md"
style={{ maxHeight: "200px" }}
/>
)}
<span
className={cn(
"mt-1 block text-xs opacity-70",
@@ -855,21 +928,42 @@ export default function ChatRoom() {
onChange={(e) => setMessage(e.target.value)}
placeholder={`Hi ${username || "Loading..."}, send a message to start...`}
className="min-h-20 flex-1 rounded-md rounded-t-none border border-t-0 bg-neutral-50 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-600 dark:text-neutral-200 dark:placeholder-neutral-400"
onKeyPress={(e) => e.key === "Enter" && sendMessage()}
onKeyPress={(e) =>
e.key === "Enter" && !e.shiftKey && sendMessage()
}
disabled={!isConnected}
/>
<Button
onClick={sendMessage}
disabled={!isConnected || !message || isSending}
className="absolute bottom-2 right-2"
size={"sm"}
>
{isSending ? (
<span className="animate-spin"></span>
) : (
<Icons.send className="size-4" />
)}
</Button>
<div className="absolute bottom-2 right-2 flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={!isConnected || isSending}
title="Send Image"
>
<ImageIcon size={16} />
</Button>
<input
type="file"
ref={fileInputRef}
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<Button
onClick={sendMessage}
disabled={
!isConnected || (!message && !selectedImage) || isSending
}
size={"sm"}
>
{isSending ? (
<span className="animate-spin"></span>
) : (
<Icons.send className="size-4" />
)}
</Button>
</div>
</div>
<footer className="mt-2 py-2 text-center text-sm font-semibold text-neutral-600 dark:text-neutral-300">

View File

@@ -12,6 +12,7 @@ export const sidebarLinks: SidebarNavItem[] = [
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
{ href: "/emails", icon: "mail", title: "Emails" },
{ href: "/dashboard/records", icon: "globeLock", title: "DNS Records" },
{ href: "/chat", icon: "messages", title: "WRoom" },
],
},
{
@@ -59,18 +60,18 @@ export const sidebarLinks: SidebarNavItem[] = [
title: "Users",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/records",
icon: "globe",
title: "Records",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/urls",
icon: "link",
title: "URLs",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/records",
icon: "globe",
title: "Records",
authorizeOnly: UserRole.ADMIN,
},
],
},
{

View File

@@ -31,6 +31,11 @@ export const docsConfig: DocsConfig = {
href: "/docs/dns-records",
icon: "page",
},
{
title: "WRoom",
href: "/docs/wroom",
icon: "page",
},
],
},
{

196
content/docs/wroom.mdx Normal file
View File

@@ -0,0 +1,196 @@
---
title: WRoom
description: A temporary, peer-to-peer, and secure chat room
---
## Introduction
The **WRoom** is a decentralized, browser-based application built using **PeerJS**, enabling real-time communication between users without relying on a central server. Leveraging WebRTC technology, this chat room supports both text and image messaging, making it a versatile tool for peer-to-peer interaction. The application features a modern, responsive UI built with **React** and **Next.js**, offering a seamless experience across desktop and mobile devices.
### Key Features
- **Decentralized Communication**: Uses PeerJS for direct peer-to-peer connections, eliminating the need for a central server.
- **Text Messaging**: Send and receive real-time text messages with timestamps and usernames.
- **Image Messaging**: Share images (up to 5MB) with other users in the chat room, displayed inline with messages.
- **User Management**: Displays a list of connected users with unique avatars and usernames.
- **Room Creation and Sharing**: Create new rooms or join existing ones using a unique peer ID, with easy sharing via a generated URL.
- **Responsive Design**: Adapts to various screen sizes, with a collapsible sidebar for mobile users.
- **Connection Status**: Real-time indicators for online users and connection state.
- **Dark Mode**: Toggle between light and dark themes for user preference.
### Technical Highlights
- Built with **Next.js** for server-side rendering and client-side interactivity.
- Utilizes **PeerJS** with STUN servers for reliable WebRTC connections.
- Messages are transmitted as structured objects to ensure data integrity, especially for Base64-encoded images.
- Random usernames are generated using the `@scaleway/random-name` library.
- Styled with **Tailwind CSS** and custom gradient-based avatars.
## Usage Guide
This section provides step-by-step instructions on how to use the WRoom effectively.
### Prerequisites
- A modern web browser (e.g., Chrome, Firefox, Edge) with WebRTC support.
- An internet connection (required for initial peer connection via STUN servers).
- No additional software installation is needed—just open the application in your browser.
### Getting Started
1. **Access the Chat Room**
- Open [https://wr.do/chat](https://wr.do/chat) in your browser.
- The chat room will automatically initialize and assign you a unique **Peer ID**.
2. **Create a New Room**
- Upon loading, you are the "owner" of a new chat room by default.
- Your **Peer ID** (e.g., `abc123-xyz789`) is displayed under "Your ID."
- Share this ID with others to invite them to your room.
3. **Join an Existing Room**
- If you have a room ID from another user, enter it in the "Room ID" field.
- Click the **Connect** button to join the room.
- Once connected, the button will turn green and display "Connected."
4. **Share Your Room**
- Click the **Share Room** button next to "Your ID" to copy a URL (e.g., `https://wr.do/chat?room=abc123-xyz789`).
- Send this URL to others via email, messaging apps, or any preferred method.
- Recipients can open the URL to join your room directly.
### Sending Messages
1. **Text Messages**
- Type your message in the text area at the bottom of the chat window.
- Press **Enter** (without Shift) or click the **Send** button (paper plane icon) to send.
- Your message will appear on the right side with your username and timestamp, while others' messages appear on the left.
2. **Image Messages**
- Click the **Image** icon (next to the Send button) to open a file picker.
- Select an image file from your device (max size: 5MB, supported formats: PNG, JPG, etc.).
- The image will be sent automatically and displayed in the chat with your username and timestamp.
- *Note*: If the image exceeds 5MB, an error toast will appear.
### Managing the Chat Room
- **View Users**
- Toggle the sidebar (via the menu icon) to see a list of connected users.
- The room owner is marked with an "Owner" badge.
- Each user has a unique gradient avatar based on their username.
- **Create a New Room**
- If youre the owner, click **New Room** in the sidebar to reset the current room and generate a new Peer ID.
- This disconnects all users and starts a fresh session.
- **Disconnect**
- Simply close the browser tab to leave the room. If youre the owner, the room persists as long as other peers remain connected.
### UI Customization
- **Dark Mode**: Use the toggle in the top-right corner to switch between light and dark themes.
- **Sidebar**: On mobile devices, the sidebar is hidden by default; tap the menu icon to show it.
### Troubleshooting
- **Connection Issues**: If "Disconnected" appears, check your internet connection or try refreshing the page.
- **Image Not Displaying**: Ensure the image is under 5MB and in a supported format. If the issue persists, verify the recipients browser console for errors.
- **Peer ID Not Working**: Confirm the ID is correct and the room owner is still online.
## Notes
- **Limitations**: The chat room relies on WebRTC, so firewall or network restrictions may affect connectivity. Images larger than 5MB are blocked to prevent performance issues.
- **Privacy**: No messages are stored server-side; all communication is peer-to-peer. However, use caution when sharing sensitive information.
- **Future Enhancements**: Potential features include file compression, multi-file support, or end-to-end encryption.
This WRoom offers a lightweight, user-friendly solution for real-time communication. Enjoy chatting and sharing with your peers at [https://wr.do/chat](https://wr.do/chat)!
Chinese (Simplified):
## 介绍
**WRoom 聊天室** 是一款基于浏览器的去中心化应用程序,利用 **PeerJS** 实现用户之间的实时通信,无需依赖中央服务器。通过 WebRTC 技术,该聊天室支持文本和图片消息,使其成为一个多功能的点对点交互工具。应用采用 **React** 和 **Next.js** 构建,拥有现代化的响应式用户界面,适用于桌面和移动设备。
### 主要功能
- **去中心化通信**:通过 PeerJS 实现直接的点对点连接,无需中央服务器。
- **文本消息**:实时发送和接收带有时间戳和用户名的文本消息。
- **图片消息**:与聊天室中的其他用户分享图片(最大 5MB图片将内嵌显示在消息中。
- **用户管理**:显示连接用户的列表,每个用户拥有独特的头像和用户名。
- **房间创建与分享**:创建新房间或加入现有房间,使用唯一的 Peer ID并通过生成的 URL 轻松分享。
- **响应式设计**:适配不同屏幕尺寸,移动端用户可折叠侧边栏。
- **连接状态**:实时显示在线用户数和连接状态。
- **暗色模式**:可在浅色和深色主题之间切换,满足用户偏好。
### 技术亮点
- 使用 **Next.js** 实现服务端渲染和客户端交互。
- 利用 **PeerJS** 和 STUN 服务器确保 WebRTC 连接的可靠性。
- 消息以结构化对象形式传输,确保数据完整性,特别是 Base64 编码的图片。
- 使用 `@scaleway/random-name` 库生成随机用户名。
- 使用 **Tailwind CSS** 进行样式设计,搭配基于梯度的自定义头像。
## 使用指南
本节提供逐步说明,帮助您有效使用 P2P 聊天室。
### 前提条件
- 一个支持 WebRTC 的现代浏览器(如 Chrome、Firefox、Edge
- 互联网连接(通过 STUN 服务器建立初始 Peer 连接时需要)。
- 无需安装额外软件,直接在浏览器中打开应用即可。
### 开始使用
1. **访问聊天室**
- 在浏览器中打开 [https://wr.do/chat](https://wr.do/chat)。
- 聊天室将自动初始化并为您分配一个唯一的 **Peer ID**。
2. **创建新房间**
- 加载应用后,您默认成为新聊天室的“拥有者”。
- 您的 **Peer ID**(例如 `abc123-xyz789`会显示在“Your ID”字段中。
- 将此 ID 分享给他人,邀请他们加入您的房间。
3. **加入现有房间**
- 如果您有其他用户的房间 ID请在“Room ID”字段中输入。
- 点击 **Connect连接** 按钮加入房间。
- 连接成功后按钮将变为绿色并显示“Connected已连接”。
4. **分享您的房间**
- 点击“Your ID”旁边的 **Share Room分享房间** 按钮,复制包含您 Peer ID 的 URL例如 `https://wr.do/chat?room=abc123-xyz789`)。
- 通过电子邮件、消息应用或其他方式将此 URL 发送给他人。
- 接收者可直接打开 URL 加入您的房间。
### 发送消息
1. **文本消息**
- 在聊天窗口底部的文本区域输入消息。
- 按 **Enter** 键(不按 Shift或点击 **Send发送** 按钮(纸飞机图标)发送。
- 您的消息将显示在右侧,带有您的用户名和时间戳;他人的消息显示在左侧。
2. **图片消息**
- 点击 **Image图片** 图标(位于发送按钮旁)打开文件选择器。
- 从设备中选择图片文件(最大 5MB支持 PNG、JPG 等格式)。
- 图片将自动发送并在聊天中显示,带有您的用户名和时间戳。
- *注意*:如果图片超过 5MB会显示错误提示。
### 管理聊天室
- **查看用户**
- 通过菜单图标切换侧边栏,查看连接用户列表。
- 房间拥有者标有“Owner拥有者”徽章。
- 每个用户拥有基于用户名的独特梯度头像。
- **创建新房间**
- 如果您是拥有者,点击侧边栏中的 **New Room新房间** 重置当前房间并生成新的 Peer ID。
- 这将断开所有用户连接并开始一个新会话。
- **断开连接**
- 关闭浏览器标签即可离开房间。如果您是拥有者,只要其他 Peer 仍在线,房间会继续存在。
### 界面自定义
- **暗色模式**:使用右上角的切换按钮在浅色和深色主题间切换。
- **侧边栏**:在移动设备上,侧边栏默认隐藏,点击菜单图标可显示。
### 故障排除
- **连接问题**如果显示“Disconnected已断开请检查网络连接或刷新页面。
- **图片未显示**:确保图片小于 5MB 且格式支持。如问题持续,请检查接收方浏览器控制台是否有错误。
- **Peer ID 无效**:确认 ID 正确且房间拥有者仍在线。
## 注意事项
- **限制**:聊天室依赖 WebRTC防火墙或网络限制可能影响连接。图片大小超过 5MB 将被阻止以避免性能问题。
- **隐私**:消息不存储在服务器端,所有通信均为点对点。但分享敏感信息时仍需谨慎。
- **未来改进**:可能添加的功能包括文件压缩、多文件支持或端到端加密。
**WRoom** 提供了一个轻量级、用户友好的实时通信解决方案。立即访问 [https://wr.do/chat](https://wr.do/chat),享受与您的朋友聊天和分享吧!

File diff suppressed because one or more lines are too long