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:
Ben Rog-Wilhelm 2023-09-04 12:42:20 -05:00 committed by GitHub
parent 7032d0680d
commit 310c6c4424
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 3018 additions and 435 deletions

8
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,7 @@
#!/bin/bash
set -euxo pipefail
cd ./chat
yarn install
yarn chat
cd ..

29
bootstrap/nginx_dev.conf Normal file
View 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;
}
}

View file

@ -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'

View file

@ -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
View file

@ -0,0 +1,4 @@
FEATURES_ACTIVITY=false
DEBUG=false
NODE_ENV="production"
APPROXIMATE_CHARACTER_WIDTH = 8

20
chat/build.js Normal file
View 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
View 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
View 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
View 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
View 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>
);
}

View file

@ -0,0 +1,5 @@
.BaseDrawer {
flex: 1;
padding-right: 2rem;
overflow: hidden;
}

View 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>;
}

View file

@ -0,0 +1 @@
export * from "./BaseDrawer"

View 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;
}

View 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>
);
}

View file

@ -0,0 +1,10 @@
.ChatHeading {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
}
.ChatHeading i {
margin-right: 0.5rem;
}

View 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>
);
}

View 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);
}

View 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;
}

View 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;
}

View 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>
);
}

View file

@ -0,0 +1,3 @@
.QuotedMessageLink {
font-size: 10px;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View file

@ -0,0 +1,9 @@
.Username {
display: inline-flex;
align-items: center;
}
.Username > a {
font-weight: bold;
margin-left: 8px;
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
.UsersTyping {
height: 18px;
display: inline-block;
font-size: 10px;
}

View 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>
);
}

View 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";

View file

@ -0,0 +1 @@
export * from "./chat";

4
chat/src/hooks/index.ts Normal file
View 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
View 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;
}

View 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>
);
}

View 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,
};
}

View 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
View 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
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react",
"lib": ["es2015", "dom", "ESNext"],
"noEmit": true
}
}

604
chat/yarn.lock Normal file
View 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==

View file

@ -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

View file

@ -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

Before After
Before After

File diff suppressed because one or more lines are too long

View file

@ -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

View 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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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()

View file

@ -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>

View file

@ -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" %}

View file

@ -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">

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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: