Compare commits

...

47 Commits

Author SHA1 Message Date
lamp 3c6a549a5f sanitize socket event types 2025-05-16 01:27:27 -07:00
lamp 85d2189595 fix 2025-03-26 23:14:07 -07:00
lamp bb85b00b9c more limits 2025-03-26 23:09:53 -07:00
lamp 5df9a65ecf fix theme select 2025-03-12 16:25:07 -07:00
lamp cefad7dcdd enforce user data types 2025-03-12 16:13:38 -07:00
lamp 59aef22205 create readme 2024-12-30 00:38:29 -08:00
lamp 2f8f59c881 add link to repo 2024-12-30 00:27:34 -08:00
lamp bdd41b2db7 limit message size 2024-12-30 00:20:50 -08:00
lamp 80dca24a47 mongoose 2023-11-28 09:06:52 +00:00
lamp 3196d47569 refacotr server 2023-11-22 09:06:27 +00:00
lamp fc26fcf778 move all components to separate files 2023-11-22 05:48:08 +00:00
lamp 61420ddd4e fix 2023-11-22 05:36:06 +00:00
lamp 1e79ea9e91 fixed emoji form with memo... 2023-11-21 09:50:24 +00:00
lamp ec84354aea fix uuid, add secret 2023-11-21 07:27:00 +00:00
lamp 8bd1eeb0b7 a 2023-11-21 06:26:00 +00:00
lamp be12d92e86 fix youtube size 2022-10-24 14:39:34 -05:00
lamp 8168ab2ebd youtube embed 2022-10-23 22:57:43 -05:00
lamp c9893501b6 fix cursor font 2022-10-18 16:02:15 -05:00
lamp c1207c0e13 mice 2022-10-17 19:01:24 -05:00
lamp 466bfa4625 basic replying 2022-10-06 03:08:36 -05:00
lamp bbb0e79a91 nothing much 2022-10-05 21:42:54 -05:00
lamp b3bdaae496 include user agent string in user 2022-10-05 16:52:16 -05:00
lamp 9a2bc648c4 unread message indicator 2022-09-27 22:25:27 -05:00
lamp 79d12b4f51 fix bug 2022-09-25 00:31:51 -05:00
lamp 96c670e39f theme override 2022-09-25 00:21:16 -05:00
lamp 2ad4a05527 Better file uploading 2022-09-23 15:46:16 -05:00
lamp b6d8babe94 object storage and media embedding 2022-09-23 02:47:32 -05:00
lamp 330a78dfff somewhat usable history scroll 2022-09-18 17:20:29 -05:00
lamp f6df3901b1 crappy message history load 2022-09-18 16:52:51 -05:00
lamp 92d1faf3e5 emoji selector improvements 2022-09-15 00:03:10 -05:00
lamp 5c4d258384 typing indicator 2022-09-14 23:43:44 -05:00
lamp 210fca62d2 user settings 2022-09-13 01:37:09 -05:00
lamp e828746579 emoji picker 2022-09-13 01:09:23 -05:00
lamp 77a939a637 emoji put 2022-09-11 20:03:29 -05:00
lamp 88f2d453e6 custom emoji 2022-09-11 18:54:24 -05:00
lamp 4c8aedd339 clean deps 2022-09-10 23:24:02 -05:00
lamp 83f59b0ce3 use react-string-replace 2022-09-10 22:26:48 -05:00
lamp 87583281ca fix scroll 2022-09-10 01:07:50 -05:00
lamp 5b77a4bbc5 dotenv 2022-09-09 18:31:27 -05:00
lamp 60cd8d93df video embed 2022-09-09 15:19:31 -07:00
lamp fe200cbcd8 file uploading 2022-09-08 23:08:03 -07:00
lamp b8c00b057b set website 2022-09-08 01:38:56 -07:00
lamp e381d4b4c6 change name 2022-09-08 00:11:24 -07:00
lamp 4cd9caa516 color change 2022-09-07 23:39:16 -07:00
lamp c05382b3f1 fix show own connect msg 2022-09-05 22:52:16 -07:00
lamp 2fb9766be6 2022-09-05 22:11:56 -07:00
lamp 4a81376fcc userlist 2022-09-05 21:57:33 -07:00
36 changed files with 31349 additions and 30062 deletions
+2 -21
View File
@@ -1,23 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# production
build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
data
.env
+22
View File
@@ -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"
}
}
]
}
+27900 -28880
View File
File diff suppressed because it is too large Load Diff
+43 -43
View File
@@ -1,45 +1,45 @@
{
"name": "chat",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"html-escaper": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"socket.io-client": "^4.5.2",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.8",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8"
}
"name": "chat",
"private": true,
"dependencies": {
"axios": "^0.27.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1",
"react-string-replace": "^1.1.0",
"socket.io-client": "^4.5.2",
"uuid": "^9.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"react/jsx-no-target-blank": "off"
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.8",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8"
}
}
+17 -40
View File
@@ -1,43 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
<head>
<meta charset="utf-8" />
<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" />
<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>
<div id="root"></div>
</body>
</html>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "chat",
"name": "chat app",
"icons": [
{
"src": "favicon.ico",
+28 -8
View File
@@ -1,17 +1,37 @@
import Chat from "./Chat"
import InitPage from "./InitPage"
import { useState } from "react"
import {useState} from 'react';
import { InitPage } from './InitPage';
import { Chat } from './Chat';
export default function App() {
var [user, _setUser] = useState(localStorage.user && JSON.parse(localStorage.user));
export const SERVER_BASE_URL = "https://chat.owo69.me";
export function App() {
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 setTheme = theme => { _setTheme(theme); localStorage.theme = theme; }
return <div className="App h-full dark:bg-black dark:text-white">
{user ? <Chat user={user} /> : <InitPage setUser={setUser} />}
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} updateUser={updateUser} theme={theme} setTheme={setTheme} /> : <InitPage updateUser={updateUser} />}
</div>
</div>
}
}
+85 -89
View File
@@ -1,103 +1,99 @@
import {useEffect, useState} from 'react';
import { useEffect, useState, useCallback } from 'react';
import io from "socket.io-client";
import ScrollableFeed from 'react-scrollable-feed';
import {escape} from "html-escaper";
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';
function Chat({user}) {
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(() => {
var socket = io(window.location.hostname === "localhost" ? "http://localhost:8535" : undefined);
setSocket(socket);
socket.emit("user", user);
socket.on("messages", setMessages);
socket.on("message", message => {
console.debug("message", message);
setMessages(messages => [...messages, message]);
});
return () => socket.close();
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);
}, []);
function sendMessage(content) {
var message = {
content,
author: user.name,
color: user.color
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.emit("message", message);
}
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 h-full flex flex-col">
<MessageList messages={messages} />
<ChatInput sendMessage={sendMessage} />
<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>
)
);
}
function MessageList({messages}) {
//var endRef = useRef(null);
//function scrollToBottom() {
// endRef.current?.scrollIntoView({ behavior: "smooth" });
//}
//useEffect(scrollToBottom, [messages]);
return <ScrollableFeed>
<ul className="messages p-4 w-full flex-1 overflow-y-auto">
{messages.map(message =>
<li key={message._id} className="message" title={message.timestamp}>
{message.author ? <span className="author font-bold">{message.author}: </span> : ''}
<span className={"content" + (message.author ? '' : " font-bold")} style={{color: message.color}} dangerouslySetInnerHTML={{__html:processMessageContent(message.content)}}></span>
</li>
)}
{/*<div ref={endRef}></div>*/}
</ul>
</ScrollableFeed>
}
function ChatInput({sendMessage}) {
var [content, setContent] = useState("");
function onSubmit(event) {
event.preventDefault();
content = content.trim();
if (!content) return;
sendMessage(content);
setContent("");
}
return <form onSubmit={onSubmit}>
<input
className="p-4 w-full dark:bg-black"
type="text"
onChange={event => setContent(event.target.value)}
value={content}
placeholder="type and press ENTER">
</input>
</form>
}
export default Chat;
function processMessageContent(content) {
content = escape(content);
content = hyperlinker(content);
return content;
}
function hyperlinker(string) {
var links = string.match(/https?:\/\/\S+/gi);
if (links) for (let link of links) {
string = string.replace(link, `<a href="${encodeURI(link)}" target="_blank">${link}</a>`);
}
return string;
}
+87
View File
@@ -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>;
});
+8
View File
@@ -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)} />;
}
+23
View File
@@ -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>;
}
+12 -5
View File
@@ -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(null);
var [color, setColor] = useState(random_color());
function onSubmit(event) {
event.preventDefault();
setUser({name, color});
updateUser({name, color});
}
return <div className="InitPage p-3">
@@ -15,15 +15,22 @@ export default function InitPage({setUser}) {
<form onSubmit={onSubmit}>
<div className="m-1">welcome to chat app</div>
<div className="m-1">
<label for="name" className="mr-2">enter name:</label>
<label htmlFor="name" className="mr-2">enter name:</label>
<input type="text" id="name" className="dark:bg-black border p-1" value={name} onChange={event => setName(event.target.value)} />
</div>
<div className="m-1">
<label for="color" className="mr-2">choose color:</label>
<label htmlFor="color" className="mr-2">choose color:</label>
<input type="color" id="color" value={color} onChange={event => setColor(event.target.value)} />
</div>
<div className="m-1"><input type="submit" className="border p-1" value="ok go"></input></div>
</form>
</div>
</div>
}
function random_color() {
var letters = '0123456789ABCDEF'.split('');
var color = '#';
for (let i = 0; i < 6; i++) color += letters[Math.floor(Math.random() * 16)];
return color;
}
+25
View File
@@ -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>;
}
+49
View File
@@ -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>;
});
+20
View File
@@ -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>;
}
+10
View File
@@ -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>;
}
+63
View File
@@ -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>;
});
+24
View File
@@ -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>;
});
+3 -2
View File
@@ -2,12 +2,13 @@
@tailwind components;
@tailwind utilities;
html, body, #root, .App {
html, body, #root, .App, .Chat {
height: 100%;
min-height: 100%;
overflow: hidden;
}
a {
.content a {
color: revert;
text-decoration: revert;
}
+4 -4
View File
@@ -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>
);
+65
View File
@@ -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;
}
+1
View File
@@ -7,4 +7,5 @@ module.exports = {
extend: {},
},
plugins: [],
darkMode: "class"
}
+9
View File
@@ -0,0 +1,9 @@
unfinished/work in progress:
- message delete & edit
todo:
- better replies
- mousing not in react?
- fix scrollback issues
+2 -42
View File
@@ -1,42 +1,2 @@
var serve = require("serve-handler");
var {MongoClient} = require("mongodb");
var socketio = require("socket.io");
var http = require("http");
var server = http.createServer((req, res) => serve(req, res, {
public: "../app/build/"
}));
var io = socketio(server, {
cors: {origin: "*"}
});
var dbclient = new MongoClient(process.env.MONGODB_URL || "mongodb://127.0.0.1:27017/chatserver");
dbclient.connect().then(async () => {
var db = dbclient.db();
var col = db.collection("messages");
io.on("connection", async socket => {
console.log("connection from", socket.handshake?.address);
socket.once("user", async user => {
socket.user = user;
newMessage({color: "#00FF00", content:`${user.name} connected`});
socket.on("disconnect", () => {
newMessage({color: "#FF0000", content: `${socket.user.name} disconnected`});
});
socket.on("message", newMessage);
var history = await col.find().sort({timestamp: -1}).limit(100).toArray();
history = history.reverse();
socket.emit("messages", history);
});
});
server.listen(8535);
async function newMessage({author, content, color}) {
var message = {author, content, color, timestamp: new Date()};
var {insertedId} = await col.insertOne(message);
message._id = insertedId;
console.debug("newMessage", JSON.stringify(message));
io.emit("message", message);
return message;
}
});
import 'dotenv/config';
import "./src/index.js";
+100
View File
@@ -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

+2380 -921
View File
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -1,7 +1,17 @@
{
"dependencies": {
"mongodb": "^4.9.1",
"serve-handler": "^6.1.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"
}
+21
View File
@@ -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;
+2
View File
@@ -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;
+6
View File
@@ -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);
+21
View File
@@ -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);
+36
View File
@@ -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);
+130
View File
@@ -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/")));
+4
View File
@@ -0,0 +1,4 @@
import { createServer } from "http";
import app from "./app.js";
export default createServer(app);
+94
View File
@@ -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);
}
+36
View File
@@ -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]);
}