Compare commits
23 Commits
79d12b4f51
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c6a549a5f | |||
| 85d2189595 | |||
| bb85b00b9c | |||
| 5df9a65ecf | |||
| cefad7dcdd | |||
| 59aef22205 | |||
| 2f8f59c881 | |||
| bdd41b2db7 | |||
| 80dca24a47 | |||
| 3196d47569 | |||
| fc26fcf778 | |||
| 61420ddd4e | |||
| 1e79ea9e91 | |||
| ec84354aea | |||
| 8bd1eeb0b7 | |||
| be12d92e86 | |||
| 8168ab2ebd | |||
| c9893501b6 | |||
| c1207c0e13 | |||
| 466bfa4625 | |||
| bbb0e79a91 | |||
| b3bdaae496 | |||
| 9a2bc648c4 |
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Server",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/server/index.js",
|
||||
"cwd": "${workspaceFolder}/server/",
|
||||
"env": {
|
||||
"ADDRESS": "127.0.0.1",
|
||||
"PORT": "8535"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
-1
@@ -6,7 +6,6 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "chat",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "chat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link id="favicon" rel="icon" href="%PUBLIC_URL%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="chat app"
|
||||
/>
|
||||
<meta name="description" content="chat app" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>chat</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Symbols+2&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
+18
-374
@@ -1,253 +1,31 @@
|
||||
import {useEffect, useState, useRef} from 'react';
|
||||
import InitPage from './InitPage';
|
||||
import io from "socket.io-client";
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import {v1 as uuidv1} from "uuid";
|
||||
import axios from "axios";
|
||||
import {useState} from 'react';
|
||||
import { InitPage } from './InitPage';
|
||||
import { Chat } from './Chat';
|
||||
|
||||
|
||||
var BASE_URL = window.location.hostname === "localhost" ? "http://localhost:8535" : "";
|
||||
export const SERVER_BASE_URL = "https://chat.owo69.me";
|
||||
|
||||
function App() {
|
||||
export function App() {
|
||||
|
||||
var [user, _setUser] = useState(localStorage.user ? JSON.parse(localStorage.user) : {uuid: uuidv1()});
|
||||
var setUser = u => { _setUser(u); localStorage.user = JSON.stringify(u); }
|
||||
var [user, _setUser] = useState(localStorage.user ? JSON.parse(localStorage.user) : {
|
||||
uuid: crypto.randomUUID(),
|
||||
secret: crypto.randomUUID()
|
||||
});
|
||||
function setUser(u) {
|
||||
_setUser(u);
|
||||
localStorage.user = JSON.stringify(u);
|
||||
}
|
||||
function updateUser(partial_user) {
|
||||
setUser({...user, ...partial_user});
|
||||
};
|
||||
|
||||
var [theme, _setTheme] = useState(localStorage.theme);
|
||||
var [theme, _setTheme] = useState(localStorage.theme || "");
|
||||
var setTheme = theme => { _setTheme(theme); localStorage.theme = theme; }
|
||||
|
||||
|
||||
return <div className={"App"+((theme === "dark" || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) ? " dark" : "")}>
|
||||
<div className="App h-full dark:bg-black dark:text-white">
|
||||
{user.name ? <Chat user={user} setUser={setUser} theme={theme} setTheme={setTheme} /> : <InitPage setUser={setUser} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
export default App;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function Chat({user, setUser, theme, setTheme}) {
|
||||
|
||||
var [messages, setMessages] = useState([]);
|
||||
var [users, setUsers] = useState([]);
|
||||
var [socket, setSocket] = useState();
|
||||
var [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
var [progress, setProgress] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
var socket = io(BASE_URL);
|
||||
setSocket(socket);
|
||||
global.socket = socket;
|
||||
socket.on("connect", () => {
|
||||
console.debug("socket connect, id: " + socket.id);
|
||||
socket.emit("user", user);
|
||||
});
|
||||
socket.on("disconnect", () => console.debug("socket disconnect"));
|
||||
{let emit = socket.emit; socket.emit = function() {
|
||||
emit.apply(socket, arguments);
|
||||
console.debug("send", ...arguments);
|
||||
}}
|
||||
socket.onAny((eventName, ...args) => {
|
||||
console.debug("receive", eventName, ...args)
|
||||
});
|
||||
socket.on("messages", setMessages);
|
||||
socket.on("message", message => {
|
||||
setMessages(messages => [...messages, message]);
|
||||
});
|
||||
socket.on("users", setUsers);
|
||||
return () => socket.close();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
function updateUser(partial_user) {
|
||||
var updated_user = Object.assign(user, partial_user)
|
||||
setUser(updated_user);
|
||||
socket.emit("user", updated_user);
|
||||
};
|
||||
|
||||
function sendMessage(message) {
|
||||
socket.emit("message", message);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Chat flex flex-col">
|
||||
<MessageList messages={messages} setMessages={setMessages} />
|
||||
<UserList users={users} updateUser={updateUser} user={user} socket={socket} />
|
||||
<ProgressBar progress={progress} />
|
||||
<ChatInput sendMessage={sendMessage} user={user} updateUser={updateUser} setSettingsModalOpen={setSettingsModalOpen} socket={socket} setProgress={setProgress} />
|
||||
<SettingsModal open={settingsModalOpen} setOpen={setSettingsModalOpen} user={user} updateUser={updateUser} theme={theme} setTheme={setTheme} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function MessageList({messages, setMessages}) {
|
||||
var [atBottom, setAtBottom] = useState(true);
|
||||
var [atTop, setAtTop] = useState(false);
|
||||
var endRef = useRef();
|
||||
|
||||
function onScroll(event) {
|
||||
var { scrollTop, scrollHeight, clientHeight } = event.target;
|
||||
setAtBottom(scrollHeight - (scrollTop + clientHeight) < 32);
|
||||
setAtTop(scrollTop < 32);
|
||||
};
|
||||
|
||||
var scrollToBottom = () => endRef.current?.scrollIntoView();
|
||||
useEffect(() => {
|
||||
if (atBottom) scrollToBottom();
|
||||
});
|
||||
var observer = new ResizeObserver(() => atBottom && scrollToBottom());
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("atTop", atTop);
|
||||
if (atTop) loadOlderMessages();
|
||||
}, [atTop]);
|
||||
useEffect(() => {
|
||||
console.debug("atBottom", atBottom);
|
||||
if (atBottom) setMessages(messages => messages.slice(-100));
|
||||
}, [atBottom]);
|
||||
|
||||
async function loadOlderMessages() {
|
||||
var olderMessages = await fetch(BASE_URL+"/messages?before="+messages[0].timestamp).then(res => res.json());
|
||||
console.debug("loadOlderMessages", olderMessages);
|
||||
setMessages(messages => [...olderMessages, ...messages]);
|
||||
setTimeout(() =>
|
||||
document.getElementById(olderMessages.at(-1)._id).scrollIntoView(), 0);
|
||||
}
|
||||
|
||||
return <ul className="p-4 w-full flex-1 overflow-y-auto break-words" onScroll={onScroll} onLoad={e => observer.observe(e.target)}>
|
||||
{messages.map(message => <Message message={message} key={message._id} />)}
|
||||
<div ref={endRef}></div>
|
||||
</ul>
|
||||
}
|
||||
|
||||
function Message({message}) {
|
||||
if (message.user || message.author) {
|
||||
var prefix = <b>{message.user?.website ? <a href={message.user.website} target="_blank" rel="noopener" className={message.user?.website && "hover:underline"}>{message.user?.name || message.author}</a> : message.user?.name || message.author}: </b>
|
||||
}
|
||||
var content = <span className={"content" + (message.user||message.author ? '' : " font-bold")} style={{color: message.color || message.user?.color}} title={new Date(message.timestamp).toLocaleString()}>{processMessageContent(message.content)}</span>
|
||||
|
||||
if (message.file) {
|
||||
let url = BASE_URL + `/file/${message._id}/${message.file.name}`;
|
||||
var file =
|
||||
message.file.type?.startsWith("image") ?
|
||||
<a href={url} target="_blank" rel="noopener"><img src={url} alt={message.file.name} className="max-h-32 inline-block align-top border" /></a>
|
||||
: message.file.type?.startsWith("video") ?
|
||||
<video className='max-h-64 inline-block align-top border' controls>
|
||||
<source src={url} type={message.file.type} />
|
||||
</video>
|
||||
: message.file.type?.startsWith("audio") ?
|
||||
<audio className='max-h-64 inline-block align-top' controls>
|
||||
<source src={url} type={message.file.type} />
|
||||
</audio>
|
||||
: <a href={url} target="_blank" rel="noopener">{message.file.name}</a>
|
||||
}
|
||||
|
||||
return <li id={message._id}>{prefix} {content} {file}</li>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function ChatInput({sendMessage, user, updateUser, setSettingsModalOpen, socket, setProgress}) {
|
||||
var [content, setContent] = useState("");
|
||||
var fileInput = useRef();
|
||||
var [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
content = content.trim();
|
||||
if (!content) return;
|
||||
sendMessage({content});
|
||||
setContent("");
|
||||
}
|
||||
async function uploadFiles(files) {
|
||||
console.debug("uploadFiles", files);
|
||||
var formdata = new FormData();
|
||||
for (let file of files) formdata.append(file.name, file);
|
||||
setProgress(0);
|
||||
var res = await axios.post(BASE_URL + "/upload", formdata, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
},
|
||||
onUploadProgress: progressEvent => {
|
||||
console.debug("progressEvent", progressEvent);
|
||||
var progress = progressEvent.loaded / progressEvent.total;
|
||||
console.debug("upload progress", `${progress*100}%`);
|
||||
setProgress(progress);
|
||||
}
|
||||
});
|
||||
setProgress(null);
|
||||
console.debug("upload res", res.data);
|
||||
var codes = res.data.map(({name, code, type}) => {
|
||||
name = name.replaceAll(' ', '_');
|
||||
var t = type.toLowerCase().split('/')[0];
|
||||
t = t === "image" ? "img" : t === "video" ? "video" : t === "audio" ? "audio" : "file";
|
||||
return `[${t}:${code}/${name}]`
|
||||
}).join(" ");
|
||||
setContent(content => content + ' ' + codes);
|
||||
}
|
||||
return <div className='ChatInput'>
|
||||
<form className="flex flex-row" onSubmit={onSubmit}>
|
||||
<input
|
||||
className="p-4 flex-1 dark:bg-black"
|
||||
style={{color: user.color}}
|
||||
type="text"
|
||||
onChange={event => {
|
||||
setContent(event.target.value);
|
||||
socket.emit("type");
|
||||
}}
|
||||
onPaste={event => {
|
||||
var files = event.clipboardData.files;
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
uploadFiles(files);
|
||||
}
|
||||
}}
|
||||
value={content}
|
||||
placeholder="type or paste files and press ENTER">
|
||||
</input>
|
||||
<input type="submit" className='w-14 h-14 cursor-pointer' value="➡️" />
|
||||
<button type="button" className='w-14 h-14' onClick={()=>setShowEmojiPicker(!showEmojiPicker)}>🤔</button>
|
||||
<label htmlFor="file" className="w-14 h-14 text-center leading-[56px] cursor-pointer">📄</label>
|
||||
<input type="file" id="file" className='hidden' onChange={event => {
|
||||
uploadFiles(event.target.files);
|
||||
event.target.value = null;
|
||||
}} ref={fileInput} multiple></input>
|
||||
<button type="button" className='w-14 h-14' onClick={e => setSettingsModalOpen(true)}>⚙️</button>
|
||||
</form>
|
||||
<EmojiPicker open={showEmojiPicker} setOpen={setShowEmojiPicker} setChatInputContent={setContent} />
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
function ProgressBar({progress}) {
|
||||
if (progress !== null) return <div className="w-full h-1" style={{backgroundColor: "red"}}>
|
||||
<div className="h-full" style={{backgroundColor: "lime", width: `${progress*100}%`}}></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
function EmojiPicker({open, setOpen, setChatInputContent}) {
|
||||
var [emojis, setEmojis] = useState([]);
|
||||
var getEmojis = () => fetch(BASE_URL + "/emojis").then(r => r.json());
|
||||
useEffect(() => {open && getEmojis().then(setEmojis)}, [open]);
|
||||
var onEmojiClick = event => {
|
||||
//setOpen(false);
|
||||
setChatInputContent(content => `${content} :${event.target.dataset.emoji}:`);
|
||||
};
|
||||
if (open)
|
||||
return <div className='fixed top-0 left-0 w-full h-full' onClick={e=>setOpen(false)}>
|
||||
<div className='w-[258px] h-64 rounded border fixed right-6 bottom-16 overflow-auto bg-white dark:bg-black p-2' onClick={e=>e.stopPropagation()}>
|
||||
{emojis.map(emoji => <img src={BASE_URL+"/emoji/"+emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} data-emoji={emoji} className="w-10 h-10 inline-block p-0.5 cursor-pointer border border-transparent hover:border-gray-600" onClick={onEmojiClick} />)}
|
||||
{user.name ? <Chat user={user} updateUser={updateUser} theme={theme} setTheme={setTheme} /> : <InitPage updateUser={updateUser} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -257,137 +35,3 @@ function EmojiPicker({open, setOpen, setChatInputContent}) {
|
||||
|
||||
|
||||
|
||||
|
||||
function UserList({users, updateUser, user: myUser, socket}) {
|
||||
function changeName() {
|
||||
var name = prompt("Change name", myUser.name);
|
||||
if (!name) return;
|
||||
updateUser({name});
|
||||
}
|
||||
useEffect(() => {
|
||||
socket?.on("type", socketid => {
|
||||
var n = document.getElementById(socketid);
|
||||
n.style.bottom = "2px";
|
||||
setTimeout(() => n.style.bottom = "0px", 100);
|
||||
});
|
||||
}, [socket])
|
||||
return <div className="fixed top-0 right-5 text-right max-w-xs">
|
||||
<b>{users.length}</b> online: {users.map(user => <div style={{color: user.color}} key={user.socketid} id={user.socketid} className={"mr-1 inline-block relative" + (user.socketid === socket.id ? ' cursor-pointer ' : '')} onClick={user.socketid === socket.id ? changeName : undefined}>{user.name}</div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function SettingsModal({open, setOpen, user, updateUser, theme, setTheme}) {
|
||||
var [emojis, setEmojis] = useState([]);
|
||||
async function getEmojis() {
|
||||
var emojis = await (await fetch(BASE_URL + "/emojis")).json();
|
||||
return emojis;
|
||||
}
|
||||
useEffect(function(){
|
||||
if (open) {
|
||||
getEmojis().then(setEmojis);
|
||||
}
|
||||
},[open]);
|
||||
|
||||
|
||||
function EmojiUploader({setEmojis}) {
|
||||
var [file, setFile] = useState(null);
|
||||
var [name, setName] = useState("");
|
||||
async function onSubmit(event){
|
||||
event.preventDefault();
|
||||
if (!name || !file) return;
|
||||
var _file = file; setFile(null);
|
||||
var _name = name; setName("");
|
||||
var res = await fetch(BASE_URL+"/emoji/"+_name+"?type="+encodeURIComponent(_file.type), {
|
||||
method: "PUT",
|
||||
body: await _file.arrayBuffer()
|
||||
});
|
||||
if (res.status === 201) setEmojis(emojis => [...emojis, _name]);
|
||||
else alert(await res.text());
|
||||
}
|
||||
return <form onSubmit={onSubmit}>
|
||||
<input type="file" onChange={event => setFile(event.target.files[0])} />
|
||||
<input type="text" placeholder='Emoji name' className="dark:text-black border" value={name} onChange={event => setName(event.target.value.replace(/[^a-z0-9-_]/gi, ''))} />
|
||||
<input type="submit" className="border" />
|
||||
</form>
|
||||
}
|
||||
|
||||
if (open) return <div className="fixed top-0 left-0 w-full h-full bg-slate-500/50 flex justify-center items-center" onClick={e => setOpen(false)}>
|
||||
<div className="bg-white dark:bg-black border rounded-lg w-8/12 p-8 h-4/6 overflow-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-2xl border-b border-slate-500 mb-1">Settings</div>
|
||||
<div className="m-3"><label>Name: <input type="text" className="dark:text-black border" value={user.name} onChange={e => updateUser({name: e.target.value})} /></label></div>
|
||||
<div className="m-3"><label>Color: <input type="color" value={user.color} onChange={e => updateUser({color: e.target.value})} /></label></div>
|
||||
<div className="m-3"><label>Website: <input type="text" className="dark:text-black border" placeholder='http://example.com' value={user.website} onChange={e => updateUser({website: e.target.value})} /></label></div>
|
||||
<div className="m-3"><label>Theme: <select className="dark:text-black border" value={theme} onChange={event => setTheme(event.target.value)}>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="">System</option>
|
||||
</select></label></div>
|
||||
<div className="text-2xl mt-8 border-b border-slate-500 mb-2">Emoji</div>
|
||||
{emojis.map(emoji => <img src={BASE_URL+"/emoji/"+emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} className="w-8 h-8 inline-block m-1" />)}
|
||||
<div className="text-xl mt-8">Upload Emoji</div>
|
||||
<EmojiUploader setEmojis={setEmojis} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function Emoji({emoji}) {
|
||||
var [failed, setFailed] = useState(null);
|
||||
if (failed) return `:${emoji}:`;
|
||||
else return <img src={BASE_URL + "/emoji/" + emoji} alt={`:${emoji}:`} title={emoji} className="inline-block w-8 h-8" onError={() => setFailed(true)} />
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function processMessageContent(content) {
|
||||
if (!content) return;
|
||||
|
||||
// hyperlinks
|
||||
content = reactStringReplace(content, /(https?:\/\/\S+)/gi, link =>
|
||||
<a href={link} target="_blank" rel="noopener" style={{color: "revert", textDecoration: "revert"}}>{link}</a>
|
||||
)
|
||||
|
||||
// emoji
|
||||
content = reactStringReplace(content, /:([a-z0-9-_]+):/gi, emoji => <Emoji emoji={emoji} />)
|
||||
|
||||
// files
|
||||
content = reactStringReplace(content, /\[img:(\S+)\]/gi, x => {
|
||||
var url = BASE_URL + "/objects/" + x;
|
||||
return <a href={url} target="_blank" rel="noopener">
|
||||
<img src={url} className="max-h-32 inline-block align-top border" />
|
||||
</a>
|
||||
});
|
||||
content = reactStringReplace(content, /\[video:(\S+)\]/gi, x => {
|
||||
var url = BASE_URL + "/objects/" + x;
|
||||
return <video className='max-h-64 inline-block align-top border' controls>
|
||||
<source src={url} />
|
||||
</video>
|
||||
});
|
||||
content = reactStringReplace(content, /\[audio:(\S+)\]/gi, x => {
|
||||
var url = BASE_URL + "/objects/" + x;
|
||||
return <audio className='max-h-64 inline-block align-top' controls>
|
||||
<source src={url} />
|
||||
</audio>
|
||||
});
|
||||
content = reactStringReplace(content, /\[file:(\S+)\]/gi, x => {
|
||||
var url = BASE_URL + "/objects/" + x;
|
||||
var filename = x.split('/').pop() || x;
|
||||
return <a href={url} target="_blank" rel="noopener">{filename}</a>
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import io from "socket.io-client";
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
import { MousingLayer } from './MousingLayer';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { UserList } from './UserList';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { MessageList } from './MessageList';
|
||||
|
||||
export function Chat({ user, updateUser, theme, setTheme }) {
|
||||
|
||||
var [messages, setMessages] = useState([]);
|
||||
var [users, setUsers] = useState([]);
|
||||
var [socket, setSocket] = useState();
|
||||
var [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
var [progress, setProgress] = useState(null);
|
||||
var [mice, setMice] = useState({});
|
||||
var [unread, setUnread] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById("favicon").href = SERVER_BASE_URL + `/favicon.svg?num=${unread}`;
|
||||
}, [unread]);
|
||||
|
||||
useEffect(() => {
|
||||
var fn = () => {
|
||||
if (document.visibilityState === "visible") setUnread(0);
|
||||
};
|
||||
document.addEventListener("visibilitychange", fn);
|
||||
return () => document.removeEventListener("visibilitychange", fn);
|
||||
}, []);
|
||||
|
||||
|
||||
var loadMessages = useCallback(function loadMessages() {
|
||||
fetch(SERVER_BASE_URL + "/messages?secret=" + user.secret).then(res => res.json()).then(messages => {
|
||||
console.debug("messages", messages);
|
||||
setMessages(messages);
|
||||
});
|
||||
}, [setMessages, user.secret]);
|
||||
|
||||
useEffect(() => {
|
||||
var socket = io(SERVER_BASE_URL, {
|
||||
transports: ["websocket", "polling"]
|
||||
});
|
||||
setSocket(socket);
|
||||
global.socket = socket;
|
||||
socket.on("connect", () => {
|
||||
console.debug("socket connect, id: " + socket.id);
|
||||
socket.emit("user", user);
|
||||
});
|
||||
socket.on("disconnect", () => console.debug("socket disconnect"));
|
||||
/*{let emit = socket.emit; socket.emit = function() {
|
||||
emit.apply(socket, arguments);
|
||||
console.debug("send", ...arguments);
|
||||
}}
|
||||
socket.onAny((eventName, ...args) => {
|
||||
console.debug("receive", eventName, ...args)
|
||||
});*/
|
||||
socket.on("messages", setMessages);
|
||||
socket.on("message", message => {
|
||||
setMessages(messages => [...messages, message]);
|
||||
if (document.visibilityState === "hidden") setUnread(unread => ++unread);
|
||||
});
|
||||
socket.on("users", setUsers);
|
||||
window.onmousemove = event => {
|
||||
socket.emit("mouse", event.pageX / window.innerWidth, event.pageY / window.innerHeight);
|
||||
};
|
||||
socket.on("mouse", (x, y, socketid) => {
|
||||
setMice(mice => ({ ...mice, [socketid]: { x, y } }));
|
||||
});
|
||||
return () => socket.close();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
socket?.emit("user", user);
|
||||
}, [socket, user]);
|
||||
|
||||
useEffect(() => {
|
||||
var socketids = users.map(user => user.socketid);
|
||||
setMice(mice => {
|
||||
for (let mouseid in mice) if (!socketids.includes(mouseid)) delete mice[mouseid];
|
||||
return mice;
|
||||
});
|
||||
}, [users]);
|
||||
|
||||
var sendMessage = useCallback(message => socket.emit("message", message), [socket]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="Chat flex flex-col">
|
||||
<MessageList messages={messages} setMessages={setMessages} userSecret={user.secret} />
|
||||
<UserList users={users} updateUser={updateUser} user={user} socket={socket} />
|
||||
<ProgressBar progress={progress} />
|
||||
<ChatInput sendMessage={sendMessage} user={user} updateUser={updateUser} setSettingsModalOpen={setSettingsModalOpen} socket={socket} setProgress={setProgress} />
|
||||
<SettingsModal open={settingsModalOpen} setOpen={setSettingsModalOpen} user={user} updateUser={updateUser} theme={theme} setTheme={setTheme} />
|
||||
<MousingLayer mice={mice} users={users} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useRef, memo } from 'react';
|
||||
import axios from "axios";
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
import { EmojiPicker } from './EmojiPicker';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export var ChatInput = memo(function ChatInput({ sendMessage, user, setSettingsModalOpen, socket, setProgress }) {
|
||||
var [content, setContent] = useState("");
|
||||
var textinputref = useRef();
|
||||
|
||||
function appendToTextInput(str) {
|
||||
setContent(content => `${content} ${str} `);
|
||||
textinputref.current?.select();
|
||||
}
|
||||
global.appendToTextInput = appendToTextInput;
|
||||
|
||||
var [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
content = content.trim();
|
||||
if (!content) return;
|
||||
sendMessage({ content });
|
||||
setContent("");
|
||||
}
|
||||
async function uploadFiles(files) {
|
||||
console.debug("uploadFiles", files);
|
||||
var formdata = new FormData();
|
||||
for (let file of files) formdata.append(file.name, file);
|
||||
setProgress(0);
|
||||
var res = await axios.post(SERVER_BASE_URL + "/upload", formdata, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
},
|
||||
onUploadProgress: progressEvent => {
|
||||
console.debug("progressEvent", progressEvent);
|
||||
var progress = progressEvent.loaded / progressEvent.total;
|
||||
console.debug("upload progress", `${progress * 100}%`);
|
||||
setProgress(progress);
|
||||
}
|
||||
});
|
||||
setProgress(null);
|
||||
console.debug("upload res", res.data);
|
||||
var codes = res.data.map(({ name, code, type }) => {
|
||||
name = name.replaceAll(' ', '_');
|
||||
var t = type.toLowerCase().split('/')[0];
|
||||
t = t === "image" ? "img" : t === "video" ? "video" : t === "audio" ? "audio" : "file";
|
||||
return `[${t}:${code}/${name}]`;
|
||||
}).join(" ");
|
||||
appendToTextInput(codes);
|
||||
}
|
||||
return <div className='ChatInput'>
|
||||
<form className="flex flex-row" onSubmit={onSubmit}>
|
||||
<input
|
||||
ref={textinputref}
|
||||
className="p-4 flex-1 dark:bg-black"
|
||||
style={{ color: user.color }}
|
||||
type="text"
|
||||
onChange={event => {
|
||||
setContent(event.target.value);
|
||||
socket.emit("type");
|
||||
}}
|
||||
onPaste={event => {
|
||||
var files = event.clipboardData.files;
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
uploadFiles(files);
|
||||
}
|
||||
}}
|
||||
value={content}
|
||||
placeholder="type or paste files and press ENTER">
|
||||
</input>
|
||||
<input type="submit" className='w-14 h-14 cursor-pointer' value="➡️" />
|
||||
<button type="button" className='w-14 h-14' onClick={() => setShowEmojiPicker(!showEmojiPicker)}>🤔</button>
|
||||
<label htmlFor="file" className="w-14 h-14 text-center leading-[56px] cursor-pointer">📄</label>
|
||||
<input type="file" id="file" className='hidden' onChange={event => {
|
||||
uploadFiles(event.target.files);
|
||||
event.target.value = null;
|
||||
}} multiple></input>
|
||||
<button type="button" className='w-14 h-14' onClick={e => setSettingsModalOpen(true)}>⚙️</button>
|
||||
</form>
|
||||
<EmojiPicker open={showEmojiPicker} setOpen={setShowEmojiPicker} appendToTextInput={appendToTextInput} />
|
||||
</div>;
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
|
||||
export function Emoji({ emoji }) {
|
||||
var [failed, setFailed] = useState(null);
|
||||
if (failed) return `:${emoji}:`;
|
||||
else return <img src={SERVER_BASE_URL + "/emoji/" + emoji} alt={`:${emoji}:`} title={emoji} className="inline-block w-8 h-8" onError={() => setFailed(true)} />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export function EmojiPicker({ open, setOpen, appendToTextInput }) {
|
||||
var [emojis, setEmojis] = useState([]);
|
||||
var getEmojis = () => fetch(SERVER_BASE_URL + "/emojis").then(r => r.json());
|
||||
useEffect(() => { open && getEmojis().then(setEmojis); }, [open]);
|
||||
var onEmojiClick = event => {
|
||||
//setOpen(false);
|
||||
appendToTextInput(`:${event.target.dataset.emoji}:`);
|
||||
};
|
||||
if (open)
|
||||
return <div className='fixed top-0 left-0 w-full h-full' onClick={e => setOpen(false)}>
|
||||
<div className='w-[258px] h-64 rounded border fixed right-6 bottom-16 overflow-auto bg-white dark:bg-black p-2' onClick={e => e.stopPropagation()}>
|
||||
{emojis.map(emoji => <img src={SERVER_BASE_URL + "/emoji/" + emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} data-emoji={emoji} className="w-10 h-10 inline-block p-0.5 cursor-pointer border border-transparent hover:border-gray-600" onClick={onEmojiClick} />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
import {useState} from "react";
|
||||
|
||||
export default function InitPage({setUser}) {
|
||||
export function InitPage({updateUser}) {
|
||||
|
||||
var [name, setName] = useState(null);
|
||||
var [color, setColor] = useState(random_color());
|
||||
|
||||
function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
setUser({name, color});
|
||||
updateUser({name, color});
|
||||
}
|
||||
|
||||
return <div className="InitPage p-3">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { processMessageContent } from './util';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export function Message({ message }) {
|
||||
var [hover, setHover] = useState(false);
|
||||
|
||||
if (message.user) {
|
||||
var prefix = <b>{message.user?.website ? <a href={message.user.website} target="_blank" rel="noopener" className={message.user.website && "hover:underline"}>{message.user.name}</a> : message.user.name}: </b>;
|
||||
}
|
||||
var content = <span className={"content" + (message.user ? '' : " font-bold")} style={{ color: message.user?.color }} title={new Date(message.timestamp).toLocaleString()}>{processMessageContent(message.content)}</span>;
|
||||
|
||||
if (hover) {
|
||||
if (message.user?.you) var manageButtons = <><button>🗑</button><button>✏</button></>;
|
||||
var options = <span className="absolute top-0 right-0 mr-4 text-gray-500 text-xs italic">{manageButtons}<button onClick={() => global.appendToTextInput(`@${message.user?.name}:“${message.content}”>>`)}>↩️</button> - {new Date(message.timestamp).toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
return <li id={message._id} className="px-4 relative" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||
backgroundColor: hover ? "rgb(127 127 127 / 15%)" : undefined
|
||||
}}>{prefix} {content} {options}</li>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState, useCallback, memo } from 'react';
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
import { Message } from './Message';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export var MessageList = memo(function MessageList({ messages, setMessages, userSecret }) {
|
||||
var [atBottom, setAtBottom] = useState(true);
|
||||
var [atTop, setAtTop] = useState(false);
|
||||
|
||||
var onScroll = useCallback(event => {
|
||||
var { scrollTop, scrollHeight, clientHeight } = event.target;
|
||||
setAtBottom(scrollHeight - (scrollTop + clientHeight) < 32);
|
||||
setAtTop(scrollTop < 32);
|
||||
}, [setAtBottom, setAtTop]);
|
||||
|
||||
var loadOlderMessages = useCallback(async () => {
|
||||
var olderMessages = await fetch(SERVER_BASE_URL + "/messages?secret=" + userSecret + "&before=" + messages[0].timestamp).then(res => res.json());
|
||||
console.debug("loadOlderMessages", olderMessages);
|
||||
setMessages(messages => [...olderMessages, ...messages]);
|
||||
// why don't either of these work on ios (webkit) ?
|
||||
//document.getElementById(messages[0]._id).scrollIntoView();
|
||||
setTimeout(() => document.getElementById(olderMessages.at(-1)._id).scrollIntoView(), 0);
|
||||
}, [setMessages, messages, userSecret]);
|
||||
|
||||
var scrollToBottom = () => document.getElementById(messages.at(-1)?._id)?.scrollIntoView();
|
||||
useEffect(() => {
|
||||
if (atBottom) scrollToBottom();
|
||||
});
|
||||
var observer = new ResizeObserver(() => atBottom && scrollToBottom());
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("atTop", atTop);
|
||||
if (atTop) loadOlderMessages();
|
||||
}, [atTop, loadOlderMessages]);
|
||||
useEffect(() => {
|
||||
console.debug("atBottom", atBottom);
|
||||
if (atBottom) setMessages(messages => messages.slice(-100));
|
||||
}, [atBottom, setMessages]);
|
||||
|
||||
|
||||
|
||||
return <ul className="py-4 w-full flex-1 overflow-auto break-words" onScroll={onScroll} onLoad={e => observer.observe(e.target)}>
|
||||
{messages.map(message => <Message message={message} key={message._id} />)}
|
||||
</ul>;
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export function MousingLayer({ mice: miceData, users }) {
|
||||
var mice = [];
|
||||
for (let socketid in miceData) {
|
||||
let user = users.find(user => user.socketid === socketid);
|
||||
let mouse = miceData[socketid];
|
||||
mice.push(<div key={socketid} className="fixed" style={{
|
||||
color: user?.color || "gray",
|
||||
left: mouse.x * window.innerWidth,
|
||||
top: (mouse.y * window.innerHeight) - 4,
|
||||
}}><span style={{ fontFamily: "'Noto Sans Symbols 2'" }}>🮰</span><span className="relative top-2 text-xs">{user?.name || socketid}</span></div>);
|
||||
}
|
||||
return <div className="fixed top-0 left-0 w-full h-full pointer-events-none">
|
||||
{mice}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export function ProgressBar({ progress }) {
|
||||
if (progress !== null) return <div className="w-full h-1" style={{ backgroundColor: "red" }}>
|
||||
<div className="h-full" style={{ backgroundColor: "lime", width: `${progress * 100}%` }}></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState, memo } from 'react';
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export var SettingsModal = memo(function SettingsModal({ open, setOpen, user, updateUser, theme, setTheme }) {
|
||||
var [emojis, setEmojis] = useState([]);
|
||||
async function getEmojis() {
|
||||
var emojis = await (await fetch(SERVER_BASE_URL + "/emojis")).json();
|
||||
return emojis;
|
||||
}
|
||||
useEffect(function () {
|
||||
if (open) {
|
||||
getEmojis().then(setEmojis);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
||||
function EmojiUploader({ setEmojis }) {
|
||||
var [file, setFile] = useState(null);
|
||||
var [name, setName] = useState("");
|
||||
async function onSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!name || !file) return;
|
||||
var _file = file; setFile(null);
|
||||
var _name = name; setName("");
|
||||
var res = await fetch(SERVER_BASE_URL + "/emoji/" + _name + "?type=" + encodeURIComponent(_file.type), {
|
||||
method: "PUT",
|
||||
body: await _file.arrayBuffer()
|
||||
});
|
||||
if (res.status === 201) setEmojis(emojis => [...emojis, _name]);
|
||||
else alert(await res.text());
|
||||
}
|
||||
return <form onSubmit={onSubmit}>
|
||||
<input type="file" onChange={event => setFile(event.target.files[0])} />
|
||||
<input type="text" placeholder='Emoji name' className="dark:text-black border" value={name} onChange={event => setName(event.target.value.replace(/[^a-z0-9-_]/gi, ''))} />
|
||||
<input type="submit" className="border" />
|
||||
</form>;
|
||||
}
|
||||
|
||||
if (open) return <div className="fixed top-0 left-0 w-full h-full bg-slate-500/50 flex justify-center items-center" onClick={e => setOpen(false)}>
|
||||
<div className="bg-white dark:bg-black border rounded-lg w-8/12 p-8 h-4/6 overflow-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-2xl border-b border-slate-500 mb-1">Settings</div>
|
||||
<div className="m-3"><label>Name: <input type="text" className="dark:text-black border" value={user.name} onChange={e => updateUser({ name: e.target.value })} /></label></div>
|
||||
<div className="m-3"><label>Color: <input type="color" value={user.color} onChange={e => updateUser({ color: e.target.value })} /></label></div>
|
||||
<div className="m-3"><label>Website: <input type="text" className="dark:text-black border" placeholder='http://example.com' value={user.website} onChange={e => updateUser({ website: e.target.value })} /></label></div>
|
||||
<div className="m-3"><label>Theme: <select className="dark:text-black border" value={theme} onChange={event => setTheme(event.target.value)}>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="">System</option>
|
||||
</select></label></div>
|
||||
<div className="text-2xl mt-8 border-b border-slate-500 mb-2">Emoji</div>
|
||||
{emojis.map(emoji => <img src={SERVER_BASE_URL + "/emoji/" + emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} className="w-8 h-8 inline-block m-1" />)}
|
||||
<div className="text-xl mt-8">Upload Emoji</div>
|
||||
<EmojiUploader setEmojis={setEmojis} />
|
||||
<div className="text-2xl mt-8 border-b border-slate-500 mb-2">Report bug</div>
|
||||
<a style={{ color: "revert", textDecoration: "revert" }} href="https://gitea.moe/lamp/chat" target="_blank">https://gitea.moe/lamp/chat</a>
|
||||
</div>
|
||||
</div>;
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, memo } from 'react';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export var UserList = memo(function UserList({ users, updateUser, user: myUser, socket }) {
|
||||
function changeName() {
|
||||
var name = prompt("Change name", myUser.name);
|
||||
if (!name) return;
|
||||
updateUser({ name });
|
||||
}
|
||||
useEffect(() => {
|
||||
socket?.on("type", socketid => {
|
||||
var n = document.getElementById(socketid);
|
||||
n.style.bottom = "2px";
|
||||
setTimeout(() => n.style.bottom = "0px", 100);
|
||||
});
|
||||
}, [socket]);
|
||||
return <div className="fixed top-0 right-5 text-right max-w-xs">
|
||||
<b>{users.length}</b> online: {users.map(user => <div style={{ color: user.color }} key={user.socketid} id={user.socketid} className={"mr-1 inline-block relative" + (user.socketid === socket.id ? ' cursor-pointer ' : '')} onClick={user.socketid === socket.id ? changeName : undefined}>{user.name}</div>)}
|
||||
</div>;
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
html, body, #root, .App, .Chat {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content a {
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { App } from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import { Emoji } from './Emoji';
|
||||
import { SERVER_BASE_URL } from './App';
|
||||
|
||||
|
||||
|
||||
/*function reactStringReplace(pcs, re, fn, limit = 10) {
|
||||
if (!(pcs instanceof Array)) pcs = [pcs];
|
||||
return pcs.map(str => {
|
||||
if (typeof str != "string") return str;
|
||||
var split = str.split(re);
|
||||
for (var i = 1; i < split.length && ((i + 1) / 2) < limit; i += 2) {
|
||||
split[i] = fn(split[i]);
|
||||
}
|
||||
return split;
|
||||
}).flat();
|
||||
}*/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export function processMessageContent(content) {
|
||||
if (!content) return;
|
||||
if (content.length > 5000) {
|
||||
content = content.substring(0, 5000) + `[${content.length - 5000} chars truncated]`;
|
||||
}
|
||||
|
||||
// youtube
|
||||
content = reactStringReplace(content, /https?:\/\/(?:(?:www.)?youtube.com|youtu.be)\/(?:watch\?v=)?([a-zA-Z0-9-_]{11})/gi, id => <iframe className="inline-block align-top max-w-[560px] max-h-[315px] w-full h-full aspect-video" src={`https://www.youtube-nocookie.com/embed/${id}`} title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>);
|
||||
|
||||
// hyperlinks
|
||||
content = reactStringReplace(content, /(https?:\/\/\S+)/gi, link => <a href={link} target="_blank" rel="noopener" style={{ color: "revert", textDecoration: "revert" }}>{link}</a>
|
||||
);
|
||||
|
||||
// emoji
|
||||
content = reactStringReplace(content, /:([a-z0-9-_]+):/gi, emoji => <Emoji emoji={emoji} />);
|
||||
|
||||
// files
|
||||
content = reactStringReplace(content, /\[img:(\S+)\]/gi, x => {
|
||||
var url = SERVER_BASE_URL + "/objects/" + x;
|
||||
return <a href={url} target="_blank" rel="noopener">
|
||||
<img src={url} alt="" className="max-h-32 inline-block align-top border" />
|
||||
</a>;
|
||||
});
|
||||
content = reactStringReplace(content, /\[video:(\S+)\]/gi, x => {
|
||||
var url = SERVER_BASE_URL + "/objects/" + x;
|
||||
return <video className='max-h-64 inline-block align-top border' controls>
|
||||
<source src={url} />
|
||||
</video>;
|
||||
});
|
||||
content = reactStringReplace(content, /\[audio:(\S+)\]/gi, x => {
|
||||
var url = SERVER_BASE_URL + "/objects/" + x;
|
||||
return <audio className='max-h-64 inline-block align-top' controls>
|
||||
<source src={url} />
|
||||
</audio>;
|
||||
});
|
||||
content = reactStringReplace(content, /\[file:(\S+)\]/gi, x => {
|
||||
var url = SERVER_BASE_URL + "/objects/" + x;
|
||||
var filename = x.split('/').pop() || x;
|
||||
return <a href={url} target="_blank" rel="noopener">{filename}</a>;
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
unfinished/work in progress:
|
||||
|
||||
- message delete & edit
|
||||
|
||||
todo:
|
||||
|
||||
- better replies
|
||||
- mousing not in react?
|
||||
- fix scrollback issues
|
||||
+2
-213
@@ -1,213 +1,2 @@
|
||||
require("dotenv").config({path: __dirname+"/.env"});
|
||||
var MONGODB_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chatserver";
|
||||
var DATA_DIR = process.env.DATA_DIR || "data";
|
||||
|
||||
var express = require("express");
|
||||
var {MongoClient, ObjectId} = require("mongodb");
|
||||
var socketio = require("socket.io");
|
||||
var http = require("http");
|
||||
var busboy = require("busboy");
|
||||
var path = require("path");
|
||||
var crypto = require("crypto");
|
||||
var HashThrough = require("hash-through");
|
||||
var {to36} = require("1636");
|
||||
var fs = require("fs");
|
||||
var mime = require("mime");
|
||||
|
||||
var app = express();
|
||||
app.set("trust proxy", true);
|
||||
var server = http.createServer(app);
|
||||
var io = socketio(server, {
|
||||
cors: {origin: "*"},
|
||||
maxHttpBufferSize: 16e6
|
||||
});
|
||||
var dbclient = new MongoClient(MONGODB_URI);
|
||||
var db = dbclient.db();
|
||||
var messages = db.collection("messages");
|
||||
var emojis = db.collection("emojis");
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(req.ip, req.method, req.url, req.headers["user-agent"]);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header("Access-Control-Allow-Headers", "*");
|
||||
res.header("Access-Control-Allow-Methods", "*");
|
||||
next();
|
||||
});
|
||||
app.get("/messages", (req, res, next) => {
|
||||
var query = req.query.before ? {timestamp: {$lt: new Date(req.query.before)}} : {};
|
||||
messages.find(query).project({"file.data": 0, "user.ip": 0}).sort({timestamp: -1}).limit(100).toArray().then(messages => {
|
||||
res.send(messages.reverse());
|
||||
}).catch(e => next(e));
|
||||
});
|
||||
app.get("/file/:message_id/:filename", async (req, res, next) => {
|
||||
try {
|
||||
var doc = await messages.findOne({_id: new ObjectId(req.params.message_id)}, {file:1});
|
||||
if (!doc) return res.sendStatus(404);
|
||||
var file = doc.file;
|
||||
if (!file) return res.status(404).send("message does not have file");
|
||||
if (req.params.filename != file.name) return res.status(400).send("filename is not correct") //idk
|
||||
res.type(file.type);
|
||||
if (res.get("Content-Type").startsWith("text/html")) res.type("text/plain");
|
||||
res.header("Cache-Control", "max-age=31536000");
|
||||
res.send(file.data.buffer);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
app.post("/upload", (req, res, next) => {
|
||||
var boi = busboy({headers: req.headers});
|
||||
var codes = [];
|
||||
boi.on("file", (name, file, info) => {
|
||||
var resolve;
|
||||
codes.push(new Promise(r => resolve = r));
|
||||
var hash = HashThrough(() => crypto.createHash("sha1"));
|
||||
var tmpfilepath = path.join(DATA_DIR, "tmp", Math.random().toString());
|
||||
var write = fs.createWriteStream(tmpfilepath);
|
||||
file.pipe(hash).pipe(write);
|
||||
write.on("error", error => next(error));
|
||||
write.on("close", () => {
|
||||
var code = to36(hash.digest("hex"));
|
||||
var j = {name, code, type: info.mimeType};
|
||||
var targetfilepath = path.join(DATA_DIR, "objects", code);
|
||||
fs.exists(targetfilepath, exists => {
|
||||
if (exists) {
|
||||
resolve(j);
|
||||
fs.unlink(tmpfilepath, e=>e&&console.error(e.stack));
|
||||
}
|
||||
else fs.rename(tmpfilepath, targetfilepath, error => {
|
||||
if (error) console.error(error.stack);
|
||||
resolve(j);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
boi.on("close", () => {
|
||||
Promise.all(codes).then(codes => res.send(codes));
|
||||
});
|
||||
req.pipe(boi);
|
||||
});
|
||||
app.get("/objects/:code/:filename?", (req, res) => {
|
||||
//var type = req.query.type?.trim().toLowerCase().replace(/[^a-z0-9\/; =-]/g,'');
|
||||
//if (type?.startsWith("text/html")) type = type.replace("text/html", "text/plain");
|
||||
if (req.params.filename) {
|
||||
var type = mime.getType(req.params.filename);
|
||||
}
|
||||
res.header("Cache-Control", "max-age=31536000");
|
||||
res.sendFile(req.params.code, {
|
||||
root: path.join(DATA_DIR, "objects/"),
|
||||
headers: type ? {"Content-Type": type} : undefined
|
||||
});
|
||||
});
|
||||
app.get("/emoji/:emoji", (req, res, next) => {
|
||||
emojis.findOne({name: req.params.emoji}, function(err, emoji){
|
||||
if (err) return next(err);
|
||||
if (!emoji) return res.sendStatus(404);
|
||||
res.header("Cache-Control", "max-age=31536000");
|
||||
res.type(emoji.type);
|
||||
res.send(emoji.data.buffer);
|
||||
});
|
||||
});
|
||||
app.get("/emojis", (req, res, next) => {
|
||||
emojis.find({}, {name: 1}).toArray(function (err, emojis) {
|
||||
if (err) return next(err);
|
||||
emojis = emojis.map(e => e.name);
|
||||
shuffleArray(emojis);
|
||||
res.send(emojis);
|
||||
});
|
||||
});
|
||||
app.put("/emoji/:name", express.raw({limit: "1mb", type: ()=>true}), async (req, res, next) => {
|
||||
try {
|
||||
var emoji = {
|
||||
name: req.params.name,
|
||||
type: req.query.type,
|
||||
data: req.body
|
||||
};
|
||||
if (!emoji.name || !/^[a-z0-9_-]+$/i.test(emoji.name) || !["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"].includes(emoji.type)) return res.sendStatus(400);
|
||||
var total = await emojis.countDocuments();
|
||||
if (total >= 100) return res.status(507).send("too many emojis on the server");
|
||||
var exist = await emojis.countDocuments({name: emoji.name});
|
||||
if (exist > 0) return res.status(409).send("emoji already exists with that name");
|
||||
await emojis.insertOne(emoji);
|
||||
res.sendStatus(201);
|
||||
} catch (error) {next(error)}
|
||||
});
|
||||
app.use(express.static(require("path").join(__dirname, "../app/build/")));
|
||||
|
||||
|
||||
dbclient.connect().then(async () => {
|
||||
io.on("connection", async socket => {
|
||||
socket.ip = socket.handshake.headers["x-forwarded-for"]?.split(',')[0] || socket.handshake.address;
|
||||
console.debug("connection from", socket.ip, "socket id", socket.id);
|
||||
socket.on("user", user => {
|
||||
user = {
|
||||
name: user.name?.trim()?.substring(0,32) || "no name",
|
||||
color: user.color?.trim()?.substring(0,32),
|
||||
website: user.website?.trim()?.substring(0,1000),
|
||||
uuid: user.uuid?.substring(0,128) || Math.random().toString(),
|
||||
socketid: socket.id,
|
||||
ip: socket.ip
|
||||
};
|
||||
console.debug("user", user);
|
||||
socket.data.user = user;
|
||||
broadcastUsers();
|
||||
});
|
||||
socket.once("user", async user => {
|
||||
//await newMessage({color: "#00FF00", content:`${user.name} connected`});
|
||||
socket.on("disconnect", () => {
|
||||
//newMessage({color: "#FF0000", content: `${socket.data.user.name} disconnected`});
|
||||
broadcastUsers();
|
||||
});
|
||||
socket.on("message", m => newMessage({
|
||||
content: m.content?.substring(0,10240),
|
||||
user: Object.assign({}, socket.data.user),
|
||||
file: m.file ? {
|
||||
name: m.file.name?.substring(0,128),
|
||||
data: m.file.data,
|
||||
type: m.file.type?.substring(0,64)
|
||||
} : undefined
|
||||
}));
|
||||
var history = await messages.find().project({"file.data": 0, "user.ip": 0}).sort({timestamp: -1}).limit(100).toArray();
|
||||
history = history.reverse();
|
||||
socket.emit("messages", history);
|
||||
socket.on("type", () => {
|
||||
io.emit("type", socket.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
server.listen(8535);
|
||||
|
||||
async function newMessage(message) {
|
||||
message.timestamp = new Date();
|
||||
var {insertedId} = await messages.insertOne(message);
|
||||
message._id = insertedId.toString();
|
||||
console.debug("message", message);
|
||||
delete message.file?.data;
|
||||
delete message.user.ip;
|
||||
io.emit("message", message);
|
||||
}
|
||||
|
||||
async function broadcastUsers() {
|
||||
var users = await io.fetchSockets().then(sockets => sockets.filter(socket => socket.data.user).map(socket => {
|
||||
var user = Object.assign({}, socket.data.user);
|
||||
delete user.ip;
|
||||
return user;
|
||||
}));
|
||||
io.emit("users", users);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function shuffleArray(array) {
|
||||
for (var i = array.length - 1; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var temp = array[i];
|
||||
array[i] = array[j];
|
||||
array[j] = temp;
|
||||
}
|
||||
}
|
||||
import 'dotenv/config';
|
||||
import "./src/index.js";
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 135.46667 135.46667"
|
||||
version="1.1"
|
||||
id="svg12"
|
||||
inkscape:export-filename="bitmap.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
|
||||
sodipodi:docname="logo.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview14"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.71624579"
|
||||
inkscape:cx="155.67282"
|
||||
inkscape:cy="226.87742"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="991"
|
||||
inkscape:window-x="-9"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs9">
|
||||
<filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter1158"
|
||||
x="-0.2668752"
|
||||
y="-0.16879727"
|
||||
width="1.5337504"
|
||||
height="1.3375945">
|
||||
<feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
stdDeviation="5.2893724"
|
||||
id="feGaussianBlur1160" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<circle
|
||||
style="fill:#00ffff;stroke-width:0.216579"
|
||||
id="path372"
|
||||
cx="-67.73333"
|
||||
cy="67.73333"
|
||||
transform="scale(-1,1)"
|
||||
r="67.73333" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:134.795px;line-height:1.25;font-family:sans-serif;fill:#008080;fill-opacity:1;stroke:none;stroke-width:3.36987"
|
||||
x="0.060306985"
|
||||
y="113.65733"
|
||||
id="text378"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan376"
|
||||
style="fill:#008080;stroke-width:3.36987"
|
||||
x="0.060306985"
|
||||
y="113.65733">チ</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:110.067px;line-height:1.25;font-family:sans-serif;direction:rtl;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:2.752;stroke-opacity:1;filter:url(#filter1158);stroke-dasharray:none"
|
||||
x="140.86877"
|
||||
y="131.62408"
|
||||
id="text393-5"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan391-2"
|
||||
style="direction:rtl;fill:#000000;stroke-width:2.752;stroke:#000000;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
|
||||
x="140.86877"
|
||||
y="131.62408">{num}</tspan></text>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:110.067px;line-height:1.25;font-family:sans-serif;direction:rtl;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:2.75168"
|
||||
x="138.9102"
|
||||
y="130.72346"
|
||||
id="text393"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan391"
|
||||
style="direction:rtl;fill:#ff0000;stroke-width:2.75168"
|
||||
x="138.9102"
|
||||
y="130.72346">{num}</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
Generated
+2380
-2092
File diff suppressed because it is too large
Load Diff
+15
-12
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"1636": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"dotenv": "^16.0.2",
|
||||
"express": "^4.18.1",
|
||||
"hash-through": "^0.1.16",
|
||||
"mime": "^3.0.0",
|
||||
"mongodb": "^4.9.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"socket.io": "^4.5.2"
|
||||
}
|
||||
"dependencies": {
|
||||
"1636": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"hash-through": "^0.1.16",
|
||||
"mime": "^3.0.0",
|
||||
"mongodb": "^6.3.0",
|
||||
"mongoose": "^8.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"socket.io": "^4.7.2"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import express from "express";
|
||||
import {router} from "./router.js";
|
||||
|
||||
var app = express();
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(req.ip, req.method, req.url, req.headers["user-agent"]);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header("Access-Control-Allow-Headers", "*");
|
||||
res.header("Access-Control-Allow-Methods", "*");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DATA_DIR = process.env.DATA_DIR || "data";
|
||||
export const MIN_FREE_GB = process.env.MIN_FREE_GB ? Number(process.env.MIN_FREE_GB) : 10;
|
||||
@@ -0,0 +1,6 @@
|
||||
import mongoose from "mongoose";
|
||||
import server from "./server.js";
|
||||
import "./socketio.js";
|
||||
|
||||
await mongoose.connect(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chatserver");
|
||||
server.listen(process.env.PORT || 8535, process.env.ADDRESS);
|
||||
@@ -0,0 +1,21 @@
|
||||
import {Schema, model} from "mongoose";
|
||||
|
||||
var schema = new Schema({
|
||||
name: {
|
||||
type: String,
|
||||
unique: true,
|
||||
match: /^[a-z0-9_-]+$/i,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"],
|
||||
required: true
|
||||
},
|
||||
data: {
|
||||
type: Buffer,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
export default model("Emoji", schema);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Schema, model } from "mongoose";
|
||||
|
||||
var schema = new Schema({
|
||||
timestamp: Date,
|
||||
content: String,
|
||||
user: {
|
||||
name: String,
|
||||
color: String,
|
||||
website: String,
|
||||
uuid: String,
|
||||
secret: {type: String, select: false},
|
||||
ip: {type: String, select: false},
|
||||
agent: String
|
||||
}
|
||||
}, {
|
||||
strict: true,
|
||||
strictQuery: true
|
||||
});
|
||||
|
||||
|
||||
schema.static("getMessages", async function({query = {}, secret}) {
|
||||
var messages = await this.find(query).sort({timestamp: -1}).limit(100).select('+user.secret').lean().exec();
|
||||
for (var message of messages) {
|
||||
if (message.user) {
|
||||
if (secret && secret === message.user.secret) {
|
||||
message.user.you = true;
|
||||
}
|
||||
delete message.user.secret;
|
||||
}
|
||||
}
|
||||
return messages.reverse();
|
||||
});
|
||||
|
||||
|
||||
export default model("Message", schema);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import express from "express";
|
||||
import "express-async-errors";
|
||||
import busboy from "busboy";
|
||||
import HashThrough from "hash-through";
|
||||
import { to36 } from "1636";
|
||||
import { createHash } from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import mime from "mime";
|
||||
|
||||
import Message from "./models/Message.js";
|
||||
import Emoji from "./models/Emoji.js";
|
||||
import { DATA_DIR, MIN_FREE_GB } from "./constants.js";
|
||||
import { df } from "./util.js";
|
||||
|
||||
export var router = express.Router();
|
||||
|
||||
|
||||
|
||||
|
||||
router.get("/messages", (req, res, next) => {
|
||||
if (req.query.before) var beforeDate = new Date(req.query.before);
|
||||
if (beforeDate == "Invalid Date") return res.status(400).send("`before` param is invalid date");
|
||||
if (req.query.after) var afterDate = new Date(req.query.after);
|
||||
if (afterDate == "Invalid Date") return res.status(400).send("`after` param is invalid date");
|
||||
|
||||
if (beforeDate || afterDate) {
|
||||
var query = {timestamp:{}};
|
||||
if (beforeDate) query.timestamp.$lt = beforeDate;
|
||||
if (afterDate) query.timestamp.$gt = afterDate;
|
||||
}
|
||||
|
||||
Message.getMessages({query, secret: req.query.secret}).then(messages => res.send(messages)).catch(next);
|
||||
});
|
||||
|
||||
router.post("/upload", async (req, res, next) => {
|
||||
var free = await df(DATA_DIR);
|
||||
if (free < MIN_FREE_GB) {
|
||||
res.sendStatus(507);
|
||||
return;
|
||||
}
|
||||
var boi = busboy({headers: req.headers});
|
||||
var codes = [];
|
||||
boi.on("file", (name, file, info) => {
|
||||
var resolve;
|
||||
codes.push(new Promise(r => resolve = r));
|
||||
var hash = HashThrough(() => createHash("sha1"));
|
||||
var tmpfilepath = path.join(DATA_DIR, "tmp", Math.random().toString());
|
||||
var write = fs.createWriteStream(tmpfilepath);
|
||||
file.pipe(hash).pipe(write);
|
||||
write.on("error", error => next(error));
|
||||
write.on("close", () => {
|
||||
var code = to36(hash.digest("hex"));
|
||||
var j = {name, code, type: info.mimeType};
|
||||
var targetfilepath = path.join(DATA_DIR, "objects", code);
|
||||
fs.exists(targetfilepath, exists => {
|
||||
if (exists) {
|
||||
resolve(j);
|
||||
fs.unlink(tmpfilepath, e=>e&&console.error(e.stack));
|
||||
}
|
||||
else fs.rename(tmpfilepath, targetfilepath, error => {
|
||||
if (error) console.error(error.stack);
|
||||
resolve(j);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
boi.on("close", () => {
|
||||
Promise.all(codes).then(codes => res.send(codes));
|
||||
});
|
||||
req.pipe(boi);
|
||||
});
|
||||
|
||||
|
||||
router.get("/objects/:code/:filename?", (req, res) => {
|
||||
//var type = req.query.type?.trim().toLowerCase().replace(/[^a-z0-9\/; =-]/g,'');
|
||||
//if (type?.startsWith("text/html")) type = type.replace("text/html", "text/plain");
|
||||
if (req.params.filename) {
|
||||
var type = mime.getType(req.params.filename);
|
||||
}
|
||||
res.header("Cache-Control", "max-age=31536000");
|
||||
res.sendFile(req.params.code, {
|
||||
root: path.join(DATA_DIR, "objects/"),
|
||||
headers: type ? {"Content-Type": type} : undefined
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.get("/emoji/:emoji", (req, res, next) => {
|
||||
Emoji.findOne({name: req.params.emoji}).then(emoji => {
|
||||
if (!emoji) return res.sendStatus(404);
|
||||
res.header("Cache-Control", "max-age=31536000");
|
||||
res.type(emoji.type);
|
||||
res.send(emoji.data);
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
|
||||
router.get("/emojis", (req, res, next) => {
|
||||
Emoji.find({}, "name").then(emojis => {
|
||||
emojis = emojis.map(e => e.name);
|
||||
res.send(emojis);
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
|
||||
router.put("/emoji/:name", express.raw({limit: "1mb", type: ()=>true}), async (req, res) => {
|
||||
var free = await df(DATA_DIR);
|
||||
if (free < MIN_FREE_GB) {
|
||||
res.sendStatus(507);
|
||||
return;
|
||||
}
|
||||
var emoji = new Emoji({
|
||||
name: req.params.name,
|
||||
type: req.query.type,
|
||||
data: req.body
|
||||
});
|
||||
await emoji.save();
|
||||
res.sendStatus(201);
|
||||
});
|
||||
|
||||
|
||||
var favicon = fs.readFileSync(path.join(process.cwd(), "logo.svg"), "utf-8");
|
||||
router.get("/favicon.svg", (req, res) => {
|
||||
res.header("Cache-Control", "max-age=86400");
|
||||
res.type("svg").send(favicon.replaceAll("{num}", Number(req.query.num || 0) || ""));
|
||||
});
|
||||
|
||||
|
||||
router.use(express.static(path.join(process.cwd(), "../app/build/")));
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createServer } from "http";
|
||||
import app from "./app.js";
|
||||
|
||||
export default createServer(app);
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Server as SocketIOServer } from "socket.io";
|
||||
import { randomUUID } from "crypto";
|
||||
import server from "./server.js";
|
||||
import Message from "./models/Message.js";
|
||||
import { Quota } from "./util.js";
|
||||
|
||||
export var io = new SocketIOServer(server, {
|
||||
cors: {origin: "*"},
|
||||
maxHttpBufferSize: 100000,
|
||||
perMessageDeflate: true
|
||||
});
|
||||
|
||||
io.on("connection", socket => {
|
||||
socket.ip = socket.handshake.headers["x-forwarded-for"]?.split(',')[0] || socket.handshake.address;
|
||||
console.log("connect", socket.ip, socket.id);
|
||||
socket.quotas = {
|
||||
user: new Quota(1000, 1000*60*60),
|
||||
message: new Quota(30, 60000)
|
||||
};
|
||||
socket.on("disconnect", reason => {
|
||||
console.log("disconnect", socket.id, reason);
|
||||
for (var quota in socket.quotas) {
|
||||
socket.quotas[quota].destroy();
|
||||
}
|
||||
})
|
||||
socket.on("user", user => {
|
||||
if (!socket.quotas.user.spend()) return;
|
||||
if (typeof user != "object") return;
|
||||
user = {
|
||||
name: user.name?.toString().trim().substring(0,32),
|
||||
color: user.color?.toString().trim().substring(0,32),
|
||||
website: user.website?.toString().trim().substring(0,1000),
|
||||
uuid: user.uuid?.toString().substring(0,128) || "A"+randomUUID(),
|
||||
secret: user.secret?.toString().substring(0,128),
|
||||
socketid: socket.id,
|
||||
ip: socket.ip,
|
||||
agent: socket.handshake.headers["user-agent"]
|
||||
};
|
||||
console.debug("user", JSON.stringify(user));
|
||||
socket.data.user = user;
|
||||
broadcastUsers();
|
||||
});
|
||||
socket.once("user", async user => {
|
||||
if (typeof user != "object") return;
|
||||
//await newMessage({color: "#00FF00", content:`${user.name} connected`});
|
||||
socket.on("disconnect", () => {
|
||||
//newMessage({color: "#FF0000", content: `${socket.data.user.name} disconnected`});
|
||||
broadcastUsers();
|
||||
});
|
||||
socket.on("message", message => {
|
||||
if (!socket.quotas.message.spend()) return;
|
||||
if (typeof message != "object") return;
|
||||
newMessage({
|
||||
content: message.content?.toString().substring(0,10000),
|
||||
user: {...socket.data.user}
|
||||
}).catch(error => {
|
||||
console.error(socket.id, error.message);
|
||||
});
|
||||
});
|
||||
socket.on("type", () => {
|
||||
io.emit("type", socket.id);
|
||||
});
|
||||
socket.on("mouse", (x, y) => {
|
||||
if (typeof x != "number" || typeof y != "number") return;
|
||||
//socket.broadcast.emit("mouse", x, y, socket.id);
|
||||
// see own cursor (test)
|
||||
io.emit("mouse", x, y, socket.id);
|
||||
});
|
||||
Message.getMessages({secret: user.secret}).then(messages => {
|
||||
socket.emit("messages", messages);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function broadcastUsers() {
|
||||
var users = await io.fetchSockets().then(sockets => sockets.filter(socket => socket.data.user).map(socket => {
|
||||
var user = Object.assign({}, socket.data.user);
|
||||
delete user.ip;
|
||||
delete user.secret;
|
||||
return user;
|
||||
}));
|
||||
io.emit("users", users);
|
||||
}
|
||||
|
||||
async function newMessage(message) {
|
||||
message = new Message(message);
|
||||
message.timestamp = new Date();
|
||||
message = await message.save();
|
||||
message = message.toObject();
|
||||
console.debug("message", message);
|
||||
delete message.user.ip;
|
||||
delete message.user.secret;
|
||||
io.emit("message", message);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function shuffleArray(array) {
|
||||
for (var i = array.length - 1; i > 0; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
var temp = array[i];
|
||||
array[i] = array[j];
|
||||
array[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
export class Quota {
|
||||
constructor(limit, resetInterval) {
|
||||
this.limit = limit;
|
||||
this.count = limit;
|
||||
this.interval = setInterval(() => {
|
||||
this.count = this.limit;
|
||||
}, resetInterval);
|
||||
}
|
||||
destroy () {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
spend() {
|
||||
return --this.count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
var exec = promisify(execFile);
|
||||
var free, ldft;
|
||||
export async function df(path) {
|
||||
if (Date.now() - ldft < 1000*60) return free;
|
||||
ldft = Date.now();
|
||||
var {stdout} = await exec("df", ["--output=avail", "--block-size=1G", path]);
|
||||
return free = Number(stdout.toString().match(/\d+/)[0]);
|
||||
}
|
||||
Reference in New Issue
Block a user