diff --git a/.gitignore b/.gitignore index 717f57b48..4161f3a76 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,11 @@ env # Diagnostic output output.svg + +# Chat environment +chat/node_modules +chat/build + +# Chat artefacts +files/assets/css/chat_done.css +files/assets/js/chat_done.js diff --git a/Dockerfile b/Dockerfile index b06f765e6..2a3ac6a33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,23 @@ CMD [ "bootstrap/init.sh" ] ################################################################### -# Release container -FROM base AS release +# Environment capable of building React files +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 @@ -32,8 +47,8 @@ COPY bootstrap/supervisord.conf.release /etc/supervisord.conf ################################################################### -# Dev container -FROM release AS dev +# Dev environment +FROM build AS dev RUN poetry install --with dev @@ -53,3 +68,29 @@ CMD sleep infinity # Turn off the rate limiter 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 diff --git a/bootstrap/init.sh b/bootstrap/init.sh index 6948b22d7..f0b4c5aad 100755 --- a/bootstrap/init.sh +++ b/bootstrap/init.sh @@ -4,4 +4,8 @@ set -euxo pipefail python3 -m flask db upgrade # this does not actually return error codes properly! python3 -m flask cron_setup +if [[ ! -f prebuilt.flag ]]; then + ./bootstrap/init_build.sh +fi + /usr/local/bin/supervisord -c /etc/supervisord.conf diff --git a/bootstrap/init_build.sh b/bootstrap/init_build.sh new file mode 100755 index 000000000..e11433195 --- /dev/null +++ b/bootstrap/init_build.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euxo pipefail + +cd ./chat +yarn install +yarn chat +cd .. diff --git a/bootstrap/nginx_dev.conf b/bootstrap/nginx_dev.conf new file mode 100644 index 000000000..8c1a22efa --- /dev/null +++ b/bootstrap/nginx_dev.conf @@ -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; + } +} diff --git a/bootstrap/supervisord.conf.dev b/bootstrap/supervisord.conf.dev index 60a51bb6e..8a48c03af 100644 --- a/bootstrap/supervisord.conf.dev +++ b/bootstrap/supervisord.conf.dev @@ -18,6 +18,14 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr 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] directory=/service command=sh -c 'python3 -m flask cron' diff --git a/bootstrap/supervisord.conf.release b/bootstrap/supervisord.conf.release index 34d327edb..c0adab820 100644 --- a/bootstrap/supervisord.conf.release +++ b/bootstrap/supervisord.conf.release @@ -18,6 +18,14 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr 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] directory=/service command=sh -c 'python3 -m flask cron' diff --git a/chat/.env.template b/chat/.env.template new file mode 100644 index 000000000..8c2879595 --- /dev/null +++ b/chat/.env.template @@ -0,0 +1,4 @@ +FEATURES_ACTIVITY=false +DEBUG=false +NODE_ENV="production" +APPROXIMATE_CHARACTER_WIDTH = 8 diff --git a/chat/build.js b/chat/build.js new file mode 100644 index 000000000..9fb8d0bae --- /dev/null +++ b/chat/build.js @@ -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)); diff --git a/chat/global.d.ts b/chat/global.d.ts new file mode 100644 index 000000000..aab102f2c --- /dev/null +++ b/chat/global.d.ts @@ -0,0 +1,13 @@ +declare const process: { + env: Record; +}; + +declare interface IChatMessage { + id: string; + username: string; + avatar: string; + text: string; + text_html: string; + time: number; + quotes: null | string; +} diff --git a/chat/package.json b/chat/package.json new file mode 100644 index 000000000..1af1fcef4 --- /dev/null +++ b/chat/package.json @@ -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" + } +} diff --git a/chat/src/App.css b/chat/src/App.css new file mode 100644 index 000000000..5f7987419 --- /dev/null +++ b/chat/src/App.css @@ -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; +} diff --git a/chat/src/App.tsx b/chat/src/App.tsx new file mode 100644 index 000000000..194a742e5 --- /dev/null +++ b/chat/src/App.tsx @@ -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 ( + + + + + + + + ); +} + +function AppInner() { + const [_, dropRef] = useDrop({ + accept: "drawer", + }); + const { open, config } = useDrawer(); + const contentWrapper = useRef(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 ( +
+
+
+ +
+
+
+ {open ? ( +
{config.content}
+ ) : ( + + )} +
+
+ +
+
+
+
+ {quote && ( +
+ +
+ )} + + +
+
+
+
+
+ ); +} diff --git a/chat/src/drawers/BaseDrawer.css b/chat/src/drawers/BaseDrawer.css new file mode 100644 index 000000000..5d76f8c9a --- /dev/null +++ b/chat/src/drawers/BaseDrawer.css @@ -0,0 +1,5 @@ +.BaseDrawer { + flex: 1; + padding-right: 2rem; + overflow: hidden; +} diff --git a/chat/src/drawers/BaseDrawer.tsx b/chat/src/drawers/BaseDrawer.tsx new file mode 100644 index 000000000..f1fdedb49 --- /dev/null +++ b/chat/src/drawers/BaseDrawer.tsx @@ -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
{children}
; +} diff --git a/chat/src/drawers/index.ts b/chat/src/drawers/index.ts new file mode 100644 index 000000000..0595ebf13 --- /dev/null +++ b/chat/src/drawers/index.ts @@ -0,0 +1 @@ +export * from "./BaseDrawer" diff --git a/chat/src/features/chat/ActivityList.css b/chat/src/features/chat/ActivityList.css new file mode 100644 index 000000000..763807152 --- /dev/null +++ b/chat/src/features/chat/ActivityList.css @@ -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; +} \ No newline at end of file diff --git a/chat/src/features/chat/ActivityList.tsx b/chat/src/features/chat/ActivityList.tsx new file mode 100644 index 000000000..de2ce30c0 --- /dev/null +++ b/chat/src/features/chat/ActivityList.tsx @@ -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 ( +
+

+
+ Activities +

+
+ {ACTIVITIES.map(({ game, description, icon }) => ( +
+
+
+ +
{game}
{description}
+
+ + 0 +
+
+ ))} +
+
+ ); +} diff --git a/chat/src/features/chat/ChatHeading.css b/chat/src/features/chat/ChatHeading.css new file mode 100644 index 000000000..5c13a474a --- /dev/null +++ b/chat/src/features/chat/ChatHeading.css @@ -0,0 +1,10 @@ +.ChatHeading { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; +} + +.ChatHeading i { + margin-right: 0.5rem; +} \ No newline at end of file diff --git a/chat/src/features/chat/ChatHeading.tsx b/chat/src/features/chat/ChatHeading.tsx new file mode 100644 index 000000000..811410bed --- /dev/null +++ b/chat/src/features/chat/ChatHeading.tsx @@ -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: , + }); + } + }, [open]); + + return ( +
+
+
+ {open ? ( + + ) : ( + <> + + {online.length} users + + )} +
+
+ ); +} diff --git a/chat/src/features/chat/ChatMessage.css b/chat/src/features/chat/ChatMessage.css new file mode 100644 index 000000000..d9d55cc93 --- /dev/null +++ b/chat/src/features/chat/ChatMessage.css @@ -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); +} diff --git a/chat/src/features/chat/ChatMessage.tsx b/chat/src/features/chat/ChatMessage.tsx new file mode 100644 index 000000000..0a2c5dd82 --- /dev/null +++ b/chat/src/features/chat/ChatMessage.tsx @@ -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 ( +
+ {!isDirect && !isOptimistic && !actionsOpen && ( +
+ + +
+ )} + {!isDirect && !isOptimistic && actionsOpen && ( +
+ + {admin && ( + + )} + +
+ )} + {showUser && ( +
+ +
{timestamp}
+
+ )} + {quotes && quotedMessage && ( +
+ +
+ )} +
+
+ +
+
+
+ ); +} + +export function ChatMessageList() { + const listRef = useRef(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 ( +
+ {groupedMessages.map((group) => ( +
+ {group.map((message, index) => ( + + ))} +
+ ))} +
+ ); +} + +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; +} diff --git a/chat/src/features/chat/QuotedMessage.css b/chat/src/features/chat/QuotedMessage.css new file mode 100644 index 000000000..41b149432 --- /dev/null +++ b/chat/src/features/chat/QuotedMessage.css @@ -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; +} diff --git a/chat/src/features/chat/QuotedMessage.tsx b/chat/src/features/chat/QuotedMessage.tsx new file mode 100644 index 000000000..ff9c53888 --- /dev/null +++ b/chat/src/features/chat/QuotedMessage.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/chat/src/features/chat/QuotedMessageLink.css b/chat/src/features/chat/QuotedMessageLink.css new file mode 100644 index 000000000..e6667b8d5 --- /dev/null +++ b/chat/src/features/chat/QuotedMessageLink.css @@ -0,0 +1,3 @@ +.QuotedMessageLink { + font-size: 10px; +} \ No newline at end of file diff --git a/chat/src/features/chat/QuotedMessageLink.tsx b/chat/src/features/chat/QuotedMessageLink.tsx new file mode 100644 index 000000000..4582a9135 --- /dev/null +++ b/chat/src/features/chat/QuotedMessageLink.tsx @@ -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 ( + + @{message.username}:{" "} + "{replyText}" + + ); +} diff --git a/chat/src/features/chat/UserInput.css b/chat/src/features/chat/UserInput.css new file mode 100644 index 000000000..d6791a777 --- /dev/null +++ b/chat/src/features/chat/UserInput.css @@ -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; +} diff --git a/chat/src/features/chat/UserInput.tsx b/chat/src/features/chat/UserInput.tsx new file mode 100644 index 000000000..d19ef0a3c --- /dev/null +++ b/chat/src/features/chat/UserInput.tsx @@ -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(null); + const form = useRef(null); + const [typingOffset, setTypingOffset] = useState(0); + const handleChange = useCallback( + (event: ChangeEvent) => { + const input = event.target.value; + updateDraft(input); + }, + [] + ); + const handleSendMessage = useCallback( + (event?: FormEvent) => { + event?.preventDefault(); + sendMessage(); + }, + [sendMessage] + ); + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + handleSendMessage(); + } + }, + [handleSendMessage] + ); + const handleFocus = useCallback(() => { + builtChatInput.current?.scrollIntoView({ behavior: "smooth" }); + onFocus(); + }, [onFocus]); + + return ( +
+ - -
-
- - - +{% block Banner %}{% endblock %} +{% block mobilenavbar %}{% endblock %} +{% block defaultContainer %} + {% include "component/modal/expanded_image.html" %} +
- - - - - - - - - - - - - + + +{% endblock %} \ No newline at end of file diff --git a/files/templates/default.html b/files/templates/default.html index a0f1042fe..f2797dd34 100644 --- a/files/templates/default.html +++ b/files/templates/default.html @@ -227,6 +227,7 @@ {% block postNav %} {% endblock %} +{% block defaultContainer %}
@@ -255,6 +256,7 @@ {% endblock %}
+{% endblock %} {% block mobilenavbar %} {% include "mobile_navigation_bar.html" %} diff --git a/files/templates/header.html b/files/templates/header.html index 2cca528bb..6a38a24df 100644 --- a/files/templates/header.html +++ b/files/templates/header.html @@ -26,6 +26,14 @@ {% endif %} {% if v %} + {% if v.can_access_chat %} + {% if v.unread_chat_messages_count > 0 %} + {{v.unread_chat_messages_count}} + {% else %} + + {% endif %} + {% endif %} + {% if v.notifications_count %} {{v.notifications_count}} {% else %} @@ -54,6 +62,18 @@