Rig up chat to be suitable for a Doge election. (Hopefully.) (#692)
* Integrate chat from upstream Substantially borrowed from upstream ref: 13a208ee88e55 (before they started editing generated artefacts instead of source). Integrated, including: - Remove previously removed features: emoji, hats, and name colors - Compensate for lack of unified root template - Add React build process to Dockerfile and `bootstrap/init.sh` - Preliminary integration of chat websocket workers For testing, modify `supervisord.conf.dev` to put chat on port 80 and the site service on some other port. Then visit: http://localhost/chat Still to do: - Access control for specific small-groups (and admins probably): Set the values somewhere (site_settings.json? Redis?) and use for authorization in `chat_is_allowed`. - Proxying only /chat to the websocket workers - Chat persistance across restarts: either Redis devops or to DB * Add nginx server to do appropriate redirection. * Add necessary columns to User. * Wire up chat permissions. * Reload chat on source change. * Add a better structure for slash commands and add/remove functionality. * Stop putting up previews of slash commands. * We require more whitespace. * Strip DMs out entirely, I currently do not want to deal with them. * Change "Users Online" to just "Users". * Clean up a little more DM detritus. * Save chat history in database. * Remove unnecessary hefty query to the DB. * Clean up optimistic messages. * Initial implementation of notification icon. * Update readme a little bit. * Fix notification highlight (mostly). * Remove chat version number that will never be updated. * Fix: Errors on logged-out users. * Add function to nuke the chat state. * Update DB. * Add a dedicated deployable docker image. * Fix: init_build.sh execute bit not set. * Whoops, screwed up the abort() call. * Relax chat rate limiter. * Remove a somewhat silly comment. * Remove an unnecessary g.db.add(). --------- Co-authored-by: TLSM <duolsm@outlook.com>
This commit is contained in:
parent
7032d0680d
commit
310c6c4424
62 changed files with 3018 additions and 435 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -21,3 +21,11 @@ env
|
||||||
|
|
||||||
# Diagnostic output
|
# Diagnostic output
|
||||||
output.svg
|
output.svg
|
||||||
|
|
||||||
|
# Chat environment
|
||||||
|
chat/node_modules
|
||||||
|
chat/build
|
||||||
|
|
||||||
|
# Chat artefacts
|
||||||
|
files/assets/css/chat_done.css
|
||||||
|
files/assets/js/chat_done.js
|
||||||
|
|
49
Dockerfile
49
Dockerfile
|
@ -23,8 +23,23 @@ CMD [ "bootstrap/init.sh" ]
|
||||||
|
|
||||||
|
|
||||||
###################################################################
|
###################################################################
|
||||||
# Release container
|
# Environment capable of building React files
|
||||||
FROM base AS release
|
FROM base AS build
|
||||||
|
|
||||||
|
# Chat compilation framework
|
||||||
|
ENV NODE_VERSION=16.13.0
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||||
|
ENV NVM_DIRECTORY=/root/.nvm
|
||||||
|
RUN . "$NVM_DIRECTORY/nvm.sh" && nvm install ${NODE_VERSION}
|
||||||
|
RUN . "$NVM_DIRECTORY/nvm.sh" && nvm use v${NODE_VERSION}
|
||||||
|
RUN . "$NVM_DIRECTORY/nvm.sh" && nvm alias default v${NODE_VERSION}
|
||||||
|
ENV PATH="/root/.nvm/versions/node/v${NODE_VERSION}/bin/:${PATH}"
|
||||||
|
RUN npm i -g yarn
|
||||||
|
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
# Release-mimicking environment
|
||||||
|
FROM build AS release
|
||||||
|
|
||||||
RUN poetry install --without dev
|
RUN poetry install --without dev
|
||||||
|
|
||||||
|
@ -32,8 +47,8 @@ COPY bootstrap/supervisord.conf.release /etc/supervisord.conf
|
||||||
|
|
||||||
|
|
||||||
###################################################################
|
###################################################################
|
||||||
# Dev container
|
# Dev environment
|
||||||
FROM release AS dev
|
FROM build AS dev
|
||||||
|
|
||||||
RUN poetry install --with dev
|
RUN poetry install --with dev
|
||||||
|
|
||||||
|
@ -53,3 +68,29 @@ CMD sleep infinity
|
||||||
|
|
||||||
# Turn off the rate limiter
|
# Turn off the rate limiter
|
||||||
ENV DBG_LIMITER_DISABLED=true
|
ENV DBG_LIMITER_DISABLED=true
|
||||||
|
|
||||||
|
|
||||||
|
###################################################################
|
||||||
|
# Deployable standalone image
|
||||||
|
|
||||||
|
# First, use the `build` container to actually build stuff
|
||||||
|
FROM build AS build_built
|
||||||
|
COPY . /service
|
||||||
|
RUN bootstrap/init_build.sh
|
||||||
|
|
||||||
|
# Now assemble our final container
|
||||||
|
# Gotta start from base again so we don't pick up the Node framework
|
||||||
|
FROM base AS deploy
|
||||||
|
|
||||||
|
RUN poetry install --without dev
|
||||||
|
COPY bootstrap/supervisord.conf.release /etc/supervisord.conf
|
||||||
|
|
||||||
|
# All the base files
|
||||||
|
COPY . /service
|
||||||
|
|
||||||
|
# Our built React files
|
||||||
|
COPY --from=build_built /service/files/assets/css/chat_done.css files/assets/css/chat_done.css
|
||||||
|
COPY --from=build_built /service/files/assets/js/chat_done.js files/assets/js/chat_done.js
|
||||||
|
|
||||||
|
# Flag telling us not to try rebuilding React
|
||||||
|
RUN touch prebuilt.flag
|
||||||
|
|
|
@ -4,4 +4,8 @@ set -euxo pipefail
|
||||||
python3 -m flask db upgrade # this does not actually return error codes properly!
|
python3 -m flask db upgrade # this does not actually return error codes properly!
|
||||||
python3 -m flask cron_setup
|
python3 -m flask cron_setup
|
||||||
|
|
||||||
|
if [[ ! -f prebuilt.flag ]]; then
|
||||||
|
./bootstrap/init_build.sh
|
||||||
|
fi
|
||||||
|
|
||||||
/usr/local/bin/supervisord -c /etc/supervisord.conf
|
/usr/local/bin/supervisord -c /etc/supervisord.conf
|
||||||
|
|
7
bootstrap/init_build.sh
Executable file
7
bootstrap/init_build.sh
Executable file
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
cd ./chat
|
||||||
|
yarn install
|
||||||
|
yarn chat
|
||||||
|
cd ..
|
29
bootstrap/nginx_dev.conf
Normal file
29
bootstrap/nginx_dev.conf
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://site:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /chat {
|
||||||
|
proxy_pass http://site:5001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /socket.io {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_pass http://site:5001/socket.io;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,14 @@ stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:chat]
|
||||||
|
directory=/service
|
||||||
|
command=sh -c 'gunicorn files.__main__:app load_chat -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 -b 0.0.0.0:5001 --reload'
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:cron]
|
[program:cron]
|
||||||
directory=/service
|
directory=/service
|
||||||
command=sh -c 'python3 -m flask cron'
|
command=sh -c 'python3 -m flask cron'
|
||||||
|
|
|
@ -18,6 +18,14 @@ stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:chat]
|
||||||
|
directory=/service
|
||||||
|
command=sh -c 'gunicorn files.__main__:app load_chat -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 -b 0.0.0.0:5001'
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:cron]
|
[program:cron]
|
||||||
directory=/service
|
directory=/service
|
||||||
command=sh -c 'python3 -m flask cron'
|
command=sh -c 'python3 -m flask cron'
|
||||||
|
|
4
chat/.env.template
Normal file
4
chat/.env.template
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
FEATURES_ACTIVITY=false
|
||||||
|
DEBUG=false
|
||||||
|
NODE_ENV="production"
|
||||||
|
APPROXIMATE_CHARACTER_WIDTH = 8
|
20
chat/build.js
Normal file
20
chat/build.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
require('dotenv').config()
|
||||||
|
const package = require("./package.json");
|
||||||
|
const path = require("path");
|
||||||
|
const { build } = require("esbuild");
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
entryPoints: ["./src/index.tsx"],
|
||||||
|
outfile: path.resolve(__dirname, "../files/assets/js/chat_done.js"),
|
||||||
|
bundle: true,
|
||||||
|
minify: process.env.NODE_ENV === "production",
|
||||||
|
define: {
|
||||||
|
"process.env.VERSION": `"${package.version}"`,
|
||||||
|
"process.env.NODE_ENV": `"${process.env.NODE_ENV}"`,
|
||||||
|
"process.env.DEBUG": process.env.DEBUG,
|
||||||
|
"process.env.FEATURES_ACTIVITY": process.env.FEATURES_ACTIVITY,
|
||||||
|
"process.env.APPROXIMATE_CHARACTER_WIDTH": process.env.APPROXIMATE_CHARACTER_WIDTH,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
build(options).catch(() => process.exit(1));
|
13
chat/global.d.ts
vendored
Normal file
13
chat/global.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
declare const process: {
|
||||||
|
env: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare interface IChatMessage {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
text: string;
|
||||||
|
text_html: string;
|
||||||
|
time: number;
|
||||||
|
quotes: null | string;
|
||||||
|
}
|
40
chat/package.json
Normal file
40
chat/package.json
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "chat",
|
||||||
|
"version": "0.1.27",
|
||||||
|
"main": "./src/index.tsx",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/humanize-duration": "^3.27.1",
|
||||||
|
"@types/lodash.clonedeep": "^4.5.7",
|
||||||
|
"@types/lodash.debounce": "^4.0.7",
|
||||||
|
"@types/lodash.throttle": "^4.1.7",
|
||||||
|
"@types/react": "^18.0.21",
|
||||||
|
"@types/react-dom": "^18.0.7",
|
||||||
|
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||||
|
"@types/react-window": "^1.8.5",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"esbuild": "^0.15.11",
|
||||||
|
"humanize-duration": "^3.27.3",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dnd": "^16.0.1",
|
||||||
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-virtualized-auto-sizer": "^1.0.7",
|
||||||
|
"react-window": "^1.8.8",
|
||||||
|
"run-when-changed": "^2.1.0",
|
||||||
|
"socket.io-client": "^4.5.3",
|
||||||
|
"typescript": "^4.8.4",
|
||||||
|
"weak-key": "^1.0.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"chat": "yarn check && yarn build && yarn css:move",
|
||||||
|
"chat:watch": "run-when-changed --watch \"**/*.*\" --exec \"yarn chat\"",
|
||||||
|
"check": "tsc",
|
||||||
|
"build": "node ./build",
|
||||||
|
"css:move": "mv ../files/assets/js/chat_done.css ../files/assets/css/chat_done.css"
|
||||||
|
}
|
||||||
|
}
|
138
chat/src/App.css
Normal file
138
chat/src/App.css
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: -webkit-fill-available;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: calc(var(--vh, 1vh) * 100);
|
||||||
|
overflow: hidden;
|
||||||
|
/* mobile viewport bug fix */
|
||||||
|
min-height: -webkit-fill-available;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App {
|
||||||
|
position: fixed;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-heading {
|
||||||
|
flex-basis: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-heading small {
|
||||||
|
opacity: 0.2;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-side {
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
background: var(--gray-500);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-content {
|
||||||
|
position: relative;
|
||||||
|
flex: 3;
|
||||||
|
height: 62vh;
|
||||||
|
height: calc(var(--vh, 1vh) * 72);
|
||||||
|
max-height: 1000px;
|
||||||
|
overflow: auto;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-content::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-drawer {
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
background: var(--background);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.App-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-bottom-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-bottom {
|
||||||
|
flex: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-bottom-dummy {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-bottom-extra {
|
||||||
|
padding: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On mobile, hide the sidebar and make the input full-width. */
|
||||||
|
@media screen and (max-width: 1100px) {
|
||||||
|
.App-wrapper {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-heading {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-side {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-bottom-dummy {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-bottom-wrapper {
|
||||||
|
padding-right: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-content__reduced {
|
||||||
|
height: calc(var(--vh, 1vh) * 55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lite-youtube {
|
||||||
|
min-width: min(80vw, 500px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:focus {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
140
chat/src/App.tsx
Normal file
140
chat/src/App.tsx
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import cx from "classnames";
|
||||||
|
import throttle from "lodash.throttle";
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { DndProvider, useDrop } from "react-dnd";
|
||||||
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
|
import "./App.css";
|
||||||
|
import {
|
||||||
|
ChatHeading,
|
||||||
|
ChatMessageList,
|
||||||
|
QuotedMessage,
|
||||||
|
UserInput,
|
||||||
|
UserList,
|
||||||
|
UsersTyping,
|
||||||
|
} from "./features";
|
||||||
|
import { ChatProvider, DrawerProvider, useChat, useDrawer } from "./hooks";
|
||||||
|
|
||||||
|
const SCROLL_CANCEL_THRESHOLD = 500;
|
||||||
|
const WINDOW_RESIZE_THROTTLE_WAIT = 250;
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<DrawerProvider>
|
||||||
|
<ChatProvider>
|
||||||
|
<AppInner />
|
||||||
|
</ChatProvider>
|
||||||
|
</DrawerProvider>
|
||||||
|
</DndProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const [_, dropRef] = useDrop({
|
||||||
|
accept: "drawer",
|
||||||
|
});
|
||||||
|
const { open, config } = useDrawer();
|
||||||
|
const contentWrapper = useRef<HTMLDivElement>(null);
|
||||||
|
const initiallyScrolledDown = useRef(false);
|
||||||
|
const { messages, quote } = useChat();
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const toggleFocus = useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setFocused(prev => !prev);
|
||||||
|
}, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// See: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
|
||||||
|
useEffect(() => {
|
||||||
|
const updateViewportHeightUnit = () => {
|
||||||
|
const vh = window.innerHeight * 0.01;
|
||||||
|
document.documentElement.style.setProperty("--vh", `${vh}px`);
|
||||||
|
};
|
||||||
|
const throttledResizeHandler = throttle(
|
||||||
|
updateViewportHeightUnit,
|
||||||
|
WINDOW_RESIZE_THROTTLE_WAIT
|
||||||
|
);
|
||||||
|
|
||||||
|
throttledResizeHandler();
|
||||||
|
|
||||||
|
window.addEventListener("resize", throttledResizeHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", throttledResizeHandler);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
if (initiallyScrolledDown.current) {
|
||||||
|
/* We only want to scroll back down on a new message
|
||||||
|
if the user is not scrolled up looking at previous messages. */
|
||||||
|
const scrollableDistance =
|
||||||
|
contentWrapper.current.scrollHeight -
|
||||||
|
contentWrapper.current.clientHeight;
|
||||||
|
const scrolledDistance = contentWrapper.current.scrollTop;
|
||||||
|
const hasScrolledEnough =
|
||||||
|
scrollableDistance - scrolledDistance >= SCROLL_CANCEL_THRESHOLD;
|
||||||
|
|
||||||
|
if (hasScrolledEnough) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always scroll to the bottom on first load.
|
||||||
|
initiallyScrolledDown.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentWrapper.current.scrollTop = contentWrapper.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
// Scroll to the bottom after any drawer closes.
|
||||||
|
contentWrapper.current.scrollTop = contentWrapper.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="App" ref={dropRef}>
|
||||||
|
<div className="App-wrapper">
|
||||||
|
<div className="App-heading">
|
||||||
|
<ChatHeading />
|
||||||
|
</div>
|
||||||
|
<div className="App-center">
|
||||||
|
<div
|
||||||
|
className={cx("App-content", {
|
||||||
|
"App-content__reduced": quote || focused,
|
||||||
|
})}
|
||||||
|
ref={contentWrapper}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<div className="App-drawer">{config.content}</div>
|
||||||
|
) : (
|
||||||
|
<ChatMessageList />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="App-side">
|
||||||
|
<UserList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="App-bottom-wrapper">
|
||||||
|
<div className="App-bottom">
|
||||||
|
{quote && (
|
||||||
|
<div className="App-bottom-extra">
|
||||||
|
<QuotedMessage />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<UserInput
|
||||||
|
large={focused}
|
||||||
|
onFocus={toggleFocus}
|
||||||
|
onBlur={toggleFocus}
|
||||||
|
/>
|
||||||
|
<UsersTyping />
|
||||||
|
</div>
|
||||||
|
<div className="App-bottom-dummy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
chat/src/drawers/BaseDrawer.css
Normal file
5
chat/src/drawers/BaseDrawer.css
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.BaseDrawer {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
10
chat/src/drawers/BaseDrawer.tsx
Normal file
10
chat/src/drawers/BaseDrawer.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React, { PropsWithChildren, useEffect } from "react";
|
||||||
|
import "./BaseDrawer.css";
|
||||||
|
|
||||||
|
interface Props extends PropsWithChildren {
|
||||||
|
onClose?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseDrawer({ onClose, children }: Props) {
|
||||||
|
return <div className="BaseDrawer">{children}</div>;
|
||||||
|
}
|
1
chat/src/drawers/index.ts
Normal file
1
chat/src/drawers/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./BaseDrawer"
|
35
chat/src/features/chat/ActivityList.css
Normal file
35
chat/src/features/chat/ActivityList.css
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
.ActivityList {
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActivityList h4 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActivityList h4 hr {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActivityList-activity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActivityList-activity-icon {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActivityList-activity {
|
||||||
|
transition: background 0.4s ease-in-out;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActivityList-activity:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background: #ffffff05;
|
||||||
|
}
|
69
chat/src/features/chat/ActivityList.tsx
Normal file
69
chat/src/features/chat/ActivityList.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import React from "react";
|
||||||
|
import cx from 'classnames'
|
||||||
|
import { useDrawer } from "../../hooks";
|
||||||
|
import "./ActivityList.css";
|
||||||
|
|
||||||
|
const ACTIVITIES = [
|
||||||
|
{
|
||||||
|
game: "Poker",
|
||||||
|
description: "Know when to hold 'em.",
|
||||||
|
icon: "fas fa-cards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Roulette",
|
||||||
|
description: "Table go brrrr.",
|
||||||
|
icon: "fas fa-circle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Slots",
|
||||||
|
description: "Is today your lucky day?",
|
||||||
|
icon: "fas fa-dollar-sign",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Blackjack",
|
||||||
|
description: "Twenty one ways to change your life.",
|
||||||
|
icon: "fas fa-cards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Racing",
|
||||||
|
description: "Look at 'em go.",
|
||||||
|
icon: "fas fa-cards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Crossing",
|
||||||
|
description: "A simple life.",
|
||||||
|
icon: "fas fa-cards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: "Lottershe",
|
||||||
|
description: "Can't win if you don't play.",
|
||||||
|
icon: "fas fa-ticket",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ActivityList() {
|
||||||
|
const { toggle } = useDrawer();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ActivityList">
|
||||||
|
<h4>
|
||||||
|
<hr />
|
||||||
|
<span>Activities</span>
|
||||||
|
</h4>
|
||||||
|
<section>
|
||||||
|
{ACTIVITIES.map(({ game, description, icon }) => (
|
||||||
|
<div key={game} role="button" onClick={toggle}>
|
||||||
|
<div className="ActivityList-activity">
|
||||||
|
<div className="ActivityList-activity">
|
||||||
|
<i className={cx("ActivityList-activity-icon", icon)} />
|
||||||
|
<h5>{game}<br /><small>{description}</small></h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small><i className="far fa-user fa-sm" /> 0</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
10
chat/src/features/chat/ChatHeading.css
Normal file
10
chat/src/features/chat/ChatHeading.css
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.ChatHeading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatHeading i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
42
chat/src/features/chat/ChatHeading.tsx
Normal file
42
chat/src/features/chat/ChatHeading.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { useChat, useDrawer } from "../../hooks";
|
||||||
|
import { UserList } from "./UserList";
|
||||||
|
import "./ChatHeading.css";
|
||||||
|
|
||||||
|
export function ChatHeading() {
|
||||||
|
const { open, hide, reveal } = useDrawer();
|
||||||
|
const { online } = useChat();
|
||||||
|
const handleToggleUserListDrawer = useCallback(() => {
|
||||||
|
if (open) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
reveal({
|
||||||
|
title: "Users in chat",
|
||||||
|
content: <UserList fluid={true} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ChatHeading">
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
{open ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleToggleUserListDrawer}
|
||||||
|
>Close</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i
|
||||||
|
role="button"
|
||||||
|
className="far fa-user"
|
||||||
|
onClick={handleToggleUserListDrawer}
|
||||||
|
/>
|
||||||
|
<em>{online.length} users</em>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
125
chat/src/features/chat/ChatMessage.css
Normal file
125
chat/src/features/chat/ChatMessage.css
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
.ChatMessage {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage__isDm {
|
||||||
|
background: var(--gray-800);
|
||||||
|
border-top: 1px dashed var(--primary);
|
||||||
|
border-bottom: 1px dashed var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage__isOptimistic {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage .btn {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-timestamp {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-left: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-content {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-button {
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-button i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-button__confirmed {
|
||||||
|
color: red !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-quoted-link {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-actions-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-actions-button button {
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-actions-button button i {
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-actions {
|
||||||
|
z-index: 1;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: rgba(20, 20, 20, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-right: 3rem;
|
||||||
|
animation: fading-in 0.3s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessage-actions button {
|
||||||
|
font-size: 10px;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List */
|
||||||
|
.ChatMessageList {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessageList-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ChatMessageList-group:nth-child(even) {
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
294
chat/src/features/chat/ChatMessage.tsx
Normal file
294
chat/src/features/chat/ChatMessage.tsx
Normal file
|
@ -0,0 +1,294 @@
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import cx from "classnames";
|
||||||
|
import key from "weak-key";
|
||||||
|
import humanizeDuration from "humanize-duration";
|
||||||
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
import {
|
||||||
|
DIRECT_MESSAGE_ID,
|
||||||
|
OPTIMISTIC_MESSAGE_ID,
|
||||||
|
useChat,
|
||||||
|
useRootContext,
|
||||||
|
} from "../../hooks";
|
||||||
|
import { QuotedMessageLink } from "./QuotedMessageLink";
|
||||||
|
import "./ChatMessage.css";
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: IChatMessage;
|
||||||
|
timestampUpdates: number;
|
||||||
|
showUser?: boolean;
|
||||||
|
actionsOpen: boolean;
|
||||||
|
onToggleActions(messageId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMESTAMP_UPDATE_INTERVAL = 20000;
|
||||||
|
|
||||||
|
export function ChatMessage({
|
||||||
|
message,
|
||||||
|
showUser = true,
|
||||||
|
timestampUpdates,
|
||||||
|
actionsOpen,
|
||||||
|
onToggleActions,
|
||||||
|
}: ChatMessageProps) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
avatar,
|
||||||
|
username,
|
||||||
|
text,
|
||||||
|
text_html,
|
||||||
|
time,
|
||||||
|
quotes,
|
||||||
|
} = message;
|
||||||
|
const {
|
||||||
|
id: userId,
|
||||||
|
username: userUsername,
|
||||||
|
admin,
|
||||||
|
themeColor,
|
||||||
|
} = useRootContext();
|
||||||
|
const {
|
||||||
|
messageLookup,
|
||||||
|
quote,
|
||||||
|
deleteMessage,
|
||||||
|
quoteMessage,
|
||||||
|
} = useChat();
|
||||||
|
const [confirmedDelete, setConfirmedDelete] = useState(false);
|
||||||
|
const quotedMessage = messageLookup[quotes];
|
||||||
|
const content = text_html;
|
||||||
|
const isMention =
|
||||||
|
quotedMessage?.username === userUsername ||
|
||||||
|
(text_html.includes(`/id/${userId}`) &&
|
||||||
|
userUsername &&
|
||||||
|
username !== userUsername);
|
||||||
|
const isDirect = id === DIRECT_MESSAGE_ID;
|
||||||
|
const isOptimistic = id === OPTIMISTIC_MESSAGE_ID;
|
||||||
|
|
||||||
|
const timestamp = useMemo(
|
||||||
|
() => formatTimeAgo(time),
|
||||||
|
[time, timestampUpdates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteMessage = useCallback(() => {
|
||||||
|
if (confirmedDelete) {
|
||||||
|
deleteMessage(id);
|
||||||
|
} else {
|
||||||
|
setConfirmedDelete(true);
|
||||||
|
}
|
||||||
|
}, [id, confirmedDelete]);
|
||||||
|
|
||||||
|
const handleQuoteMessageAction = useCallback(() => {
|
||||||
|
quoteMessage(message);
|
||||||
|
onToggleActions(message.id);
|
||||||
|
}, [message, onToggleActions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actionsOpen) {
|
||||||
|
setConfirmedDelete(false);
|
||||||
|
}
|
||||||
|
}, [actionsOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx("ChatMessage", {
|
||||||
|
ChatMessage__isOptimistic: isOptimistic,
|
||||||
|
})}
|
||||||
|
id={id}
|
||||||
|
style={
|
||||||
|
isMention
|
||||||
|
? {
|
||||||
|
background: `#${themeColor}25`,
|
||||||
|
borderLeft: `1px solid #${themeColor}`,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isDirect && !isOptimistic && !actionsOpen && (
|
||||||
|
<div className="ChatMessage-actions-button">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
quoteMessage(quote ? null : message);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fas fa-reply" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => onToggleActions(id)}
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isDirect && !isOptimistic && actionsOpen && (
|
||||||
|
<div className="ChatMessage-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ChatMessage-button"
|
||||||
|
onClick={handleQuoteMessageAction}
|
||||||
|
>
|
||||||
|
<i className="fas fa-reply" /> Reply
|
||||||
|
</button>
|
||||||
|
{admin && (
|
||||||
|
<button
|
||||||
|
className={cx("btn btn-secondary ChatMessage-button", {
|
||||||
|
"ChatMessage-button__confirmed": confirmedDelete,
|
||||||
|
})}
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
>
|
||||||
|
<i className="fas fa-trash-alt" />{" "}
|
||||||
|
{confirmedDelete ? "Are you sure?" : "Delete"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary ChatMessage-button"
|
||||||
|
onClick={() => onToggleActions(id)}
|
||||||
|
>
|
||||||
|
<i>X</i> Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showUser && (
|
||||||
|
<div className="ChatMessage-top">
|
||||||
|
<Username
|
||||||
|
avatar={avatar}
|
||||||
|
name={username}
|
||||||
|
color="#000"
|
||||||
|
/>
|
||||||
|
<div className="ChatMessage-timestamp">{timestamp}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quotes && quotedMessage && (
|
||||||
|
<div className="ChatMessage-quoted-link">
|
||||||
|
<QuotedMessageLink message={quotedMessage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ChatMessage-bottom">
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className="ChatMessage-content"
|
||||||
|
title={content}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageList() {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { messages } = useChat();
|
||||||
|
const [timestampUpdates, setTimestampUpdates] = useState(0);
|
||||||
|
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
||||||
|
const [actionsOpenForMessage, setActionsOpenForMessage] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const handleToggleActionsForMessage = useCallback(
|
||||||
|
(messageId: string) =>
|
||||||
|
setActionsOpenForMessage(
|
||||||
|
messageId === actionsOpenForMessage ? null : messageId
|
||||||
|
),
|
||||||
|
[actionsOpenForMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatingTimestamps = setInterval(
|
||||||
|
() => setTimestampUpdates((prev) => prev + 1),
|
||||||
|
TIMESTAMP_UPDATE_INTERVAL
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(updatingTimestamps);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const images = Array.from(
|
||||||
|
listRef.current.getElementsByTagName("img")
|
||||||
|
).filter((image) => image.dataset.src);
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
image.src = image.dataset.src;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ChatMessageList" ref={listRef}>
|
||||||
|
{groupedMessages.map((group) => (
|
||||||
|
<div key={key(group)} className="ChatMessageList-group">
|
||||||
|
{group.map((message, index) => (
|
||||||
|
<ChatMessage
|
||||||
|
key={key(message)}
|
||||||
|
message={message}
|
||||||
|
timestampUpdates={timestampUpdates}
|
||||||
|
showUser={index === 0}
|
||||||
|
actionsOpen={actionsOpenForMessage === message.id}
|
||||||
|
onToggleActions={handleToggleActionsForMessage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(time: number) {
|
||||||
|
const shortEnglishHumanizer = humanizeDuration.humanizer({
|
||||||
|
language: "shortEn",
|
||||||
|
languages: {
|
||||||
|
shortEn: {
|
||||||
|
y: () => "y",
|
||||||
|
mo: () => "mo",
|
||||||
|
w: () => "w",
|
||||||
|
d: () => "d",
|
||||||
|
h: () => "h",
|
||||||
|
m: () => "m",
|
||||||
|
s: () => "s",
|
||||||
|
ms: () => "ms",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
round: true,
|
||||||
|
units: ["h", "m", "s"],
|
||||||
|
largest: 2,
|
||||||
|
spacer: "",
|
||||||
|
delimiter: ", ",
|
||||||
|
});
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const humanized = `${shortEnglishHumanizer(time * 1000 - now)} ago`;
|
||||||
|
|
||||||
|
return humanized === "0s ago" ? "just now" : humanized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMessages(messages: IChatMessage[]) {
|
||||||
|
const grouped: IChatMessage[][] = [];
|
||||||
|
let lastUsername = "";
|
||||||
|
let temp: IChatMessage[] = [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!lastUsername) {
|
||||||
|
lastUsername = message.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.username === lastUsername) {
|
||||||
|
temp.push(message);
|
||||||
|
} else {
|
||||||
|
grouped.push(cloneDeep(temp));
|
||||||
|
lastUsername = message.username;
|
||||||
|
temp = [message];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.length > 0) {
|
||||||
|
grouped.push(cloneDeep(temp));
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
15
chat/src/features/chat/QuotedMessage.css
Normal file
15
chat/src/features/chat/QuotedMessage.css
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
.QuotedMessage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.QuotedMessage-content {
|
||||||
|
margin-left: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
21
chat/src/features/chat/QuotedMessage.tsx
Normal file
21
chat/src/features/chat/QuotedMessage.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useChat } from "../../hooks";
|
||||||
|
import "./QuotedMessage.css";
|
||||||
|
import { QuotedMessageLink } from "./QuotedMessageLink";
|
||||||
|
|
||||||
|
export function QuotedMessage() {
|
||||||
|
const { quote, quoteMessage } = useChat();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="QuotedMessage">
|
||||||
|
<QuotedMessageLink message={quote} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => quoteMessage(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
3
chat/src/features/chat/QuotedMessageLink.css
Normal file
3
chat/src/features/chat/QuotedMessageLink.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.QuotedMessageLink {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
49
chat/src/features/chat/QuotedMessageLink.tsx
Normal file
49
chat/src/features/chat/QuotedMessageLink.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { useRootContext } from "../../hooks";
|
||||||
|
import "./QuotedMessageLink.css";
|
||||||
|
|
||||||
|
const SCROLL_TO_QUOTED_OVERFLOW = 250;
|
||||||
|
const QUOTED_MESSAGE_CONTEXTUAL_HIGHLIGHTING_DURATION = 2500;
|
||||||
|
const QUOTED_MESSAGE_CONTEXTUAL_SNIPPET_LENGTH = 30;
|
||||||
|
|
||||||
|
export function QuotedMessageLink({ message }: { message: IChatMessage }) {
|
||||||
|
const { themeColor } = useRootContext();
|
||||||
|
const handleLinkClick = useCallback(() => {
|
||||||
|
const element = document.getElementById(message.id);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView();
|
||||||
|
element.style.background = `#${themeColor}33`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
element.style.background = "unset";
|
||||||
|
}, QUOTED_MESSAGE_CONTEXTUAL_HIGHLIGHTING_DURATION);
|
||||||
|
|
||||||
|
const [appContent] = Array.from(
|
||||||
|
document.getElementsByClassName("App-content")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appContent) {
|
||||||
|
appContent.scrollTop -= SCROLL_TO_QUOTED_OVERFLOW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const replyText = useMemo(() => {
|
||||||
|
const textToUse = message.text;
|
||||||
|
const slicedText = textToUse.slice(
|
||||||
|
0,
|
||||||
|
QUOTED_MESSAGE_CONTEXTUAL_SNIPPET_LENGTH
|
||||||
|
);
|
||||||
|
|
||||||
|
return textToUse.length >= QUOTED_MESSAGE_CONTEXTUAL_SNIPPET_LENGTH
|
||||||
|
? `${slicedText}...`
|
||||||
|
: slicedText;
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a className="QuotedMessageLink" href="#" onClick={handleLinkClick}>
|
||||||
|
<i className="fas fa-reply" /> @{message.username}:{" "}
|
||||||
|
<em>"{replyText}"</em>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
28
chat/src/features/chat/UserInput.css
Normal file
28
chat/src/features/chat/UserInput.css
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
.UserInput {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1100px) {
|
||||||
|
.UserInput-input__large {
|
||||||
|
min-height: 100px !important;
|
||||||
|
height: 100px !important;
|
||||||
|
max-height: 100px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserInput-input {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 2rem;
|
||||||
|
min-height: 50px;
|
||||||
|
height: 50px;
|
||||||
|
max-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserInput-emoji {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
81
chat/src/features/chat/UserInput.tsx
Normal file
81
chat/src/features/chat/UserInput.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
KeyboardEvent,
|
||||||
|
FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { useChat } from "../../hooks";
|
||||||
|
import "./UserInput.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
large?: boolean;
|
||||||
|
onFocus(): void;
|
||||||
|
onBlur(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserInput({ large = false, onFocus, onBlur }: Props) {
|
||||||
|
const { draft, sendMessage, updateDraft } = useChat();
|
||||||
|
const builtChatInput = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const form = useRef<HTMLFormElement>(null);
|
||||||
|
const [typingOffset, setTypingOffset] = useState(0);
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const input = event.target.value;
|
||||||
|
updateDraft(input);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const handleSendMessage = useCallback(
|
||||||
|
(event?: FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
const handleKeyUp = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSendMessage]
|
||||||
|
);
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
builtChatInput.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
onFocus();
|
||||||
|
}, [onFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form ref={form} className="UserInput" onSubmit={handleSendMessage}>
|
||||||
|
<textarea
|
||||||
|
ref={builtChatInput}
|
||||||
|
id="builtChatInput"
|
||||||
|
className={cx("UserInput-input form-control", {
|
||||||
|
"UserInput-input__large": large
|
||||||
|
})}
|
||||||
|
minLength={1}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={1}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder="Message"
|
||||||
|
autoComplete="off"
|
||||||
|
value={draft}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={draft.length === 0}
|
||||||
|
onClick={sendMessage}
|
||||||
|
>
|
||||||
|
<i className="UserInput-emoji fas fa-reply" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
23
chat/src/features/chat/UserList.css
Normal file
23
chat/src/features/chat/UserList.css
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
.UserList {
|
||||||
|
padding: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList-heading h5 {
|
||||||
|
margin-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList ul {
|
||||||
|
max-height: calc(var(--vh, 1vh) * 50);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList ul::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
30
chat/src/features/chat/UserList.tsx
Normal file
30
chat/src/features/chat/UserList.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import React from "react";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { useChat } from "../../hooks";
|
||||||
|
import "./UserList.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fluid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserList({ fluid = false }: Props) {
|
||||||
|
const { online } = useChat();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="UserList">
|
||||||
|
<div className="UserList-heading">
|
||||||
|
<h5>Users</h5>
|
||||||
|
<div className="Chat-online">
|
||||||
|
<i className="far fa-user fa-sm" /> {online.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className={cx({ fluid })}>
|
||||||
|
{online.map((user) => (
|
||||||
|
<li key={user}>
|
||||||
|
<a href={`/@${user}`}>@{user}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
9
chat/src/features/chat/Username.css
Normal file
9
chat/src/features/chat/Username.css
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
.Username {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Username > a {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
27
chat/src/features/chat/Username.tsx
Normal file
27
chat/src/features/chat/Username.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React from "react";
|
||||||
|
import "./Username.css";
|
||||||
|
|
||||||
|
interface UsernameProps {
|
||||||
|
avatar: string;
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Username({ avatar, color, name, hat = "" }: UsernameProps) {
|
||||||
|
return (
|
||||||
|
<div className="Username">
|
||||||
|
<div className="profile-pic-20-wrapper">
|
||||||
|
<img alt={name} src={avatar} className="pp20" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="userlink"
|
||||||
|
style={{ color: `#${color}` }}
|
||||||
|
target="_blank"
|
||||||
|
href={`/@${name}`}
|
||||||
|
rel="nofollow noopener"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
5
chat/src/features/chat/UsersTyping.css
Normal file
5
chat/src/features/chat/UsersTyping.css
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.UsersTyping {
|
||||||
|
height: 18px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
32
chat/src/features/chat/UsersTyping.tsx
Normal file
32
chat/src/features/chat/UsersTyping.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useChat } from "../../hooks";
|
||||||
|
import "./UsersTyping.css";
|
||||||
|
|
||||||
|
export function UsersTyping() {
|
||||||
|
const { typing } = useChat();
|
||||||
|
const [first, second, third, ...rest] = typing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="UsersTyping">
|
||||||
|
{(() => {
|
||||||
|
if (rest.length > 0) {
|
||||||
|
return `${first}, ${second}, ${third} and ${rest.length} more are typing...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first && second && third) {
|
||||||
|
return `${first}, ${second} and ${third} are typing...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first && second) {
|
||||||
|
return `${first} and ${second} are typing...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
return `${first} is typing...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
8
chat/src/features/chat/index.ts
Normal file
8
chat/src/features/chat/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
export * from "./ChatHeading";
|
||||||
|
export * from "./ChatMessage";
|
||||||
|
export * from "./UserInput";
|
||||||
|
export * from "./UserList";
|
||||||
|
export * from "./Username";
|
||||||
|
export * from "./UsersTyping";
|
||||||
|
export * from "./QuotedMessage";
|
||||||
|
export * from "./QuotedMessageLink";
|
1
chat/src/features/index.ts
Normal file
1
chat/src/features/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./chat";
|
4
chat/src/hooks/index.ts
Normal file
4
chat/src/hooks/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./useChat";
|
||||||
|
export * from "./useDrawer";
|
||||||
|
export * from "./useRootContext";
|
||||||
|
export * from "./useWindowFocus";
|
260
chat/src/hooks/useChat.tsx
Normal file
260
chat/src/hooks/useChat.tsx
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import debounce from "lodash.debounce";
|
||||||
|
import { useRootContext } from "./useRootContext";
|
||||||
|
import { useWindowFocus } from "./useWindowFocus";
|
||||||
|
|
||||||
|
enum ChatHandlers {
|
||||||
|
CONNECT = "connect",
|
||||||
|
CATCHUP = "catchup",
|
||||||
|
ONLINE = "online",
|
||||||
|
TYPING = "typing",
|
||||||
|
DELETE = "delete",
|
||||||
|
SPEAK = "speak",
|
||||||
|
READ = "read",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatProviderContext {
|
||||||
|
online: string[];
|
||||||
|
typing: string[];
|
||||||
|
messages: IChatMessage[];
|
||||||
|
draft: string;
|
||||||
|
quote: null | IChatMessage;
|
||||||
|
messageLookup: Record<string, IChatMessage>;
|
||||||
|
updateDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
sendMessage(): void;
|
||||||
|
quoteMessage(message: null | IChatMessage): void;
|
||||||
|
deleteMessage(withId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatContext = createContext<ChatProviderContext>({
|
||||||
|
online: [],
|
||||||
|
typing: [],
|
||||||
|
messages: [],
|
||||||
|
draft: "",
|
||||||
|
quote: null,
|
||||||
|
messageLookup: {},
|
||||||
|
updateDraft() {},
|
||||||
|
sendMessage() {},
|
||||||
|
quoteMessage() {},
|
||||||
|
deleteMessage() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const MINIMUM_TYPING_UPDATE_INTERVAL = 250;
|
||||||
|
export const DIRECT_MESSAGE_ID = "DIRECT_MESSAGE";
|
||||||
|
export const OPTIMISTIC_MESSAGE_ID = "OPTIMISTIC";
|
||||||
|
|
||||||
|
export function ChatProvider({ children }: PropsWithChildren) {
|
||||||
|
const { username, id, siteName, avatar } = useRootContext();
|
||||||
|
const socket = useRef<null | Socket>(null);
|
||||||
|
const [online, setOnline] = useState<string[]>([]);
|
||||||
|
const [typing, setTyping] = useState<string[]>([]);
|
||||||
|
const [messages, setMessages] = useState<IChatMessage[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const lastDraft = useRef("");
|
||||||
|
const [quote, setQuote] = useState<null | IChatMessage>(null);
|
||||||
|
const focused = useWindowFocus();
|
||||||
|
const [notifications, setNotifications] = useState<number>(0);
|
||||||
|
const [messageLookup, setMessageLookup] = useState({});
|
||||||
|
|
||||||
|
const setMessagesAndRead = useCallback((messages: IChatMessage[]) => {
|
||||||
|
setMessages(messages);
|
||||||
|
console.log("TSRM begin");
|
||||||
|
console.log(messages);
|
||||||
|
trySendReadMessage(messages);
|
||||||
|
console.log("TSRM end")
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addMessage = useCallback((message: IChatMessage) => {
|
||||||
|
if (message.id === OPTIMISTIC_MESSAGE_ID) {
|
||||||
|
setMessages((prev) => prev.concat(message));
|
||||||
|
} else {
|
||||||
|
// Are there any optimistic messages that have the same text?
|
||||||
|
setMessages((prev) => {
|
||||||
|
const matchingOptimisticMessage = prev.findIndex(
|
||||||
|
(prevMessage) =>
|
||||||
|
prevMessage.id === OPTIMISTIC_MESSAGE_ID &&
|
||||||
|
prevMessage.text.trim() === message.text.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingOptimisticMessage === -1) {
|
||||||
|
return prev.slice(-99).concat(message);
|
||||||
|
} else {
|
||||||
|
const before = prev.slice(0, matchingOptimisticMessage);
|
||||||
|
const after = prev.slice(matchingOptimisticMessage + 1);
|
||||||
|
|
||||||
|
return [...before, message, ...after];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.username !== username && !document.hasFocus()) {
|
||||||
|
setNotifications((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(() => {
|
||||||
|
if (draft.startsWith("/")) {
|
||||||
|
// this is a command; just skip posting stuff
|
||||||
|
} else {
|
||||||
|
addMessage({
|
||||||
|
id: OPTIMISTIC_MESSAGE_ID,
|
||||||
|
username,
|
||||||
|
avatar,
|
||||||
|
text: draft,
|
||||||
|
text_html: draft,
|
||||||
|
time: new Date().getTime() / 1000,
|
||||||
|
quotes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.current?.emit(ChatHandlers.SPEAK, {
|
||||||
|
message: draft,
|
||||||
|
quotes: quote?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setQuote(null);
|
||||||
|
setDraft("");
|
||||||
|
}, [draft, quote]);
|
||||||
|
|
||||||
|
const requestDeleteMessage = useCallback((withId: string) => {
|
||||||
|
socket.current?.emit(ChatHandlers.DELETE, withId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteMessage = useCallback((withId: string) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.filter((prevMessage) => prevMessage.id !== withId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (quote?.id === withId) {
|
||||||
|
setQuote(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const quoteMessage = useCallback((message: IChatMessage) => {
|
||||||
|
setQuote(message);
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.getElementById("builtChatInput").focus();
|
||||||
|
} catch (error) {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [lastMaxTime, setLastMaxTime] = useState<number | null>(null);
|
||||||
|
const trySendReadMessage = useCallback((messages: IChatMessage[]) => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
console.log("Early abort");
|
||||||
|
return; // Exit if the messages array is empty
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTime = Math.max(...messages.map(msg => msg.time));
|
||||||
|
|
||||||
|
if (maxTime !== lastMaxTime) { // Only emit if there's a new maxTime
|
||||||
|
setLastMaxTime(maxTime); // Update the stored maxTime
|
||||||
|
socket.current?.emit(ChatHandlers.READ, maxTime);
|
||||||
|
console.log("Smaxy");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("duvo");
|
||||||
|
}, [lastMaxTime]);
|
||||||
|
|
||||||
|
|
||||||
|
const context = useMemo<ChatProviderContext>(
|
||||||
|
() => ({
|
||||||
|
online,
|
||||||
|
typing,
|
||||||
|
messages,
|
||||||
|
draft,
|
||||||
|
quote,
|
||||||
|
messageLookup,
|
||||||
|
quoteMessage,
|
||||||
|
sendMessage,
|
||||||
|
deleteMessage: requestDeleteMessage,
|
||||||
|
updateDraft: setDraft,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
online,
|
||||||
|
typing,
|
||||||
|
messages,
|
||||||
|
draft,
|
||||||
|
quote,
|
||||||
|
messageLookup,
|
||||||
|
sendMessage,
|
||||||
|
deleteMessage,
|
||||||
|
quoteMessage,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket.current) {
|
||||||
|
socket.current = io();
|
||||||
|
|
||||||
|
socket.current
|
||||||
|
.on(ChatHandlers.CATCHUP, setMessagesAndRead)
|
||||||
|
.on(ChatHandlers.ONLINE, setOnline)
|
||||||
|
.on(ChatHandlers.TYPING, setTyping)
|
||||||
|
.on(ChatHandlers.SPEAK, addMessage)
|
||||||
|
.on(ChatHandlers.DELETE, deleteMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedTypingUpdater = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce(
|
||||||
|
() => socket.current?.emit(ChatHandlers.TYPING, lastDraft.current),
|
||||||
|
MINIMUM_TYPING_UPDATE_INTERVAL
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lastDraft.current = draft;
|
||||||
|
debouncedTypingUpdater();
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trySendReadMessage(messages);
|
||||||
|
|
||||||
|
if (focused || document.hasFocus()) {
|
||||||
|
setNotifications(0);
|
||||||
|
}
|
||||||
|
}, [focused, messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMessageLookup(
|
||||||
|
messages.reduce((prev, next) => {
|
||||||
|
prev[next.id] = next;
|
||||||
|
return prev;
|
||||||
|
}, {} as Record<string, IChatMessage>)
|
||||||
|
);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Display e.g. [+2] Chat when notifications occur when you're away.
|
||||||
|
useEffect(() => {
|
||||||
|
const title = document.getElementsByTagName("title")[0];
|
||||||
|
const favicon = document.querySelector("link[rel=icon]") as HTMLLinkElement;
|
||||||
|
const escape = (window as any).escapeHTML;
|
||||||
|
const alertedWhileAway = notifications > 0 && !focused;
|
||||||
|
const pathIcon = alertedWhileAway ? "alert" : "icon";
|
||||||
|
|
||||||
|
favicon.href = escape(`/assets/images/${siteName}/${pathIcon}.webp?v=3`);
|
||||||
|
title.innerHTML = alertedWhileAway ? `[+${notifications}] Chat` : "Chat";
|
||||||
|
}, [notifications, focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatContext.Provider value={context}>{children}</ChatContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat() {
|
||||||
|
const value = useContext(ChatContext);
|
||||||
|
return value;
|
||||||
|
}
|
197
chat/src/hooks/useDrawer.tsx
Normal file
197
chat/src/hooks/useDrawer.tsx
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
import React, {
|
||||||
|
ReactNode,
|
||||||
|
createContext,
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useDrag } from "react-dnd";
|
||||||
|
|
||||||
|
interface DrawerConfig {
|
||||||
|
title?: string;
|
||||||
|
content: ReactNode;
|
||||||
|
actions?: Array<{ title: string; onClick(): void }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawerContextType {
|
||||||
|
config: DrawerConfig;
|
||||||
|
open: boolean;
|
||||||
|
coordinates: [number, number];
|
||||||
|
reveal(config: DrawerConfig): void;
|
||||||
|
hide(): void;
|
||||||
|
show(): void;
|
||||||
|
toggle(): void;
|
||||||
|
updateCoordinates(to: [number, number]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAWER_COORDINATES_STORAGE_KEY = "Drawer/Coordinates";
|
||||||
|
|
||||||
|
const DEFAULT_DRAWER_CONFIG = {
|
||||||
|
title: "",
|
||||||
|
content: null,
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DrawerContext = createContext<DrawerContextType>({
|
||||||
|
config: DEFAULT_DRAWER_CONFIG,
|
||||||
|
open: false,
|
||||||
|
coordinates: [0, 0] as [number, number],
|
||||||
|
reveal() {},
|
||||||
|
hide() {},
|
||||||
|
show() {},
|
||||||
|
toggle() {},
|
||||||
|
updateCoordinates() {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useDrawer() {
|
||||||
|
const values = useContext(DrawerContext);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerProvider({ children }: PropsWithChildren) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [config, setConfig] = useState<DrawerConfig>(DEFAULT_DRAWER_CONFIG);
|
||||||
|
const [coordinates, setCoordinates] = useState([0, 0] as [number, number]);
|
||||||
|
const reveal = useCallback((config: DrawerConfig) => {
|
||||||
|
setConfig(config);
|
||||||
|
show();
|
||||||
|
}, []);
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, []);
|
||||||
|
const show = useCallback(() => {
|
||||||
|
setOpen(true);
|
||||||
|
}, []);
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
const context = useMemo(
|
||||||
|
() => ({
|
||||||
|
config,
|
||||||
|
open,
|
||||||
|
coordinates,
|
||||||
|
reveal,
|
||||||
|
hide,
|
||||||
|
show,
|
||||||
|
toggle,
|
||||||
|
updateCoordinates: setCoordinates,
|
||||||
|
}),
|
||||||
|
[config, open, coordinates, reveal, hide, show, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load coordinates.
|
||||||
|
useEffect(() => {
|
||||||
|
const persisted = window.localStorage.getItem(
|
||||||
|
DRAWER_COORDINATES_STORAGE_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
if (persisted) {
|
||||||
|
setCoordinates(JSON.parse(persisted));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist coordinates.
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
DRAWER_COORDINATES_STORAGE_KEY,
|
||||||
|
JSON.stringify(coordinates)
|
||||||
|
);
|
||||||
|
}, [coordinates]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContext.Provider value={context}>{children}</DrawerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Drawer() {
|
||||||
|
const {
|
||||||
|
config: { title = "", content, actions = [] },
|
||||||
|
open,
|
||||||
|
coordinates,
|
||||||
|
updateCoordinates,
|
||||||
|
hide,
|
||||||
|
} = useDrawer();
|
||||||
|
const [x, y] = coordinates;
|
||||||
|
const [{ isDragging }, dragRef] = useDrag({
|
||||||
|
type: "drawer",
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const lastMousePosition = useRef([0, 0]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (event: MouseEvent) =>
|
||||||
|
(lastMousePosition.current = [event.clientX, event.clientY]);
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const chatWrapper = document.getElementById("chatWrapper");
|
||||||
|
const chatWrapperBox = chatWrapper.getBoundingClientRect();
|
||||||
|
|
||||||
|
updateCoordinates([chatWrapperBox.left, chatWrapperBox.top]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
const onRelease = (event: DragEvent) => {
|
||||||
|
const drawer = document.getElementById("drawer");
|
||||||
|
const drawerBox = drawer.getBoundingClientRect();
|
||||||
|
const [mouseX, mouseY] = lastMousePosition.current;
|
||||||
|
const xDiff = mouseX - drawerBox.left;
|
||||||
|
const yDiff = mouseY - drawerBox.top;
|
||||||
|
|
||||||
|
updateCoordinates([event.clientX - xDiff, event.clientY - yDiff]);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("drop", onRelease);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("drop", onRelease);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="drawer"
|
||||||
|
className="App-drawer"
|
||||||
|
ref={dragRef}
|
||||||
|
style={{
|
||||||
|
top: y,
|
||||||
|
left: x,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="App-drawer--close btn btn-default"
|
||||||
|
onClick={hide}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
<div className="App-drawer--content">{content}</div>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.title}
|
||||||
|
type="button"
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{action.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
44
chat/src/hooks/useRootContext.ts
Normal file
44
chat/src/hooks/useRootContext.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useRootContext() {
|
||||||
|
const [
|
||||||
|
{
|
||||||
|
admin,
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
themeColor,
|
||||||
|
siteName,
|
||||||
|
avatar,
|
||||||
|
},
|
||||||
|
setContext,
|
||||||
|
] = useState({
|
||||||
|
id: "",
|
||||||
|
username: "",
|
||||||
|
admin: false,
|
||||||
|
themeColor: "#ff66ac",
|
||||||
|
siteName: "",
|
||||||
|
avatar: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
|
setContext({
|
||||||
|
id: root.dataset.id,
|
||||||
|
username: root.dataset.username,
|
||||||
|
admin: root.dataset.admin === "True",
|
||||||
|
themeColor: root.dataset.themecolor,
|
||||||
|
siteName: root.dataset.sitename,
|
||||||
|
avatar: root.dataset.avatar,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
admin,
|
||||||
|
username,
|
||||||
|
themeColor,
|
||||||
|
siteName,
|
||||||
|
avatar,
|
||||||
|
};
|
||||||
|
}
|
19
chat/src/hooks/useWindowFocus.ts
Normal file
19
chat/src/hooks/useWindowFocus.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useWindowFocus() {
|
||||||
|
const [focused, setFocused] = useState(true);
|
||||||
|
const onFocus = useCallback(() => setFocused(true), []);
|
||||||
|
const onBlur = useCallback(() => setFocused(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
window.addEventListener("blur", onBlur);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return focused;
|
||||||
|
}
|
7
chat/src/index.tsx
Normal file
7
chat/src/index.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root"))
|
||||||
|
|
||||||
|
root.render(<App />);
|
9
chat/tsconfig.json
Normal file
9
chat/tsconfig.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"lib": ["es2015", "dom", "ESNext"],
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
604
chat/yarn.lock
Normal file
604
chat/yarn.lock
Normal file
|
@ -0,0 +1,604 @@
|
||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@babel/runtime@^7.0.0", "@babel/runtime@^7.9.2":
|
||||||
|
version "7.19.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
|
||||||
|
integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime "^0.13.4"
|
||||||
|
|
||||||
|
"@esbuild/android-arm@0.15.13":
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.13.tgz#ce11237a13ee76d5eae3908e47ba4ddd380af86a"
|
||||||
|
integrity sha512-RY2fVI8O0iFUNvZirXaQ1vMvK0xhCcl0gqRj74Z6yEiO1zAUa7hbsdwZM1kzqbxHK7LFyMizipfXT3JME+12Hw==
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64@0.15.13":
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.13.tgz#64e8825bf0ce769dac94ee39d92ebe6272020dfc"
|
||||||
|
integrity sha512-+BoyIm4I8uJmH/QDIH0fu7MG0AEx9OXEDXnqptXCwKOlOqZiS4iraH1Nr7/ObLMokW3sOCeBNyD68ATcV9b9Ag==
|
||||||
|
|
||||||
|
"@react-dnd/asap@^5.0.1":
|
||||||
|
version "5.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
|
||||||
|
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
|
||||||
|
|
||||||
|
"@react-dnd/invariant@^4.0.1":
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
|
||||||
|
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
|
||||||
|
|
||||||
|
"@react-dnd/shallowequal@^4.0.1":
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
|
||||||
|
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
|
||||||
|
|
||||||
|
"@socket.io/component-emitter@~3.1.0":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
|
||||||
|
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
|
||||||
|
|
||||||
|
"@types/humanize-duration@^3.27.1":
|
||||||
|
version "3.27.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/humanize-duration/-/humanize-duration-3.27.1.tgz#f14740d1f585a0a8e3f46359b62fda8b0eaa31e7"
|
||||||
|
integrity sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==
|
||||||
|
|
||||||
|
"@types/lodash.clonedeep@^4.5.7":
|
||||||
|
version "4.5.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz#0e119f582ed6f9e6b373c04a644651763214f197"
|
||||||
|
integrity sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash.debounce@^4.0.7":
|
||||||
|
version "4.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.7.tgz#0285879defb7cdb156ae633cecd62d5680eded9f"
|
||||||
|
integrity sha512-X1T4wMZ+gT000M2/91SYj0d/7JfeNZ9PeeOldSNoE/lunLeQXKvkmIumI29IaKMotU/ln/McOIvgzZcQ/3TrSA==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash.throttle@^4.1.7":
|
||||||
|
version "4.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58"
|
||||||
|
integrity sha512-znwGDpjCHQ4FpLLx19w4OXDqq8+OvREa05H89obtSyXyOFKL3dDjCslsmfBz0T2FU8dmf5Wx1QvogbINiGIu9g==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash@*":
|
||||||
|
version "4.14.185"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.185.tgz#c9843f5a40703a8f5edfd53358a58ae729816908"
|
||||||
|
integrity sha512-evMDG1bC4rgQg4ku9tKpuMh5iBNEwNa3tf9zRHdP1qlv+1WUg44xat4IxCE14gIpZRGUUWAx2VhItCZc25NfMA==
|
||||||
|
|
||||||
|
"@types/prop-types@*":
|
||||||
|
version "15.7.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
||||||
|
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
||||||
|
|
||||||
|
"@types/react-dom@^18.0.7":
|
||||||
|
version "18.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.9.tgz#ffee5e4bfc2a2f8774b15496474f8e7fe8d0b504"
|
||||||
|
integrity sha512-qnVvHxASt/H7i+XG1U1xMiY5t+IHcPGUK7TDMDzom08xa7e86eCeKOiLZezwCKVxJn6NEiiy2ekgX8aQssjIKg==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-virtualized-auto-sizer@^1.0.1":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4"
|
||||||
|
integrity sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-window@^1.8.5":
|
||||||
|
version "1.8.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
|
||||||
|
integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react@*":
|
||||||
|
version "18.0.20"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.20.tgz#e4c36be3a55eb5b456ecf501bd4a00fd4fd0c9ab"
|
||||||
|
integrity sha512-MWul1teSPxujEHVwZl4a5HxQ9vVNsjTchVA+xRqv/VYGCuKGAU6UhfrTdF5aBefwD1BHUD8i/zq+O/vyCm/FrA==
|
||||||
|
dependencies:
|
||||||
|
"@types/prop-types" "*"
|
||||||
|
"@types/scheduler" "*"
|
||||||
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/react@^18.0.21":
|
||||||
|
version "18.0.25"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.25.tgz#8b1dcd7e56fe7315535a4af25435e0bb55c8ae44"
|
||||||
|
integrity sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==
|
||||||
|
dependencies:
|
||||||
|
"@types/prop-types" "*"
|
||||||
|
"@types/scheduler" "*"
|
||||||
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/scheduler@*":
|
||||||
|
version "0.16.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
||||||
|
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
||||||
|
|
||||||
|
ansi-bold@^0.1.1:
|
||||||
|
version "0.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-bold/-/ansi-bold-0.1.1.tgz#3e63950af5acc2ae2e670e6f67deb115d1a5f505"
|
||||||
|
integrity sha512-wWKwcViX1E28U6FohtWOP4sHFyArELHJ2p7+3BzbibqJiuISeskq6t7JnrLisUngMF5zMhgmXVw8Equjzz9OlA==
|
||||||
|
dependencies:
|
||||||
|
ansi-wrap "0.1.0"
|
||||||
|
|
||||||
|
ansi-wrap@0.1.0:
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf"
|
||||||
|
integrity sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==
|
||||||
|
|
||||||
|
balanced-match@^1.0.0:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
|
brace-expansion@^1.1.7:
|
||||||
|
version "1.1.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||||
|
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
|
||||||
|
dependencies:
|
||||||
|
balanced-match "^1.0.0"
|
||||||
|
concat-map "0.0.1"
|
||||||
|
|
||||||
|
classnames@^2.3.2:
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||||
|
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||||
|
|
||||||
|
commander@^2.15.1:
|
||||||
|
version "2.20.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
|
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||||
|
|
||||||
|
concat-map@0.0.1:
|
||||||
|
version "0.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||||
|
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||||
|
|
||||||
|
core-js@^2.4.0:
|
||||||
|
version "2.6.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
|
||||||
|
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
|
||||||
|
|
||||||
|
csstype@^3.0.2:
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
|
||||||
|
integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
|
||||||
|
|
||||||
|
debug@~4.3.1, debug@~4.3.2:
|
||||||
|
version "4.3.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||||
|
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||||
|
dependencies:
|
||||||
|
ms "2.1.2"
|
||||||
|
|
||||||
|
dnd-core@^16.0.1:
|
||||||
|
version "16.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
|
||||||
|
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
|
||||||
|
dependencies:
|
||||||
|
"@react-dnd/asap" "^5.0.1"
|
||||||
|
"@react-dnd/invariant" "^4.0.1"
|
||||||
|
redux "^4.2.0"
|
||||||
|
|
||||||
|
dotenv@^16.0.3:
|
||||||
|
version "16.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07"
|
||||||
|
integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==
|
||||||
|
|
||||||
|
engine.io-client@~6.2.3:
|
||||||
|
version "6.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.2.3.tgz#a8cbdab003162529db85e9de31575097f6d29458"
|
||||||
|
integrity sha512-aXPtgF1JS3RuuKcpSrBtimSjYvrbhKW9froICH4s0F3XQWLxsKNxqzG39nnvQZQnva4CMvUK63T7shevxRyYHw==
|
||||||
|
dependencies:
|
||||||
|
"@socket.io/component-emitter" "~3.1.0"
|
||||||
|
debug "~4.3.1"
|
||||||
|
engine.io-parser "~5.0.3"
|
||||||
|
ws "~8.2.3"
|
||||||
|
xmlhttprequest-ssl "~2.0.0"
|
||||||
|
|
||||||
|
engine.io-parser@~5.0.3:
|
||||||
|
version "5.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.4.tgz#0b13f704fa9271b3ec4f33112410d8f3f41d0fc0"
|
||||||
|
integrity sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==
|
||||||
|
|
||||||
|
es6-promise@^3.0.2:
|
||||||
|
version "3.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
|
||||||
|
integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==
|
||||||
|
|
||||||
|
esbuild-android-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.13.tgz#5f25864055dbd62e250f360b38b4c382224063af"
|
||||||
|
integrity sha512-yRorukXBlokwTip+Sy4MYskLhJsO0Kn0/Fj43s1krVblfwP+hMD37a4Wmg139GEsMLl+vh8WXp2mq/cTA9J97g==
|
||||||
|
|
||||||
|
esbuild-android-arm64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.13.tgz#d8820f999314efbe8e0f050653a99ff2da632b0f"
|
||||||
|
integrity sha512-TKzyymLD6PiVeyYa4c5wdPw87BeAiTXNtK6amWUcXZxkV51gOk5u5qzmDaYSwiWeecSNHamFsaFjLoi32QR5/w==
|
||||||
|
|
||||||
|
esbuild-darwin-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.13.tgz#99ae7fdaa43947b06cd9d1a1c3c2c9f245d81fd0"
|
||||||
|
integrity sha512-WAx7c2DaOS6CrRcoYCgXgkXDliLnFv3pQLV6GeW1YcGEZq2Gnl8s9Pg7ahValZkpOa0iE/ojRVQ87sbUhF1Cbg==
|
||||||
|
|
||||||
|
esbuild-darwin-arm64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.13.tgz#bafa1814354ad1a47adcad73de416130ef7f55e3"
|
||||||
|
integrity sha512-U6jFsPfSSxC3V1CLiQqwvDuj3GGrtQNB3P3nNC3+q99EKf94UGpsG9l4CQ83zBs1NHrk1rtCSYT0+KfK5LsD8A==
|
||||||
|
|
||||||
|
esbuild-freebsd-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.13.tgz#84ef85535c5cc38b627d1c5115623b088d1de161"
|
||||||
|
integrity sha512-whItJgDiOXaDG/idy75qqevIpZjnReZkMGCgQaBWZuKHoElDJC1rh7MpoUgupMcdfOd+PgdEwNQW9DAE6i8wyA==
|
||||||
|
|
||||||
|
esbuild-freebsd-arm64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.13.tgz#033f21de434ec8e0c478054b119af8056763c2d8"
|
||||||
|
integrity sha512-6pCSWt8mLUbPtygv7cufV0sZLeylaMwS5Fznj6Rsx9G2AJJsAjQ9ifA+0rQEIg7DwJmi9it+WjzNTEAzzdoM3Q==
|
||||||
|
|
||||||
|
esbuild-linux-32@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.13.tgz#54290ea8035cba0faf1791ce9ae6693005512535"
|
||||||
|
integrity sha512-VbZdWOEdrJiYApm2kkxoTOgsoCO1krBZ3quHdYk3g3ivWaMwNIVPIfEE0f0XQQ0u5pJtBsnk2/7OPiCFIPOe/w==
|
||||||
|
|
||||||
|
esbuild-linux-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.13.tgz#4264249281ea388ead948614b57fb1ddf7779a2c"
|
||||||
|
integrity sha512-rXmnArVNio6yANSqDQlIO4WiP+Cv7+9EuAHNnag7rByAqFVuRusLbGi2697A5dFPNXoO//IiogVwi3AdcfPC6A==
|
||||||
|
|
||||||
|
esbuild-linux-arm64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.13.tgz#9323c333924f97a02bdd2ae8912b36298acb312d"
|
||||||
|
integrity sha512-alEMGU4Z+d17U7KQQw2IV8tQycO6T+rOrgW8OS22Ua25x6kHxoG6Ngry6Aq6uranC+pNWNMB6aHFPh7aTQdORQ==
|
||||||
|
|
||||||
|
esbuild-linux-arm@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.13.tgz#b407f47b3ae721fe4e00e19e9f19289bef87a111"
|
||||||
|
integrity sha512-Ac6LpfmJO8WhCMQmO253xX2IU2B3wPDbl4IvR0hnqcPrdfCaUa2j/lLMGTjmQ4W5JsJIdHEdW12dG8lFS0MbxQ==
|
||||||
|
|
||||||
|
esbuild-linux-mips64le@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.13.tgz#bdf905aae5c0bcaa8f83567fe4c4c1bdc1f14447"
|
||||||
|
integrity sha512-47PgmyYEu+yN5rD/MbwS6DxP2FSGPo4Uxg5LwIdxTiyGC2XKwHhHyW7YYEDlSuXLQXEdTO7mYe8zQ74czP7W8A==
|
||||||
|
|
||||||
|
esbuild-linux-ppc64le@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.13.tgz#2911eae1c90ff58a3bd3259cb557235df25aa3b4"
|
||||||
|
integrity sha512-z6n28h2+PC1Ayle9DjKoBRcx/4cxHoOa2e689e2aDJSaKug3jXcQw7mM+GLg+9ydYoNzj8QxNL8ihOv/OnezhA==
|
||||||
|
|
||||||
|
esbuild-linux-riscv64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.13.tgz#1837c660be12b1d20d2a29c7189ea703f93e9265"
|
||||||
|
integrity sha512-+Lu4zuuXuQhgLUGyZloWCqTslcCAjMZH1k3Xc9MSEJEpEFdpsSU0sRDXAnk18FKOfEjhu4YMGaykx9xjtpA6ow==
|
||||||
|
|
||||||
|
esbuild-linux-s390x@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.13.tgz#d52880ece229d1bd10b2d936b792914ffb07c7fc"
|
||||||
|
integrity sha512-BMeXRljruf7J0TMxD5CIXS65y7puiZkAh+s4XFV9qy16SxOuMhxhVIXYLnbdfLrsYGFzx7U9mcdpFWkkvy/Uag==
|
||||||
|
|
||||||
|
esbuild-netbsd-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.13.tgz#de14da46f1d20352b43e15d97a80a8788275e6ed"
|
||||||
|
integrity sha512-EHj9QZOTel581JPj7UO3xYbltFTYnHy+SIqJVq6yd3KkCrsHRbapiPb0Lx3EOOtybBEE9EyqbmfW1NlSDsSzvQ==
|
||||||
|
|
||||||
|
esbuild-openbsd-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.13.tgz#45e8a5fd74d92ad8f732c43582369c7990f5a0ac"
|
||||||
|
integrity sha512-nkuDlIjF/sfUhfx8SKq0+U+Fgx5K9JcPq1mUodnxI0x4kBdCv46rOGWbuJ6eof2n3wdoCLccOoJAbg9ba/bT2w==
|
||||||
|
|
||||||
|
esbuild-sunos-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.13.tgz#f646ac3da7aac521ee0fdbc192750c87da697806"
|
||||||
|
integrity sha512-jVeu2GfxZQ++6lRdY43CS0Tm/r4WuQQ0Pdsrxbw+aOrHQPHV0+LNOLnvbN28M7BSUGnJnHkHm2HozGgNGyeIRw==
|
||||||
|
|
||||||
|
esbuild-windows-32@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.13.tgz#fb4fe77c7591418880b3c9b5900adc4c094f2401"
|
||||||
|
integrity sha512-XoF2iBf0wnqo16SDq+aDGi/+QbaLFpkiRarPVssMh9KYbFNCqPLlGAWwDvxEVz+ywX6Si37J2AKm+AXq1kC0JA==
|
||||||
|
|
||||||
|
esbuild-windows-64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.13.tgz#1fca8c654392c0c31bdaaed168becfea80e20660"
|
||||||
|
integrity sha512-Et6htEfGycjDrtqb2ng6nT+baesZPYQIW+HUEHK4D1ncggNrDNk3yoboYQ5KtiVrw/JaDMNttz8rrPubV/fvPQ==
|
||||||
|
|
||||||
|
esbuild-windows-arm64@0.15.13:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.13.tgz#4ffd01b6b2888603f1584a2fe96b1f6a6f2b3dd8"
|
||||||
|
integrity sha512-3bv7tqntThQC9SWLRouMDmZnlOukBhOCTlkzNqzGCmrkCJI7io5LLjwJBOVY6kOUlIvdxbooNZwjtBvj+7uuVg==
|
||||||
|
|
||||||
|
esbuild@^0.15.11:
|
||||||
|
version "0.15.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.13.tgz#7293480038feb2bafa91d3f6a20edab3ba6c108a"
|
||||||
|
integrity sha512-Cu3SC84oyzzhrK/YyN4iEVy2jZu5t2fz66HEOShHURcjSkOSAVL8C/gfUT+lDJxkVHpg8GZ10DD0rMHRPqMFaQ==
|
||||||
|
optionalDependencies:
|
||||||
|
"@esbuild/android-arm" "0.15.13"
|
||||||
|
"@esbuild/linux-loong64" "0.15.13"
|
||||||
|
esbuild-android-64 "0.15.13"
|
||||||
|
esbuild-android-arm64 "0.15.13"
|
||||||
|
esbuild-darwin-64 "0.15.13"
|
||||||
|
esbuild-darwin-arm64 "0.15.13"
|
||||||
|
esbuild-freebsd-64 "0.15.13"
|
||||||
|
esbuild-freebsd-arm64 "0.15.13"
|
||||||
|
esbuild-linux-32 "0.15.13"
|
||||||
|
esbuild-linux-64 "0.15.13"
|
||||||
|
esbuild-linux-arm "0.15.13"
|
||||||
|
esbuild-linux-arm64 "0.15.13"
|
||||||
|
esbuild-linux-mips64le "0.15.13"
|
||||||
|
esbuild-linux-ppc64le "0.15.13"
|
||||||
|
esbuild-linux-riscv64 "0.15.13"
|
||||||
|
esbuild-linux-s390x "0.15.13"
|
||||||
|
esbuild-netbsd-64 "0.15.13"
|
||||||
|
esbuild-openbsd-64 "0.15.13"
|
||||||
|
esbuild-sunos-64 "0.15.13"
|
||||||
|
esbuild-windows-32 "0.15.13"
|
||||||
|
esbuild-windows-64 "0.15.13"
|
||||||
|
esbuild-windows-arm64 "0.15.13"
|
||||||
|
|
||||||
|
fast-deep-equal@^3.1.3:
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||||
|
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||||
|
|
||||||
|
fs-find-root@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fs-find-root/-/fs-find-root-2.0.0.tgz#71c23b384db6bcb1e8ec637cade707fda39c593b"
|
||||||
|
integrity sha512-LmgsxDwnxd+sfm3EZ66P8nSlUMm69hKz/LdXKChK2a+5xXnrAB7MPn2uo0VCyrgj8lZNqyj6EIdD516ILZAEBg==
|
||||||
|
dependencies:
|
||||||
|
es6-promise "^3.0.2"
|
||||||
|
|
||||||
|
fs.realpath@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||||
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
|
gaze@^1.1.2:
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a"
|
||||||
|
integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==
|
||||||
|
dependencies:
|
||||||
|
globule "^1.0.0"
|
||||||
|
|
||||||
|
glob@~7.1.1:
|
||||||
|
version "7.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
|
||||||
|
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
|
||||||
|
dependencies:
|
||||||
|
fs.realpath "^1.0.0"
|
||||||
|
inflight "^1.0.4"
|
||||||
|
inherits "2"
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
once "^1.3.0"
|
||||||
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
|
globule@^1.0.0:
|
||||||
|
version "1.3.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.4.tgz#7c11c43056055a75a6e68294453c17f2796170fb"
|
||||||
|
integrity sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==
|
||||||
|
dependencies:
|
||||||
|
glob "~7.1.1"
|
||||||
|
lodash "^4.17.21"
|
||||||
|
minimatch "~3.0.2"
|
||||||
|
|
||||||
|
hoist-non-react-statics@^3.3.2:
|
||||||
|
version "3.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||||
|
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||||
|
dependencies:
|
||||||
|
react-is "^16.7.0"
|
||||||
|
|
||||||
|
humanize-duration@^3.27.3:
|
||||||
|
version "3.27.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.27.3.tgz#db654e72ebf5ccfe232c7f56bc58aa3a6fe4df88"
|
||||||
|
integrity sha512-iimHkHPfIAQ8zCDQLgn08pRqSVioyWvnGfaQ8gond2wf7Jq2jJ+24ykmnRyiz3fIldcn4oUuQXpjqKLhSVR7lw==
|
||||||
|
|
||||||
|
inflight@^1.0.4:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||||
|
integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
|
||||||
|
dependencies:
|
||||||
|
once "^1.3.0"
|
||||||
|
wrappy "1"
|
||||||
|
|
||||||
|
inherits@2:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
"js-tokens@^3.0.0 || ^4.0.0":
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
|
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||||
|
|
||||||
|
lodash.clonedeep@^4.5.0:
|
||||||
|
version "4.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||||
|
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
|
||||||
|
|
||||||
|
lodash.debounce@^4.0.8:
|
||||||
|
version "4.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||||
|
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
||||||
|
|
||||||
|
lodash.throttle@^4.1.1:
|
||||||
|
version "4.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
|
||||||
|
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
|
||||||
|
|
||||||
|
lodash@^4.17.21:
|
||||||
|
version "4.17.21"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
|
||||||
|
loose-envify@^1.1.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
|
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||||
|
dependencies:
|
||||||
|
js-tokens "^3.0.0 || ^4.0.0"
|
||||||
|
|
||||||
|
"memoize-one@>=3.1.1 <6":
|
||||||
|
version "5.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||||
|
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||||
|
|
||||||
|
minimatch@^3.0.4:
|
||||||
|
version "3.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||||
|
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
|
minimatch@~3.0.2:
|
||||||
|
version "3.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1"
|
||||||
|
integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
|
ms@2.1.2:
|
||||||
|
version "2.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||||
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
|
|
||||||
|
once@^1.3.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||||
|
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
|
||||||
|
dependencies:
|
||||||
|
wrappy "1"
|
||||||
|
|
||||||
|
path-is-absolute@^1.0.0:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||||
|
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
|
||||||
|
|
||||||
|
react-dnd-html5-backend@^16.0.1:
|
||||||
|
version "16.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
|
||||||
|
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
|
||||||
|
dependencies:
|
||||||
|
dnd-core "^16.0.1"
|
||||||
|
|
||||||
|
react-dnd@^16.0.1:
|
||||||
|
version "16.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
|
||||||
|
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
|
||||||
|
dependencies:
|
||||||
|
"@react-dnd/invariant" "^4.0.1"
|
||||||
|
"@react-dnd/shallowequal" "^4.0.1"
|
||||||
|
dnd-core "^16.0.1"
|
||||||
|
fast-deep-equal "^3.1.3"
|
||||||
|
hoist-non-react-statics "^3.3.2"
|
||||||
|
|
||||||
|
react-dom@^18.2.0:
|
||||||
|
version "18.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||||
|
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.1.0"
|
||||||
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
|
react-is@^16.7.0:
|
||||||
|
version "16.13.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||||
|
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||||
|
|
||||||
|
react-virtualized-auto-sizer@^1.0.7:
|
||||||
|
version "1.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz#bfb8414698ad1597912473de3e2e5f82180c1195"
|
||||||
|
integrity sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==
|
||||||
|
|
||||||
|
react-window@^1.8.8:
|
||||||
|
version "1.8.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243"
|
||||||
|
integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.0.0"
|
||||||
|
memoize-one ">=3.1.1 <6"
|
||||||
|
|
||||||
|
react@^18.2.0:
|
||||||
|
version "18.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||||
|
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.1.0"
|
||||||
|
|
||||||
|
redux@^4.2.0:
|
||||||
|
version "4.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
|
||||||
|
integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.9.2"
|
||||||
|
|
||||||
|
regenerator-runtime@^0.13.4:
|
||||||
|
version "0.13.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
|
||||||
|
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
|
||||||
|
|
||||||
|
run-when-changed@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/run-when-changed/-/run-when-changed-2.1.0.tgz#2e76d6ff6014d38786a3a11b98e9291c7e934953"
|
||||||
|
integrity sha512-ge/wuPQAvQz0uDEOO8W2c3g4mqXDa1XXtnJmjP9+6Nqfb+XdUYkQX/KvKmSvJ4xG5weD3RGm0u2Q3UsGzAo5gw==
|
||||||
|
dependencies:
|
||||||
|
ansi-bold "^0.1.1"
|
||||||
|
commander "^2.15.1"
|
||||||
|
fs-find-root "^2.0.0"
|
||||||
|
gaze "^1.1.2"
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
|
||||||
|
scheduler@^0.23.0:
|
||||||
|
version "0.23.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||||
|
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.1.0"
|
||||||
|
|
||||||
|
socket.io-client@^4.5.3:
|
||||||
|
version "4.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.3.tgz#bed69209d001465b2fea650d2e95c1e82768ab5e"
|
||||||
|
integrity sha512-I/hqDYpQ6JKwtJOf5ikM+Qz+YujZPMEl6qBLhxiP0nX+TfXKhW4KZZG8lamrD6Y5ngjmYHreESVasVCgi5Kl3A==
|
||||||
|
dependencies:
|
||||||
|
"@socket.io/component-emitter" "~3.1.0"
|
||||||
|
debug "~4.3.2"
|
||||||
|
engine.io-client "~6.2.3"
|
||||||
|
socket.io-parser "~4.2.0"
|
||||||
|
|
||||||
|
socket.io-parser@~4.2.0:
|
||||||
|
version "4.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5"
|
||||||
|
integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==
|
||||||
|
dependencies:
|
||||||
|
"@socket.io/component-emitter" "~3.1.0"
|
||||||
|
debug "~4.3.1"
|
||||||
|
|
||||||
|
typescript@^4.8.4:
|
||||||
|
version "4.8.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
|
||||||
|
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
|
||||||
|
|
||||||
|
weak-key@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/weak-key/-/weak-key-1.0.2.tgz#dd5f66648ffb7e83810ea0553a948c60b2b50588"
|
||||||
|
integrity sha512-x9y9moPEcom985nUdHxM+YWbMcP3Ru+fmYqVNHSb6djJGg7H6Ru2ohuzaVIXx1JNyp8E7GO7GsBnehRntaBlsg==
|
||||||
|
dependencies:
|
||||||
|
core-js "^2.4.0"
|
||||||
|
|
||||||
|
wrappy@1:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
|
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||||
|
|
||||||
|
ws@~8.2.3:
|
||||||
|
version "8.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
|
||||||
|
integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@~2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
|
||||||
|
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
|
|
@ -15,8 +15,6 @@ services:
|
||||||
links:
|
links:
|
||||||
- "redis"
|
- "redis"
|
||||||
- "postgres"
|
- "postgres"
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- postgres
|
- postgres
|
||||||
|
@ -39,3 +37,13 @@ services:
|
||||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./bootstrap/nginx_dev.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
depends_on:
|
||||||
|
- site
|
||||||
|
|
|
@ -4874,6 +4874,7 @@ img.golden, img[g] {
|
||||||
.fa-x:before{content:"\58"}
|
.fa-x:before{content:"\58"}
|
||||||
.fa-scale-balanced:before{content:"\f24e"}
|
.fa-scale-balanced:before{content:"\f24e"}
|
||||||
.fa-hippo:before{content:"\f6ed"}
|
.fa-hippo:before{content:"\f6ed"}
|
||||||
|
.fa-booth:before{content:"\f734"}
|
||||||
|
|
||||||
.awards-wrapper input[type="radio"] {
|
.awards-wrapper input[type="radio"] {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 11 KiB |
File diff suppressed because one or more lines are too long
|
@ -56,6 +56,7 @@ from urllib.parse import urlencode, urlparse, parse_qs
|
||||||
from .alts import Alt
|
from .alts import Alt
|
||||||
from .award import AwardRelationship
|
from .award import AwardRelationship
|
||||||
from .badges import BadgeDef, Badge
|
from .badges import BadgeDef, Badge
|
||||||
|
from .chat_message import ChatMessage
|
||||||
from .clients import OauthApp, ClientAuth
|
from .clients import OauthApp, ClientAuth
|
||||||
from .comment import Comment
|
from .comment import Comment
|
||||||
from .domains import BannedDomain
|
from .domains import BannedDomain
|
||||||
|
|
36
files/classes/chat_message.py
Normal file
36
files/classes/chat_message.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
|
||||||
|
from files.classes.base import CreatedDateTimeBase
|
||||||
|
from files.helpers.lazy import lazy
|
||||||
|
from sqlalchemy import *
|
||||||
|
from sqlalchemy.orm import declared_attr, relationship
|
||||||
|
|
||||||
|
class ChatMessage(CreatedDateTimeBase):
|
||||||
|
__tablename__ = "chat_message"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
quote_id = Column(Integer, ForeignKey("chat_message.id"), nullable=True)
|
||||||
|
text = Column(String, nullable=False)
|
||||||
|
text_html = Column(String, nullable=False)
|
||||||
|
|
||||||
|
author = relationship("User", primaryjoin="User.id==ChatMessage.author_id")
|
||||||
|
|
||||||
|
@declared_attr
|
||||||
|
def created_datetimez_index(self):
|
||||||
|
return Index('created_datetimez_index', self.created_datetimez)
|
||||||
|
|
||||||
|
Index('quote_index', quote_id)
|
||||||
|
|
||||||
|
@lazy
|
||||||
|
def json_speak(self):
|
||||||
|
data = {
|
||||||
|
'id': str(self.id),
|
||||||
|
'quotes': None if self.quote_id is None else str(self.quote_id),
|
||||||
|
'avatar': self.author.profile_url,
|
||||||
|
'username': self.author.username,
|
||||||
|
'text': self.text,
|
||||||
|
'text_html': self.text_html,
|
||||||
|
'time': int(self.created_datetimez.timestamp()),
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
|
@ -12,6 +12,7 @@ from files.classes.alts import Alt
|
||||||
from files.classes.award import AwardRelationship
|
from files.classes.award import AwardRelationship
|
||||||
from files.classes.badges import Badge
|
from files.classes.badges import Badge
|
||||||
from files.classes.base import CreatedBase
|
from files.classes.base import CreatedBase
|
||||||
|
from files.classes.chat_message import ChatMessage
|
||||||
from files.classes.clients import * # note: imports Comment and Submission
|
from files.classes.clients import * # note: imports Comment and Submission
|
||||||
from files.classes.follows import Follow
|
from files.classes.follows import Follow
|
||||||
from files.classes.mod_logs import ModAction
|
from files.classes.mod_logs import ModAction
|
||||||
|
@ -112,6 +113,9 @@ class User(CreatedBase):
|
||||||
volunteer_last_started_utc = Column(DateTime, nullable=True)
|
volunteer_last_started_utc = Column(DateTime, nullable=True)
|
||||||
volunteer_janitor_correctness = Column(Float, default=0, nullable=False)
|
volunteer_janitor_correctness = Column(Float, default=0, nullable=False)
|
||||||
|
|
||||||
|
chat_authorized = Column(Boolean, default=False, nullable=False)
|
||||||
|
chat_lastseen = Column(DateTime(timezone=True), default=datetime(1970, 1, 1), nullable=False)
|
||||||
|
|
||||||
Index(
|
Index(
|
||||||
'users_original_username_trgm_idx',
|
'users_original_username_trgm_idx',
|
||||||
original_username,
|
original_username,
|
||||||
|
@ -136,6 +140,8 @@ class User(CreatedBase):
|
||||||
Index('users_subs_idx', stored_subscriber_count)
|
Index('users_subs_idx', stored_subscriber_count)
|
||||||
Index('users_unbanutc_idx', unban_utc.desc())
|
Index('users_unbanutc_idx', unban_utc.desc())
|
||||||
|
|
||||||
|
Index('chat_auth_index', chat_authorized)
|
||||||
|
|
||||||
badges = relationship("Badge", viewonly=True)
|
badges = relationship("Badge", viewonly=True)
|
||||||
subscriptions = relationship("Subscription", viewonly=True)
|
subscriptions = relationship("Subscription", viewonly=True)
|
||||||
following = relationship("Follow", primaryjoin="Follow.user_id==User.id", viewonly=True)
|
following = relationship("Follow", primaryjoin="Follow.user_id==User.id", viewonly=True)
|
||||||
|
@ -157,6 +163,30 @@ class User(CreatedBase):
|
||||||
def can_manage_reports(self):
|
def can_manage_reports(self):
|
||||||
return self.admin_level > 1
|
return self.admin_level > 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lazy
|
||||||
|
def can_access_chat(self):
|
||||||
|
if self.is_suspended_permanently:
|
||||||
|
return False
|
||||||
|
if self.admin_level >= PERMS['CHAT_FULL_CONTROL']:
|
||||||
|
return True
|
||||||
|
if self.chat_authorized:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lazy
|
||||||
|
def unread_chat_messages_count(self):
|
||||||
|
if not self.can_access_chat:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Query for all chat messages that are newer than the user's last seen timestamp
|
||||||
|
unread_messages_count = g.db.query(ChatMessage)\
|
||||||
|
.filter(ChatMessage.created_datetimez > self.chat_lastseen)\
|
||||||
|
.count()
|
||||||
|
|
||||||
|
return unread_messages_count
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def age_days(self):
|
def age_days(self):
|
||||||
return (datetime.now() - datetime.fromtimestamp(self.created_utc)).days
|
return (datetime.now() - datetime.fromtimestamp(self.created_utc)).days
|
||||||
|
|
|
@ -93,6 +93,7 @@ COMMENT_BODY_LENGTH_MAXIMUM: Final[int] = 500000
|
||||||
COMMENT_BODY_LENGTH_MAXIMUM_UNFILTERED: Final[int] = 50000
|
COMMENT_BODY_LENGTH_MAXIMUM_UNFILTERED: Final[int] = 50000
|
||||||
MESSAGE_BODY_LENGTH_MAXIMUM: Final[int] = 10000
|
MESSAGE_BODY_LENGTH_MAXIMUM: Final[int] = 10000
|
||||||
CSS_LENGTH_MAXIMUM: Final[int] = 4000
|
CSS_LENGTH_MAXIMUM: Final[int] = 4000
|
||||||
|
CHAT_LENGTH_LIMIT: Final[int] = 1000
|
||||||
|
|
||||||
ERROR_MESSAGES = {
|
ERROR_MESSAGES = {
|
||||||
400: "That request was bad and you should feel bad",
|
400: "That request was bad and you should feel bad",
|
||||||
|
@ -140,6 +141,9 @@ FEATURES = {
|
||||||
|
|
||||||
PERMS = {
|
PERMS = {
|
||||||
"DEBUG_LOGIN_TO_OTHERS": 3,
|
"DEBUG_LOGIN_TO_OTHERS": 3,
|
||||||
|
"CHAT_BYPASS_MUTE": 2,
|
||||||
|
"CHAT_MODERATION": 2,
|
||||||
|
"CHAT_FULL_CONTROL": 3,
|
||||||
"PERFORMANCE_KILL_PROCESS": 3,
|
"PERFORMANCE_KILL_PROCESS": 3,
|
||||||
"PERFORMANCE_SCALE_UP_DOWN": 3,
|
"PERFORMANCE_SCALE_UP_DOWN": 3,
|
||||||
"PERFORMANCE_RELOAD": 3,
|
"PERFORMANCE_RELOAD": 3,
|
||||||
|
|
|
@ -41,7 +41,7 @@ whitespace_regex = re.compile('\\s+')
|
||||||
|
|
||||||
strikethrough_regex = re.compile('''~{1,2}([^~]+)~{1,2}''', flags=re.A)
|
strikethrough_regex = re.compile('''~{1,2}([^~]+)~{1,2}''', flags=re.A)
|
||||||
|
|
||||||
mute_regex = re.compile("/mute @([a-z0-9_\\-]{3,25}) ([0-9])+", flags=re.A)
|
chat_command_regex = re.compile(r"^/(\w+)\s?(.*)", flags=re.A)
|
||||||
|
|
||||||
emoji_regex = re.compile(f"[^a]>\\s*(:[!#@]{{0,3}}[{valid_username_chars}]+:\\s*)+<\\/", flags=re.A)
|
emoji_regex = re.compile(f"[^a]>\\s*(:[!#@]{{0,3}}[{valid_username_chars}]+:\\s*)+<\\/", flags=re.A)
|
||||||
emoji_regex2 = re.compile(f"(?<!\"):([!#@{valid_username_chars}]{{1,31}}?):", flags=re.A)
|
emoji_regex2 = re.compile(f"(?<!\"):([!#@{valid_username_chars}]{{1,31}}?):", flags=re.A)
|
||||||
|
|
|
@ -1,117 +1,270 @@
|
||||||
|
import functools
|
||||||
import time
|
import time
|
||||||
from files.helpers.config.environment import SITE, SITE_FULL
|
import uuid
|
||||||
from files.helpers.wrappers import auth_required
|
from typing import Any, Final
|
||||||
from files.helpers.sanitize import sanitize
|
|
||||||
|
from flask_socketio import SocketIO, emit, disconnect
|
||||||
|
|
||||||
|
from files.__main__ import app, cache, limiter
|
||||||
|
from files.helpers.alerts import *
|
||||||
from files.helpers.config.const import *
|
from files.helpers.config.const import *
|
||||||
from datetime import datetime
|
from files.helpers.config.environment import *
|
||||||
from flask_socketio import SocketIO, emit
|
from files.helpers.config.regex import *
|
||||||
from files.__main__ import app, limiter, cache
|
from files.helpers.sanitize import sanitize
|
||||||
from flask import render_template, make_response, send_from_directory
|
from files.helpers.wrappers import get_logged_in_user, is_not_permabanned, admin_level_required
|
||||||
import sys
|
|
||||||
import atexit
|
|
||||||
|
|
||||||
if SITE == 'localhost':
|
def chat_is_allowed(perm_level: int=0):
|
||||||
socketio = SocketIO(app, async_mode='gevent', cors_allowed_origins=[SITE_FULL], logger=True, engineio_logger=True, debug=True)
|
def wrapper_maker(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> bool | None:
|
||||||
|
v = get_logged_in_user()
|
||||||
|
if not v:
|
||||||
|
abort(403)
|
||||||
|
if not v.can_access_chat:
|
||||||
|
abort(403)
|
||||||
|
if v.admin_level < perm_level:
|
||||||
|
abort(403)
|
||||||
|
kwargs['v'] = v
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return wrapper_maker
|
||||||
|
|
||||||
|
commands = {}
|
||||||
|
|
||||||
|
def register_command(cmd_name, permission_level = 0):
|
||||||
|
def decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> bool | None:
|
||||||
|
v = get_logged_in_user()
|
||||||
|
if v.admin_level < permission_level:
|
||||||
|
send_system_reply(f"Unknown command: {cmd_name}")
|
||||||
|
return False
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
commands[cmd_name] = wrapper
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
if app.debug:
|
||||||
|
socketio = SocketIO(
|
||||||
|
app,
|
||||||
|
async_mode='gevent',
|
||||||
|
logger=True,
|
||||||
|
engineio_logger=True,
|
||||||
|
debug=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
socketio = SocketIO(app, async_mode='gevent', cors_allowed_origins=[SITE_FULL])
|
socketio = SocketIO(
|
||||||
|
app,
|
||||||
|
async_mode='gevent',
|
||||||
|
)
|
||||||
|
|
||||||
typing = []
|
CHAT_SCROLLBACK_ITEMS: Final[int] = 500
|
||||||
online = []
|
|
||||||
muted = cache.get(f'{SITE}_muted') or {}
|
typing: list[str] = []
|
||||||
messages = cache.get(f'{SITE}_chat') or []
|
online: list[str] = [] # right now we maintain this but don't actually use it anywhere
|
||||||
total = cache.get(f'{SITE}_total') or 0
|
connected_users = set()
|
||||||
|
|
||||||
|
def send_system_reply(text):
|
||||||
|
data = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"avatar": g.db.query(User).filter(User.id == NOTIFICATIONS_ID).one().profile_url,
|
||||||
|
"user_id": NOTIFICATIONS_ID,
|
||||||
|
"username": "System",
|
||||||
|
"text": text,
|
||||||
|
"text_html": sanitize(text),
|
||||||
|
'time': time.time(),
|
||||||
|
}
|
||||||
|
emit('speak', data)
|
||||||
|
|
||||||
|
def get_chat_messages():
|
||||||
|
# Query for the last visible chat messages
|
||||||
|
result = (g.db.query(ChatMessage)
|
||||||
|
.join(User, User.id == ChatMessage.author_id) # Join with the User table to fetch related user data
|
||||||
|
.order_by(ChatMessage.created_datetimez.desc())
|
||||||
|
.limit(CHAT_SCROLLBACK_ITEMS)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
# Convert the list of ChatMessage objects into a list of dictionaries
|
||||||
|
# Also, most recent at the bottom, not the top.
|
||||||
|
messages = [item.json_speak() for item in result[::-1]]
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def get_chat_userlist():
|
||||||
|
# Query for the User.username column for users with chat_authorized == True
|
||||||
|
result = g.db.query(User.username).filter(User.chat_authorized == True).all()
|
||||||
|
|
||||||
|
# Convert the list of tuples into a flat list of usernames
|
||||||
|
userlist = [item[0] for item in result]
|
||||||
|
|
||||||
|
return userlist
|
||||||
|
|
||||||
@app.get("/chat")
|
@app.get("/chat")
|
||||||
@auth_required
|
@is_not_permabanned
|
||||||
|
@chat_is_allowed()
|
||||||
def chat(v):
|
def chat(v):
|
||||||
return render_template("chat.html", v=v, messages=messages)
|
return render_template("chat.html", v=v)
|
||||||
|
|
||||||
|
|
||||||
@app.get('/chat.js')
|
|
||||||
@limiter.exempt
|
|
||||||
def chatjs():
|
|
||||||
resp = make_response(send_from_directory('assets', 'js/chat.js'))
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@socketio.on('speak')
|
@socketio.on('speak')
|
||||||
@limiter.limit("3/second;10/minute")
|
@limiter.limit("3/second")
|
||||||
@auth_required
|
@chat_is_allowed()
|
||||||
def speak(data, v):
|
def speak(data, v):
|
||||||
limiter.check()
|
limiter.check()
|
||||||
if v.is_banned: return '', 403
|
if v.is_banned: return '', 403
|
||||||
|
|
||||||
vname = v.username.lower()
|
text = sanitize_raw(
|
||||||
if vname in muted:
|
data['message'],
|
||||||
if time.time() < muted[vname]: return '', 403
|
allow_newlines=True,
|
||||||
else: del muted[vname]
|
length_limit=CHAT_LENGTH_LIMIT,
|
||||||
|
)
|
||||||
|
if not text: return '', 400
|
||||||
|
|
||||||
|
command = chat_command_regex.match(text)
|
||||||
|
if command:
|
||||||
|
command_name = command.group(1).lower()
|
||||||
|
command_parameters = command.group(2)
|
||||||
|
|
||||||
|
if command_name in commands:
|
||||||
|
commands[command_name](command_parameters)
|
||||||
|
else:
|
||||||
|
send_system_reply(f"Unknown command: {command_name}")
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
global messages, total
|
|
||||||
text = data[:1000].strip()
|
|
||||||
if not text: return '', 403
|
|
||||||
text_html = sanitize(text)
|
text_html = sanitize(text)
|
||||||
|
quotes = data['quotes']
|
||||||
|
|
||||||
data={
|
chat_message = ChatMessage()
|
||||||
"avatar": v.profile_url,
|
chat_message.author_id = v.id
|
||||||
"username": v.username,
|
chat_message.quote_id = quotes
|
||||||
"namecolor": v.namecolor,
|
chat_message.text = text
|
||||||
"text": text,
|
chat_message.text_html = text_html
|
||||||
"text_html": text_html,
|
g.db.add(chat_message)
|
||||||
"text_censored": text,
|
g.db.commit()
|
||||||
"time": int(time.time())
|
|
||||||
}
|
|
||||||
|
|
||||||
if v.shadowbanned:
|
emit('speak', chat_message.json_speak(), broadcast=True)
|
||||||
emit('speak', data)
|
|
||||||
else:
|
|
||||||
emit('speak', data, broadcast=True)
|
|
||||||
messages.append(data)
|
|
||||||
messages = messages[-50:]
|
|
||||||
|
|
||||||
total += 1
|
|
||||||
|
|
||||||
if v.admin_level >= 2:
|
|
||||||
text = text.lower()
|
|
||||||
for i in mute_regex.finditer(text):
|
|
||||||
username = i.group(1)
|
|
||||||
duration = int(int(i.group(2)) * 60 + time.time())
|
|
||||||
muted[username] = duration
|
|
||||||
|
|
||||||
return '', 204
|
|
||||||
|
|
||||||
@socketio.on('connect')
|
@socketio.on('connect')
|
||||||
@auth_required
|
@chat_is_allowed()
|
||||||
def connect(v):
|
def onConnect(v):
|
||||||
if v.username not in online:
|
if v.username not in online:
|
||||||
online.append(v.username)
|
online.append(v.username)
|
||||||
emit("online", online, broadcast=True)
|
|
||||||
|
|
||||||
|
connected_users.add(request.sid)
|
||||||
|
|
||||||
|
emit('online', get_chat_userlist())
|
||||||
|
emit('catchup', get_chat_messages())
|
||||||
emit('typing', typing)
|
emit('typing', typing)
|
||||||
return '', 204
|
|
||||||
|
|
||||||
@socketio.on('disconnect')
|
@socketio.on('disconnect')
|
||||||
@auth_required
|
@chat_is_allowed()
|
||||||
def disconnect(v):
|
def onDisconnect(v):
|
||||||
if v.username in online:
|
if v.username in online:
|
||||||
online.remove(v.username)
|
online.remove(v.username)
|
||||||
emit("online", online, broadcast=True)
|
|
||||||
|
|
||||||
if v.username in typing: typing.remove(v.username)
|
if v.username in typing: typing.remove(v.username)
|
||||||
|
|
||||||
|
connected_users.remove(request.sid)
|
||||||
|
|
||||||
emit('typing', typing, broadcast=True)
|
emit('typing', typing, broadcast=True)
|
||||||
return '', 204
|
|
||||||
|
|
||||||
@socketio.on('typing')
|
@socketio.on('typing')
|
||||||
@auth_required
|
@chat_is_allowed()
|
||||||
def typing_indicator(data, v):
|
def typing_indicator(data, v):
|
||||||
if data and v.username not in typing: typing.append(v.username)
|
if data and v.username not in typing:
|
||||||
elif not data and v.username in typing: typing.remove(v.username)
|
typing.append(v.username)
|
||||||
|
elif not data and v.username in typing:
|
||||||
|
typing.remove(v.username)
|
||||||
|
|
||||||
emit('typing', typing, broadcast=True)
|
emit('typing', typing, broadcast=True)
|
||||||
return '', 204
|
|
||||||
|
|
||||||
|
|
||||||
def close_running_threads():
|
@socketio.on('read')
|
||||||
cache.set(f'{SITE}_chat', messages)
|
@chat_is_allowed()
|
||||||
cache.set(f'{SITE}_total', total)
|
def read(data, v):
|
||||||
cache.set(f'{SITE}_muted', muted)
|
limiter.check()
|
||||||
atexit.register(close_running_threads)
|
if v.is_banned: return '', 403
|
||||||
|
|
||||||
|
# This value gets truncated at some point in the pipeline and I haven't really spent time to figure out where.
|
||||||
|
# Instead, we just bump it by one.
|
||||||
|
timestamp = datetime.fromtimestamp(int(data) + 1)
|
||||||
|
|
||||||
|
v.chat_lastseen = timestamp
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on('delete')
|
||||||
|
@chat_is_allowed(PERMS['CHAT_MODERATION'])
|
||||||
|
def delete(id, v):
|
||||||
|
chat_message = g.db.query(ChatMessage).filter(ChatMessage.id == id).one_or_none()
|
||||||
|
if chat_message:
|
||||||
|
# Zero out all the quote_id references to this message
|
||||||
|
messages_quoting_this = g.db.query(ChatMessage).filter(ChatMessage.quote_id == id).all()
|
||||||
|
for message in messages_quoting_this:
|
||||||
|
message.quote_id = None
|
||||||
|
|
||||||
|
# Now, delete the chat_message
|
||||||
|
g.db.delete(chat_message)
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
emit('delete', id, broadcast=True)
|
||||||
|
|
||||||
|
@register_command('add', PERMS['CHAT_FULL_CONTROL'])
|
||||||
|
def add(user):
|
||||||
|
print("Adding user", user)
|
||||||
|
user_instance = g.db.query(User).filter(func.lower(User.username) == user.lower()).one_or_none()
|
||||||
|
|
||||||
|
if user_instance:
|
||||||
|
if user_instance.chat_authorized:
|
||||||
|
send_system_reply(f"{user} already in this chat.")
|
||||||
|
else:
|
||||||
|
user_instance.chat_authorized = True
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
emit('online', get_chat_userlist(), broadcast=True)
|
||||||
|
|
||||||
|
send_system_reply(f"Added {user} to chat.")
|
||||||
|
else:
|
||||||
|
send_system_reply(f"Could not find user {user}.")
|
||||||
|
|
||||||
|
|
||||||
|
@register_command('remove', PERMS['CHAT_FULL_CONTROL'])
|
||||||
|
def remove(user):
|
||||||
|
print("Removing user", user)
|
||||||
|
user_instance = g.db.query(User).filter(func.lower(User.username) == user.lower()).one_or_none()
|
||||||
|
|
||||||
|
if user_instance:
|
||||||
|
if not user_instance.chat_authorized:
|
||||||
|
send_system_reply(f"{user} already not in this chat.")
|
||||||
|
else:
|
||||||
|
user_instance.chat_authorized = False
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
emit('online', get_chat_userlist(), broadcast=True)
|
||||||
|
|
||||||
|
send_system_reply(f"Removed {user} from chat.")
|
||||||
|
else:
|
||||||
|
send_system_reply(f"Could not find user {user}.")
|
||||||
|
|
||||||
|
|
||||||
|
@register_command('reset_everything_seriously', PERMS['CHAT_FULL_CONTROL'])
|
||||||
|
def reset_everything_seriously(_):
|
||||||
|
# Boot everyone
|
||||||
|
for user_sid in list(connected_users): # Loop through a shallow copy to avoid modification issues
|
||||||
|
disconnect(sid=user_sid)
|
||||||
|
|
||||||
|
# Set chat_authorized to False for all users
|
||||||
|
g.db.query(User).update({User.chat_authorized: False})
|
||||||
|
|
||||||
|
# Delete all ChatMessage entries
|
||||||
|
g.db.query(ChatMessage).delete()
|
||||||
|
|
||||||
|
# Commit the changes to the database
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
|
|
@ -1,191 +1,24 @@
|
||||||
<!DOCTYPE html>
|
{% extends "default.html" %}
|
||||||
<html lang="en">
|
{% block pagetype %}chat{% endblock %}
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
{% include "analytics.html" %}
|
|
||||||
<meta name="description" content="{{config('DESCRIPTION')}}">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
|
||||||
<link id="favicon" rel="icon" type="image/png" href="{{ ('images/'~SITE_ID~'/icon.webp') | asset }}">
|
|
||||||
|
|
||||||
<title>Chat</title>
|
{% block fixedMobileBarJS %}
|
||||||
|
<link rel="stylesheet" href="{{'css/chat_done.css' | asset}}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<style>:root{--primary:#{{v.themecolor}}}</style>
|
{% block Banner %}{% endblock %}
|
||||||
<link rel="stylesheet" href="{{ 'css/main.css' | asset }}">
|
{% block mobilenavbar %}{% endblock %}
|
||||||
<link rel="stylesheet" href="{{ ('css/'~v.theme~'.css') | asset }}">
|
|
||||||
{% if v.css %}
|
|
||||||
<style>{{v.css | safe}}</style>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#chat-window {
|
|
||||||
max-height: calc(100vh - 220px);
|
|
||||||
overflow-y: scroll;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat-window .chat-profile {
|
|
||||||
min-width: 42px;
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat-window::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat-window {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-mention {
|
|
||||||
background-color: #{{v.themecolor}}55;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-message p {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#shrink * {
|
|
||||||
font-size: 10px !important;
|
|
||||||
}
|
|
||||||
.fa-reply:before {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
.diff {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
#chat-window {
|
|
||||||
max-height: calc(100vh - 180px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
display: inline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
margin: 5px 0 5px 0;
|
|
||||||
padding: 0.3rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#online {
|
|
||||||
background-color: var(--background) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-line .btn {
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cdiv {
|
|
||||||
overflow: hidden;
|
|
||||||
margin-left: 27px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote {
|
|
||||||
display: inline-block !important;
|
|
||||||
padding: 0 0.5rem !important;
|
|
||||||
margin-bottom: 0.25rem !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<script src="{{ 'js/bootstrap.js' | asset }}"></script>
|
|
||||||
|
|
||||||
{% include "header.html" %}
|
|
||||||
|
|
||||||
<div class="container pb-4">
|
|
||||||
<div class="row justify-content-around" id="main-content-row">
|
|
||||||
<div class="col h-100 {% block customPadding %}{% if request.path.startswith('/@') %}user-gutters{% else %}custom-gutters{% endif %}{% endblock %}" id="main-content-col">
|
|
||||||
|
|
||||||
<div class="border-right pb-1 pt-2 px-3">
|
|
||||||
<span data-bs-toggle="tooltip" data-bs-placement="bottom" title="Users online right now" class="text-muted">
|
|
||||||
<i class="far fa-user fa-sm mr-1"></i>
|
|
||||||
<span class="board-chat-count">0</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="chat-line-template" class="d-none">
|
|
||||||
<div class="chat-line">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="pl-md-3 text-muted">
|
|
||||||
<div>
|
|
||||||
<img class="avatar pp20 mr-1" data-toggle="tooltip" data-placement="right">
|
|
||||||
<a href="" class="font-weight-bold text-black userlink" target="_blank"></a>
|
|
||||||
<div class="cdiv">
|
|
||||||
<span class="chat-message text-black text-break"></span>
|
|
||||||
<span class="text d-none"></span>
|
|
||||||
<button class="quote btn" onclick="quote(this)"><i class="fas fa-reply" aria-hidden="true"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="shrink">
|
|
||||||
<div id="chat-window" class="container pl-0 py-0">
|
|
||||||
{% for m in messages %}
|
|
||||||
{% set text_html = m['text_censored'] if v.slurreplacer else m['text_html'] %}
|
|
||||||
{% set link = '<a href="/id/' + v.id|string + '">' %}
|
|
||||||
{% set same = loop.index > 1 and m['username'] == messages[loop.index-2]['username'] %}
|
|
||||||
<div class="chat-line {% if link in text_html %}chat-mention{% endif %} {% if not same %}diff{% endif %}">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="pl-md-3 text-muted">
|
|
||||||
<div>
|
|
||||||
{% if not same %}
|
|
||||||
<img src="{{m['avatar']}}" class="avatar pp20 mr-1" data-toggle="tooltip" data-placement="right">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<a class="{% if same %}d-none{% endif %} font-weight-bold text-black userlink" style="color:#{{m['namecolor']}}" target="_blank" href="/@{{m['username']}}">{{m['username']}}</a>
|
|
||||||
|
|
||||||
{% if not same %}
|
|
||||||
<span class="text-black time ml-2">{{m['time'] | agestamp}}</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="cdiv">
|
|
||||||
<span class="chat-message text-black text-break">{{text_html | safe}}</span>
|
|
||||||
<span class="d-none">{{m['text']}}</span>
|
|
||||||
<button class="quote btn" onclick="quote(this)"><i class="fas fa-reply" aria-hidden="true"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id='message' class="d-none position-relative form-group d-flex mt-3">
|
|
||||||
<div class="position-absolute text-muted text-small ml-1" style="bottom: -1.5rem; line-height: 1;">
|
|
||||||
<span id="typing-indicator"></span>
|
|
||||||
<span id="loading-indicator" class="d-none"></span>
|
|
||||||
</div>
|
|
||||||
<textarea id="input-text" minlength="1" maxlength="1000" type="text" class="form-control" placeholder="Message" autocomplete="off" autofocus rows="1"></textarea>
|
|
||||||
<button id="chatsend" onclick="send()" class="btn btn-primary ml-3" type="submit">Send</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="online" class="col sidebar text-left d-none d-lg-block pt-3 bg-white" style="max-width:300px">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% block defaultContainer %}
|
||||||
|
{% include "component/modal/expanded_image.html" %}
|
||||||
|
<div
|
||||||
|
id="root"
|
||||||
|
data-id="{{v.id}}"
|
||||||
|
data-username="{{v.username}}"
|
||||||
|
data-admin="{{v.admin_level >= PERMS['CHAT_MODERATION']}}"
|
||||||
|
data-sitename="{{SITE_ID}}"
|
||||||
|
data-themecolor="{{v.themecolor}}"
|
||||||
|
data-avatar="{{v.profile_url}}">
|
||||||
</div>
|
</div>
|
||||||
|
<script>window.global = window</script>
|
||||||
</div>
|
<script defer src="{{'js/chat_done.js' | asset}}"></script>
|
||||||
|
{% endblock %}
|
||||||
<input id="vid" type="hidden" value="{{v.id}}">
|
|
||||||
<input id="site_id" type="hidden" value="{{SITE_ID}}">
|
|
||||||
<input id="slurreplacer" type="hidden" value="{{v.slurreplacer}}">
|
|
||||||
|
|
||||||
<script src="/chat.js?v=16"></script>
|
|
||||||
|
|
||||||
<script src="{{ 'js/lozad.js' | asset }}"></script>
|
|
||||||
<script src="{{ 'js/lite-youtube.js' | asset }}"></script>
|
|
||||||
|
|
||||||
</body>
|
|
|
@ -227,6 +227,7 @@
|
||||||
{% block postNav %}
|
{% block postNav %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block defaultContainer %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row justify-content-around" id="main-content-row">
|
<div class="row justify-content-around" id="main-content-row">
|
||||||
|
|
||||||
|
@ -255,6 +256,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block mobilenavbar %}
|
{% block mobilenavbar %}
|
||||||
{% include "mobile_navigation_bar.html" %}
|
{% include "mobile_navigation_bar.html" %}
|
||||||
|
|
|
@ -26,6 +26,14 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if v %}
|
{% if v %}
|
||||||
|
{% if v.can_access_chat %}
|
||||||
|
{% if v.unread_chat_messages_count > 0 %}
|
||||||
|
<a class="mobile-nav-icon d-md-none pl-0" href="/chat" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Voting Booth"><i class="fas fa-booth align-middle text-danger"></i><span class="notif-count ml-1" style="padding-left: 4.5px;">{{v.unread_chat_messages_count}}</span></a>
|
||||||
|
{% else %}
|
||||||
|
<a class="mobile-nav-icon d-md-none" href="/chat" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Voting Booth"><i class="fas fa-booth align-middle text-gray-500 black"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if v.notifications_count %}
|
{% if v.notifications_count %}
|
||||||
<a class="mobile-nav-icon d-md-none pl-0" href="/notifications{% if v.do_posts %}/posts{% endif %}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Notifications"><i class="fas fa-bell align-middle text-danger" {% if v.do_posts %}style="color:blue!important"{% endif %}></i><span class="notif-count ml-1" style="padding-left: 4.5px;{% if v.do_posts %}background:blue{% endif %}">{{v.notifications_count}}</span></a>
|
<a class="mobile-nav-icon d-md-none pl-0" href="/notifications{% if v.do_posts %}/posts{% endif %}" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Notifications"><i class="fas fa-bell align-middle text-danger" {% if v.do_posts %}style="color:blue!important"{% endif %}></i><span class="notif-count ml-1" style="padding-left: 4.5px;{% if v.do_posts %}background:blue{% endif %}">{{v.notifications_count}}</span></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -54,6 +62,18 @@
|
||||||
<ul class="navbar-nav ml-auto d-none d-md-flex">
|
<ul class="navbar-nav ml-auto d-none d-md-flex">
|
||||||
|
|
||||||
{% if v %}
|
{% if v %}
|
||||||
|
{% if v.can_access_chat %}
|
||||||
|
{% if v.unread_chat_messages_count > 0 %}
|
||||||
|
<li class="nav-item d-flex align-items-center text-center justify-content-center mx-1">
|
||||||
|
<a class="nav-link position-relative" href="/chat" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Voting Booth"><i class="fas fa-booth text-danger"></i><span class="notif-count ml-1" style="padding-left: 4.5px;">{{v.unread_chat_messages_count}}</span></a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item d-flex align-items-center text-center justify-content-center mx-1">
|
||||||
|
<a class="nav-link" href="/chat" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Voting Booth"><i class="fas fa-booth"></i></a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if v.notifications_count %}
|
{% if v.notifications_count %}
|
||||||
|
|
||||||
<li class="nav-item d-flex align-items-center text-center justify-content-center mx-1">
|
<li class="nav-item d-flex align-items-center text-center justify-content-center mx-1">
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""add necessary chat fields to the user
|
||||||
|
|
||||||
|
Revision ID: 503fd4d18a54
|
||||||
|
Revises: 5876cd139e31
|
||||||
|
Create Date: 2023-08-27 07:19:35.276103+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '503fd4d18a54'
|
||||||
|
down_revision = '5876cd139e31'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('users', sa.Column('chat_authorized', sa.Boolean()))
|
||||||
|
op.execute("UPDATE users SET chat_authorized = FALSE")
|
||||||
|
op.alter_column('users', 'chat_authorized', nullable=False)
|
||||||
|
|
||||||
|
op.add_column('users', sa.Column('chat_lastseen', sa.DateTime(timezone=True)))
|
||||||
|
op.execute("UPDATE users SET chat_lastseen = '1970-01-01 00:00:00'")
|
||||||
|
op.alter_column('users', 'chat_lastseen', nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'chat_lastseen')
|
||||||
|
op.drop_column('users', 'chat_authorized')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""add chat_authorized index
|
||||||
|
|
||||||
|
Revision ID: c41b790058ad
|
||||||
|
Revises: 503fd4d18a54
|
||||||
|
Create Date: 2023-08-27 12:43:34.202689+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c41b790058ad'
|
||||||
|
down_revision = '503fd4d18a54'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index('chat_auth_index', 'users', ['chat_authorized'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('chat_auth_index', table_name='users')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""add chat_message table
|
||||||
|
|
||||||
|
Revision ID: 850c47d647ba
|
||||||
|
Revises: c41b790058ad
|
||||||
|
Create Date: 2023-08-27 13:34:34.601539+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '850c47d647ba'
|
||||||
|
down_revision = 'c41b790058ad'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('chat_message',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('quote_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('text', sa.String(), nullable=False),
|
||||||
|
sa.Column('text_html', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_datetimez', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['quote_id'], ['chat_message.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('created_datetimez_index', 'chat_message', ['created_datetimez'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('created_datetimez_index', table_name='chat_message')
|
||||||
|
op.drop_table('chat_message')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Add quote index to optimize chat item deletion
|
||||||
|
|
||||||
|
Revision ID: a2fce4808e1d
|
||||||
|
Revises: 850c47d647ba
|
||||||
|
Create Date: 2023-08-27 16:47:31.490361+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a2fce4808e1d'
|
||||||
|
down_revision = '850c47d647ba'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index('quote_index', 'chat_message', ['quote_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('quote_index', table_name='chat_message')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -35,7 +35,9 @@ The first time you do this, it will take a while. It'll be (much) faster next ti
|
||||||
|
|
||||||
6 - That's it!
|
6 - That's it!
|
||||||
|
|
||||||
Code edits will be reflected (almost) immediately. If you make any setup changes or database changes, you'll need to ctrl-C the docker-compose status log and run `docker-compose up --build` again.
|
Most code edits will be reflected (almost) immediately. If you make any setup changes or database changes, you'll need to ctrl-C the docker-compose status log and run `docker-compose up --build` again.
|
||||||
|
|
||||||
|
Chat-related code edits will take a minute to update (if it's in Python) or won't be reflected automatically at all (if it's in Flask). Improvements welcome! But almost nobody touches these systems, so it hasn't been a priority.
|
||||||
|
|
||||||
# Run the E2E tests:
|
# Run the E2E tests:
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue