diff --git a/chat/src/hooks/useChat.tsx b/chat/src/hooks/useChat.tsx index 6b7f590c6..7369fb488 100644 --- a/chat/src/hooks/useChat.tsx +++ b/chat/src/hooks/useChat.tsx @@ -20,6 +20,7 @@ enum ChatHandlers { TYPING = "typing", DELETE = "delete", SPEAK = "speak", + READ = "read", } interface ChatProviderContext { @@ -65,6 +66,11 @@ export function ChatProvider({ children }: PropsWithChildren) { const [notifications, setNotifications] = useState(0); const [messageLookup, setMessageLookup] = useState({}); + const setMessagesAndRead = useCallback((messages: IChatMessage[]) => { + setMessages(messages); + trySendReadMessage(); + }, []); + const addMessage = useCallback((message: IChatMessage) => { if (message.id === OPTIMISTIC_MESSAGE_ID) { setMessages((prev) => prev.concat(message)); @@ -139,6 +145,23 @@ export function ChatProvider({ children }: PropsWithChildren) { } catch (error) {} }, []); + const [lastMaxTime, setLastMaxTime] = useState(null); + const trySendReadMessage = useCallback(() => { + if (messages.length === 0) { + return; // Exit if the messages array is empty + } + + if (document.hasFocus()) { + 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); + } + } + }, [messages, lastMaxTime]); + + const context = useMemo( () => ({ online, @@ -170,7 +193,7 @@ export function ChatProvider({ children }: PropsWithChildren) { socket.current = io(); socket.current - .on(ChatHandlers.CATCHUP, setMessages) + .on(ChatHandlers.CATCHUP, setMessagesAndRead) .on(ChatHandlers.ONLINE, setOnline) .on(ChatHandlers.TYPING, setTyping) .on(ChatHandlers.SPEAK, addMessage) @@ -193,6 +216,8 @@ export function ChatProvider({ children }: PropsWithChildren) { }, [draft]); useEffect(() => { + trySendReadMessage(); + if (focused || document.hasFocus()) { setNotifications(0); } diff --git a/files/assets/css/main.css b/files/assets/css/main.css index 3039c0079..d5d62fad0 100644 --- a/files/assets/css/main.css +++ b/files/assets/css/main.css @@ -4874,6 +4874,7 @@ img.golden, img[g] { .fa-x:before{content:"\58"} .fa-scale-balanced:before{content:"\f24e"} .fa-hippo:before{content:"\f6ed"} +.fa-booth:before{content:"\f734"} .awards-wrapper input[type="radio"] { display: none; diff --git a/files/classes/user.py b/files/classes/user.py index 22f3d7d7d..8e57b1d7c 100644 --- a/files/classes/user.py +++ b/files/classes/user.py @@ -12,6 +12,7 @@ from files.classes.alts import Alt from files.classes.award import AwardRelationship from files.classes.badges import Badge 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.follows import Follow from files.classes.mod_logs import ModAction @@ -162,6 +163,30 @@ class User(CreatedBase): def can_manage_reports(self): 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 # return 0 if the user can't access chat + + # 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 def age_days(self): return (datetime.now() - datetime.fromtimestamp(self.created_utc)).days diff --git a/files/routes/chat.py b/files/routes/chat.py index c247eea97..385f3c9ec 100644 --- a/files/routes/chat.py +++ b/files/routes/chat.py @@ -18,9 +18,11 @@ def chat_is_allowed(perm_level: int=0): @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> bool | None: v = get_logged_in_user() - if not v or v.is_suspended_permanently or v.admin_level < perm_level: + if not v: return abort(403) - if v.admin_level < PERMS['CHAT_FULL_CONTROL'] and not v.chat_authorized: + if not v.can_access_chat: + return abort(403) + if v.admin_level < perm_level: return abort(403) kwargs['v'] = v return func(*args, **kwargs) @@ -73,7 +75,7 @@ def send_system_reply(text): "username": "System", "text": text, "text_html": sanitize(text), - 'time': int(self.created_datetimez.timestamp()), + 'time': time.time(), } emit('speak', data) @@ -188,6 +190,21 @@ def typing_indicator(data, v): emit('typing', typing, broadcast=True) +@socketio.on('read') +@chat_is_allowed() +def read(data, v): + limiter.check() + 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.add(v) + g.db.commit() + + @socketio.on('delete') @chat_is_allowed(PERMS['CHAT_MODERATION']) def delete(id, v): diff --git a/files/templates/header.html b/files/templates/header.html index 2cca528bb..735681344 100644 --- a/files/templates/header.html +++ b/files/templates/header.html @@ -25,6 +25,14 @@ {% endif %} + {% if v.can_access_chat %} + {% if v.unread_chat_messages_count > 0 %} + {{v.unread_chat_messages_count}} + {% else %} + + {% endif %} + {% endif %} + {% if v %} {% if v.notifications_count %} {{v.notifications_count}} @@ -53,6 +61,18 @@