diff --git a/chat/global.d.ts b/chat/global.d.ts index f943af7a5..34ba5119f 100644 --- a/chat/global.d.ts +++ b/chat/global.d.ts @@ -5,6 +5,7 @@ declare var process: { declare interface IChatMessage { id: string; username: string; + user_id?: string; avatar: string; hat: string; namecolor: string; @@ -14,6 +15,7 @@ declare interface IChatMessage { text_html: string; time: number; quotes: null | string; + dm: boolean; } declare interface EmojiModSelection { diff --git a/chat/package.json b/chat/package.json index 8b91ac345..609fd31a2 100644 --- a/chat/package.json +++ b/chat/package.json @@ -1,6 +1,6 @@ { "name": "chat", - "version": "0.1.18", + "version": "0.1.19", "main": "./src/index.tsx", "license": "MIT", "dependencies": { diff --git a/chat/src/App.tsx b/chat/src/App.tsx index 4e682770a..784a7ad40 100644 --- a/chat/src/App.tsx +++ b/chat/src/App.tsx @@ -36,7 +36,7 @@ function AppInner() { const { open, config } = useDrawer(); const contentWrapper = useRef(null); const initiallyScrolledDown = useRef(false); - const { messages, quote } = useChat(); + const { messages, quote, userToDm, updateUserToDm } = useChat(); // See: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ useEffect(() => { @@ -120,6 +120,25 @@ function AppInner() { )} + {userToDm && ( +
+ Directly messaging @{userToDm.username} + +
+ )} diff --git a/chat/src/features/chat/ChatMessage.css b/chat/src/features/chat/ChatMessage.css index 45018a700..db9b6b751 100644 --- a/chat/src/features/chat/ChatMessage.css +++ b/chat/src/features/chat/ChatMessage.css @@ -1,7 +1,12 @@ .ChatMessage { position: relative; padding-right: 1.5rem; - min-height: 28px; +} + +.ChatMessage__isDm { + background: var(--gray-800); + border-top: 1px dashed var(--primary); + border-bottom: 1px dashed var(--primary); } .ChatMessage p { diff --git a/chat/src/features/chat/ChatMessage.tsx b/chat/src/features/chat/ChatMessage.tsx index dff4ac236..b74d0808e 100644 --- a/chat/src/features/chat/ChatMessage.tsx +++ b/chat/src/features/chat/ChatMessage.tsx @@ -43,6 +43,7 @@ export function ChatMessage({ text_censored, time, quotes, + dm, } = message; const { id: userId, @@ -51,7 +52,14 @@ export function ChatMessage({ censored, themeColor, } = useRootContext(); - const { messageLookup, deleteMessage, quoteMessage } = useChat(); + const { + messageLookup, + userToDm, + quote, + deleteMessage, + quoteMessage, + updateUserToDm, + } = useChat(); const [confirmedDelete, setConfirmedDelete] = useState(false); const quotedMessage = messageLookup[quotes]; const content = censored ? text_censored : text_html; @@ -72,9 +80,31 @@ export function ChatMessage({ } }, [text, confirmedDelete]); const handleQuoteMessageAction = useCallback(() => { + updateUserToDm(null); quoteMessage(message); onToggleActions(message.id); }, [message, onToggleActions]); + const handleDirectMessage = useCallback( + (toggle?: boolean) => { + const userId = message.user_id ?? ""; + + if (userToDm && userToDm.id === userId) { + updateUserToDm(null); + } else { + updateUserToDm({ + id: userId, + username: message.username, + }); + + quoteMessage(null); + + if (toggle) { + onToggleActions(message.id); + } + } + }, + [userToDm, message.id, message.user_id, message.username] + ); useEffect(() => { if (!actionsOpen) { @@ -85,12 +115,11 @@ export function ChatMessage({ return (
+ @@ -116,6 +159,12 @@ export function ChatMessage({ )} {actionsOpen && (
+
)} + {dm && ( + + (Sent only to you) + + )}
(null); const { visible, addQuery } = useEmojis(); const form = useRef(null); @@ -81,6 +81,12 @@ export function UserInput() { } }, []); + useEffect(() => { + if (userToDm) { + builtChatInput.current?.focus(); + } + }, [userToDm]) + return (
{quickEmojis.length > 0 && ( diff --git a/chat/src/hooks/useChat.tsx b/chat/src/hooks/useChat.tsx index 34d31275b..0c21ae4e4 100644 --- a/chat/src/hooks/useChat.tsx +++ b/chat/src/hooks/useChat.tsx @@ -22,6 +22,11 @@ enum ChatHandlers { SPEAK = "speak", } +interface UserToDM { + id: string; + username: string; +} + interface ChatProviderContext { online: string[]; typing: string[]; @@ -29,10 +34,12 @@ interface ChatProviderContext { draft: string; quote: null | IChatMessage; messageLookup: Record; + userToDm: null | UserToDM; updateDraft: React.Dispatch>; sendMessage(): void; quoteMessage(message: null | IChatMessage): void; deleteMessage(withText: string): void; + updateUserToDm(userToDm: UserToDM): void; } const ChatContext = createContext({ @@ -42,10 +49,12 @@ const ChatContext = createContext({ draft: "", quote: null, messageLookup: {}, + userToDm: null, updateDraft() {}, sendMessage() {}, quoteMessage() {}, deleteMessage() {}, + updateUserToDm() {}, }); const MINIMUM_TYPING_UPDATE_INTERVAL = 250; @@ -60,6 +69,7 @@ export function ChatProvider({ children }: PropsWithChildren) { const lastDraft = useRef(""); const [quote, setQuote] = useState(null); const focused = useWindowFocus(); + const [userToDm, setUserToDm] = useState(null); const [notifications, setNotifications] = useState(0); const [messageLookup, setMessageLookup] = useState({}); const addMessage = useCallback((message: IChatMessage) => { @@ -73,11 +83,13 @@ export function ChatProvider({ children }: PropsWithChildren) { socket.current?.emit(ChatHandlers.SPEAK, { message: draft, quotes: quote?.id ?? null, + recipient: userToDm?.id ?? "", }); setQuote(null); setDraft(""); - }, [draft, quote]); + setUserToDm(null); + }, [draft, quote, userToDm]); const requestDeleteMessage = useCallback((withText: string) => { socket.current?.emit(ChatHandlers.DELETE, withText); }, []); @@ -105,10 +117,12 @@ export function ChatProvider({ children }: PropsWithChildren) { draft, quote, messageLookup, + userToDm, quoteMessage, sendMessage, deleteMessage: requestDeleteMessage, updateDraft: setDraft, + updateUserToDm: setUserToDm, }), [ online, @@ -117,6 +131,7 @@ export function ChatProvider({ children }: PropsWithChildren) { draft, quote, messageLookup, + userToDm, sendMessage, deleteMessage, quoteMessage, diff --git a/files/routes/chat.py b/files/routes/chat.py index 398fae1cb..a70326ed2 100644 --- a/files/routes/chat.py +++ b/files/routes/chat.py @@ -6,10 +6,9 @@ from files.helpers.sanitize import sanitize from files.helpers.const import * from files.helpers.alerts import * from files.helpers.regex import * -from datetime import datetime from flask_socketio import SocketIO, emit from files.__main__ import app, limiter, cache -from flask import render_template, make_response, send_from_directory +from flask import render_template import sys import atexit @@ -33,6 +32,8 @@ cache.set(ONLINE_STR, len(online), timeout=0) muted = cache.get(f'{SITE}_muted') or {} messages = cache.get(f'{SITE}_chat') or [] total = cache.get(f'{SITE}_total') or 0 +socket_ids_to_user_ids = {} +user_ids_to_socket_ids = {} @app.get("/chat") @is_not_permabanned @@ -60,11 +61,14 @@ def speak(data, v): if not text: return '', 403 text_html = sanitize(text, count_marseys=True) quotes = data['quotes'] + recipient = data['recipient'] data={ "id": str(uuid.uuid4()), "quotes": quotes, "avatar": v.profile_url, "hat": v.hat_active, + "user_id": v.id, + "dm": bool(recipient and recipient != ""), "username": v.username, "namecolor": v.name_color, "text": text, @@ -81,6 +85,9 @@ def speak(data, v): v.shadowbanned = 'AutoJanny' g.db.add(v) send_repeatable_notification(CARP_ID, f"{v.username} has been shadowbanned because of a chat message.") + elif recipient and user_ids_to_socket_ids.get(recipient): + recipient_sid = user_ids_to_socket_ids[recipient] + emit('speak', data, broadcast=False, to=recipient_sid) else: emit('speak', data, broadcast=True) messages.append(data) @@ -106,6 +113,10 @@ def connect(v): emit("online", online, broadcast=True) cache.set(ONLINE_STR, len(online), timeout=0) + if not socket_ids_to_user_ids.get(request.sid): + socket_ids_to_user_ids[request.sid] = v.id + user_ids_to_socket_ids[v.id] = request.sid + emit('online', online) emit('catchup', messages) emit('typing', typing) @@ -120,6 +131,11 @@ def disconnect(v): cache.set(ONLINE_STR, len(online), timeout=0) if v.username in typing: typing.remove(v.username) + + if socket_ids_to_user_ids.get(request.sid): + del socket_ids_to_user_ids[request.sid] + del user_ids_to_socket_ids[v.id] + emit('typing', typing, broadcast=True) return '', 204