diff --git a/files/assets/css/main.css b/files/assets/css/main.css index 6df2a2efb..1a9018dca 100644 --- a/files/assets/css/main.css +++ b/files/assets/css/main.css @@ -221,6 +221,7 @@ .fa-chart-simple:before{content:"\e473"} .fa-memo:before{content:"\e1d8"} .fa-file-pen:before{content:"\f31c"} +.fa-pen:before{content:"\f304"} /* do not remove - fixes hand, talking, marsey-love components from breaking out of the comment box @@ -3484,7 +3485,9 @@ pre { .text-small { font-size: 12px !important; } - +.text-smaller { + font-size: 9px !important; +} @media (max-width: 768px) { .text-small-sm { font-size: 12px !important; diff --git a/files/assets/js/chat.js b/files/assets/js/chat.js index b9babacb6..66bdf5398 100644 --- a/files/assets/js/chat.js +++ b/files/assets/js/chat.js @@ -135,6 +135,11 @@ socket.on('speak', function(json) { } }) +let chat_id = 'chat' +const chat_id_el = document.getElementById('chat_id') +if (chat_id_el) + chat_id = chat_id_el.value + function send() { const text = ta.value.trim(); const input = document.getElementById('file'); @@ -148,6 +153,7 @@ function send() { "message": text, "quotes": document.getElementById('quotes_id').value, "file": sending, + "chat_id": chat_id, }); ta.value = '' is_typing = false @@ -200,6 +206,18 @@ ta.addEventListener("keydown", function(e) { socket.on('online', function(data){ const online_li = data[0] + if (location.pathname.startsWith('/chat/')) { + for (const marker of document.getElementsByClassName('online-marker')) { + marker.classList.add('d-none') + } + for (const u of online_li) { + for (const marker of document.getElementsByClassName(`online-marker-${u[4]}`)) { + marker.classList.remove('d-none') + } + } + return + } + const muted_li = Object.keys(data[1]) document.getElementsByClassName('board-chat-count')[0].innerHTML = online_li.length diff --git a/files/assets/js/core.js b/files/assets/js/core.js index 88bc06401..54f24f3ba 100644 --- a/files/assets/js/core.js +++ b/files/assets/js/core.js @@ -121,7 +121,7 @@ function postToastSwitch(t, url, button1, button2, cls, extraActionsOnSuccess) { }); } -if (!location.pathname.endsWith('/submit') && !location.pathname.endsWith('/chat')) +if (!location.pathname.endsWith('/submit') && !location.pathname.startsWith('/chat')) { document.addEventListener('keydown', (e) => { if (!((e.ctrlKey || e.metaKey) && e.key === "Enter")) return; @@ -399,7 +399,7 @@ if (is_pwa) { } const gbrowser = document.getElementById('gbrowser').value -if (location.pathname != '/chat' && (gbrowser == 'iphone' || gbrowser == 'mac')) { +if (!location.pathname.startsWith('/chat') && (gbrowser == 'iphone' || gbrowser == 'mac')) { const videos = document.querySelectorAll('video') for (const video of videos) { @@ -783,3 +783,14 @@ for (const el of document.getElementsByClassName('tor-disabled')) { window.alert("File uploads are not allowed through TOR!") }; } + +function toggleElement(id, id2) { + for (let el of document.getElementsByClassName('toggleable')) { + if (el.id != id) { + el.classList.add('d-none'); + } + } + + document.getElementById(id).classList.toggle('d-none'); + document.getElementById(id2).focus() +} diff --git a/files/assets/js/emoji_modal/inline_emoji_modal.js b/files/assets/js/emoji_modal/inline_emoji_modal.js index 6c4e4b3b6..a07f0f71a 100644 --- a/files/assets/js/emoji_modal/inline_emoji_modal.js +++ b/files/assets/js/emoji_modal/inline_emoji_modal.js @@ -4,7 +4,7 @@ function update_ghost_div_textarea(text) { let ghostdiv - if (location.pathname == '/chat') + if (location.pathname.startsWith('/chat') ) ghostdiv = document.getElementById("ghostdiv-chat"); else ghostdiv = text.parentNode.getElementsByClassName("ghostdiv")[0]; diff --git a/files/assets/js/userpage_v.js b/files/assets/js/userpage_v.js index 0d9f6badf..22188fe84 100644 --- a/files/assets/js/userpage_v.js +++ b/files/assets/js/userpage_v.js @@ -1,14 +1,3 @@ -function toggleElement(id, id2) { - for (let el of document.getElementsByClassName('toggleable')) { - if (el.id != id) { - el.classList.add('d-none'); - } - } - - document.getElementById(id).classList.toggle('d-none'); - document.getElementById(id2).focus() -} - let TRANSFER_TAX = document.getElementById('tax').innerHTML function updateTax(mobile=false) { diff --git a/files/classes/__init__.py b/files/classes/__init__.py index 02c324a66..62e6c0215 100644 --- a/files/classes/__init__.py +++ b/files/classes/__init__.py @@ -40,3 +40,4 @@ if FEATURES['IP_LOGGING']: from .ip_logs import * from .edit_logs import * +from .private_chats import * diff --git a/files/classes/private_chats.py b/files/classes/private_chats.py new file mode 100644 index 000000000..b05743f9a --- /dev/null +++ b/files/classes/private_chats.py @@ -0,0 +1,122 @@ +import time + +from sqlalchemy import Column, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql.sqltypes import * + +from files.classes import Base +from files.helpers.lazy import lazy + +class Chat(Base): + __tablename__ = "chats" + id = Column(Integer, primary_key=True) + owner_id = Column(Integer, ForeignKey("users.id")) + name = Column(String) + created_utc = Column(Integer) + + memberships = relationship("ChatMembership") + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"<{self.__class__.__name__}(id={self.id})>" + + +class ChatMembership(Base): + __tablename__ = "chat_memberships" + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + chat_id = Column(Integer, ForeignKey("chats.id"), primary_key=True) + created_utc = Column(Integer) + + user = relationship("User") + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"<{self.__class__.__name__}(user_id={self.user_id}, chat_id={self.chat_id})>" + + @lazy + def unread_count(v): + return g.db.query(ChatNotification).filter_by(user_id=v.id, read=False, chat_id=self.chat_id).count() + + +class ChatLeave(Base): + __tablename__ = "chat_leaves" + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + chat_id = Column(Integer, ForeignKey("chats.id"), primary_key=True) + created_utc = Column(Integer) + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"<{self.__class__.__name__}(user_id={self.user_id}, chat_id={self.chat_id})>" + + +class ChatNotification(Base): + __tablename__ = "chat_notifications" + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True) + chat_message_id = Column(Integer, ForeignKey("chat_messages.id"), primary_key=True) + chat_id = Column(Integer, ForeignKey("chats.id")) + created_utc = Column(Integer) + + chat_message = relationship("ChatMessage") + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"<{self.__class__.__name__}(user_id={self.user_id}, chat_message_id={self.message_id})>" + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id")) + chat_id = Column(Integer, ForeignKey("chats.id")) + quotes = Column(Integer, ForeignKey("chat_messages.id")) + text = Column(String) + text_censored = Column(String) + text_html = Column(String) + text_html_censored = Column(String) + created_utc = Column(Integer) + + user = relationship("User") + + def __init__(self, *args, **kwargs): + if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time()) + super().__init__(*args, **kwargs) + + def __repr__(self): + return f"<{self.__class__.__name__}(id={self.id})>" + + @property + @lazy + def username(self): + return self.user.username + + @property + @lazy + def hat(self): + return self.user.hat_active(None)[0] + + @property + @lazy + def namecolor(self): + return self.user.name_color + + @property + @lazy + def patron(self): + return self.user.patron + + @property + @lazy + def pride_username(self): + return self.user.pride_username(None) diff --git a/files/classes/user.py b/files/classes/user.py index dcd2d8f80..c541c16f8 100644 --- a/files/classes/user.py +++ b/files/classes/user.py @@ -14,6 +14,7 @@ from files.classes import Base from files.classes.casino_game import CasinoGame from files.classes.group import * from files.classes.hole import Hole +from files.classes.private_chats import ChatNotification from files.classes.currency_logs import CurrencyLog from files.helpers.config.const import * from files.helpers.config.modaction_types import * @@ -815,18 +816,28 @@ class User(Base): Comment.deleted_utc == 0, ) - return notifs.count() + self.modmail_notifications_count + self.post_notifications_count + self.modaction_notifications_count + self.offsite_notifications_count + return notifs.count() + self.chats_notifications_count + self.modmail_notifications_count + self.post_notifications_count + self.modaction_notifications_count + self.offsite_notifications_count @property @lazy def normal_notifications_count(self): return self.notifications_count \ + - self.chats_notifications_count \ - self.message_notifications_count \ - self.modmail_notifications_count \ - self.post_notifications_count \ - self.modaction_notifications_count \ - self.offsite_notifications_count + @property + @lazy + def chats_notifications_count(self): + return g.db.query(ChatNotification).filter_by(user_id=self.id).count() + + @lazy + def chat_notifications_count(self, chat_id): + return g.db.query(ChatNotification).filter_by(user_id=self.id, chat_id=chat_id).count() + @property @lazy def message_notifications_count(self): @@ -929,6 +940,8 @@ class User(Base): # only meaningful when notifications_count > 0; otherwise falsely '' ~ normal if self.normal_notifications_count > 0: return '' + elif self.chats_notifications_count > 0: + return 'chats' elif self.message_notifications_count > 0: return 'messages' elif self.modmail_notifications_count > 0: @@ -947,6 +960,7 @@ class User(Base): colors = { '': '#dc3545', 'messages': '#d8910d', + 'chats': '#008080', 'modmail': '#f15387', 'posts': '#0000ff', 'modactions': '#1ad80d', @@ -1337,7 +1351,7 @@ class User(Base): @lazy def pride_username(self, v): - return not (v and v.poor) and self.has_badge(303) + return bool(not (v and v.poor) and self.has_badge(303)) @property @lazy diff --git a/files/helpers/config/const.py b/files/helpers/config/const.py index bf7e332ec..b993fd486 100644 --- a/files/helpers/config/const.py +++ b/files/helpers/config/const.py @@ -225,6 +225,7 @@ PERMS = { # Minimum admin_level to perform action. 'MODS_EVERY_HOLE': 5, 'MODS_EVERY_GROUP': 5, 'VIEW_EMAILS': 5, + 'VIEW_CHATS': 5, 'INFINITE_CURRENCY': 5, } @@ -546,6 +547,7 @@ NOTIFICATION_SPAM_AGE_THRESHOLD = 0 COMMENT_SPAM_LENGTH_THRESHOLD = 0 DEFAULT_UNDER_SIEGE_THRESHOLDS = { + "private chat": 0, "chat": 0, "normal comment": 0, "wall comment": 0, @@ -794,6 +796,7 @@ elif SITE in {'watchpeopledie.tv', 'marsey.world'}: } DEFAULT_UNDER_SIEGE_THRESHOLDS = { + "private chat": 1440, "chat": 1440, "normal comment": 10, "wall comment": 1440, diff --git a/files/helpers/regex.py b/files/helpers/regex.py index da1646f46..6de83ef95 100644 --- a/files/helpers/regex.py +++ b/files/helpers/regex.py @@ -11,6 +11,8 @@ valid_username_patron_regex = re.compile("^[\w-]{1,25}$", flags=re.A) mention_regex = re.compile('(?)!(everyone)' + NOT_IN_CODE_OR_LINKS, flags=re.A) valid_password_regex = re.compile("^.{8,100}$", flags=re.A) diff --git a/files/routes/__init__.py b/files/routes/__init__.py index d917cbf53..bc74aa7ce 100644 --- a/files/routes/__init__.py +++ b/files/routes/__init__.py @@ -53,6 +53,7 @@ from .special import * from .push_notifs import * if FEATURES['PING_GROUPS']: from .groups import * +from .private_chats import * if IS_LOCALHOST: from sys import argv diff --git a/files/routes/chat.py b/files/routes/chat.py index 07dd1adb3..9abd86b19 100644 --- a/files/routes/chat.py +++ b/files/routes/chat.py @@ -17,6 +17,7 @@ from files.helpers.alerts import push_notif from files.helpers.can_see import * from files.routes.wrappers import * from files.classes.orgy import * +from files.classes.private_chats import * from files.__main__ import app, cache, limiter @@ -32,16 +33,22 @@ socketio = SocketIO( muted = cache.get(f'muted') or {} messages = cache.get(f'messages') or {} -online = {} -typing = [] -cache.set('loggedin_chat', len(online), timeout=86400) +online = { + "chat": {}, + "messages": set() +} + +typing = { + "chat": [] +} + +cache.set('loggedin_chat', len(online["chat"]), timeout=86400) def auth_required_socketio(f): def wrapper(*args, **kwargs): v = get_logged_in_user() - if not v: return '', 401 - if v.is_permabanned: return '', 403 + if not v or v.is_permabanned: return '' return make_response(f(*args, v=v, **kwargs)) wrapper.__name__ = f.__name__ return wrapper @@ -49,8 +56,7 @@ def auth_required_socketio(f): def is_not_banned_socketio(f): def wrapper(*args, **kwargs): v = get_logged_in_user() - if not v: return '', 401 - if v.is_suspended: return '', 403 + if not v or v.is_suspended: return '' return make_response(f(*args, v=v, **kwargs)) wrapper.__name__ = f.__name__ return wrapper @@ -62,6 +68,10 @@ def refresh_chat(): emit('refresh_chat', namespace='/', to="chat") return '' +@app.get("/chat/") +def chat_redirect(): + return redirect("/chat") + @app.get("/chat") @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) @@ -83,8 +93,105 @@ def chat(v): @socketio.on('speak') @is_not_banned_socketio def speak(data, v): - if not v.allowed_in_chat: - return '', 403 + if request.referrer.startswith(f'{SITE_FULL}/chat/'): + chat_id = int(data['chat_id']) + + chat = g.db.get(Chat, chat_id) + if not chat: + abort(404, "Chat not found!") + + is_member = g.db.query(ChatMembership.user_id).filter_by(user_id=v.id, chat_id=chat_id).one_or_none() + if not is_member: return '' + + image = None + if data['file']: + name = f'/chat_images/{time.time()}'.replace('.','') + '.webp' + with open(name, 'wb') as f: + f.write(data['file']) + image = process_image(name, v) + + text = data['message'].strip()[:CHAT_LENGTH_LIMIT] + if image: text += f'\n\n{image}' + if not text: return '' + + text_html = sanitize(text, count_emojis=True, chat=True) + if isinstance(text_html , tuple): return '' + + execute_under_siege(v, None, text, "private chat") + + quotes = data['quotes'] + if quotes: quotes = int(quotes) + else: quotes = None + + chat_message = ChatMessage( + user_id=v.id, + chat_id=chat_id, + quotes=quotes, + text=text, + text_censored=censor_slurs_profanities(text, 'chat', True), + text_html=text_html, + text_html_censored=censor_slurs_profanities(text_html, 'chat'), + ) + g.db.add(chat_message) + g.db.flush() + + if v.id == chat.owner_id and chat_adding_regex.fullmatch(text): + user = get_user(text[2:], graceful=True, attributes=[User.id]) + if user: + user_id = user.id + existing = g.db.query(ChatMembership.user_id).filter_by(user_id=user_id, chat_id=chat_id).one_or_none() + leave = g.db.query(ChatLeave.user_id).filter_by(user_id=user_id, chat_id=chat_id).one_or_none() + if not existing and not leave: + chat_membership = ChatMembership( + user_id=user.id, + chat_id=chat_id, + ) + g.db.add(chat_membership) + g.db.flush() + + to_notify = [x[0] for x in g.db.query(ChatMembership.user_id).filter( + ChatMembership.chat_id == chat_id, + ChatMembership.user_id.notin_(online[request.referrer]), + )] + for uid in to_notify: + n = ChatNotification( + user_id=uid, + chat_message_id=chat_message.id, + chat_id=chat_id, + ) + g.db.add(n) + + data = { + "id": chat_message.id, + "quotes": chat_message.quotes, + "hat": chat_message.hat, + "user_id": chat_message.user_id, + "username": chat_message.username, + "namecolor": chat_message.namecolor, + "patron": chat_message.patron, + "pride_username": chat_message.pride_username, + "text": chat_message.text, + "text_censored": chat_message.text_censored, + "text_html": chat_message.text_html, + "text_html_censored": chat_message.text_html_censored, + "created_utc": chat_message.created_utc, + } + + if v.shadowbanned or execute_blackjack(v, None, text, "chat"): + emit('speak', data) + else: + emit('speak', data, room=request.referrer, broadcast=True) + + try: g.db.commit() + except: g.db.rollback() + g.db.close() + stdout.flush() + + return '' + + + + if not v.allowed_in_chat: return '' image = None if data['file']: @@ -95,13 +202,12 @@ def speak(data, v): global messages - text = data['message'][:CHAT_LENGTH_LIMIT] + text = data['message'].strip()[:CHAT_LENGTH_LIMIT] if image: text += f'\n\n{image}' - if not text: return '', 400 + if not text: return '' text_html = sanitize(text, count_emojis=True, chat=True) - if isinstance(text_html , tuple): - return text_html + if isinstance(text_html , tuple): return '' execute_under_siege(v, None, text, "chat") @@ -116,14 +222,14 @@ def speak(data, v): self_only = True else: del muted[vname] - refresh_online() + refresh_online("chat") if SITE == 'rdrama.net' and v.admin_level < PERMS['BYPASS_ANTISPAM_CHECKS']: def shut_up(): self_only = True muted_until = int(time.time() + 600) muted[vname] = muted_until - refresh_online() + refresh_online("chat") if not self_only: identical = [x for x in list(messages.values())[-5:] if v.id == x['user_id'] and text == x['text']] @@ -160,7 +266,7 @@ def speak(data, v): username = i.group(1).lower() muted_until = int(int(i.group(2)) * 60 + time.time()) muted[username] = muted_until - refresh_online() + refresh_online("chat") if self_only or v.shadowbanned or execute_blackjack(v, None, text, "chat"): emit('speak', data) @@ -169,37 +275,43 @@ def speak(data, v): messages[id] = data messages = dict(list(messages.items())[-250:]) - typing = [] + typing["chat"] = [] return '' -def refresh_online(): - for k, val in list(online.items()): +def refresh_online(room): + for k, val in list(online[room].items()): if time.time() > val[0]: - del online[k] - if val[1] in typing: - typing.remove(val[1]) + del online[room][k] + if val[1] in typing[room]: + typing[room].remove(val[1]) - data = [list(online.values()), muted] - emit("online", data, room="chat", broadcast=True) - cache.set('loggedin_chat', len(online), timeout=86400) + data = [list(online[room].values()), muted] + emit("online", data, room=room, broadcast=True) + cache.set('loggedin_chat', len(online[room]), timeout=86400) @socketio.on('connect') @auth_required_socketio def connect(v): if request.referrer == f'{SITE_FULL}/notifications/messages': join_room(v.id) - return '' - elif request.referrer and request.referrer.startswith(f'{SITE_FULL}/chat/'): - join_room(request.referrer) + online["messages"].add(v.id) return '' - join_room("chat") + if request.referrer and request.referrer.startswith(f'{SITE_FULL}/chat/'): + room = request.referrer + else: + room = "chat" - if v.username in typing: - typing.remove(v.username) + join_room(room) - emit('typing', typing, room="chat") + if not typing.get(room): + typing[room] = [] + + if v.username in typing.get(room): + typing[room].remove(v.username) + + emit('typing', typing[room], room=room) return '' @socketio.on('disconnect') @@ -207,40 +319,60 @@ def connect(v): def disconnect(v): if request.referrer == f'{SITE_FULL}/notifications/messages': leave_room(v.id) - return '' - elif request.referrer and request.referrer.startswith(f'{SITE_FULL}/chat/'): - leave_room(request.referrer) + online["messages"].remove(v.id) return '' - online.pop(v.id, None) + if request.referrer and request.referrer.startswith(f'{SITE_FULL}/chat/'): + room = request.referrer + else: + room = "chat" - if v.username in typing: - typing.remove(v.username) + online[room].pop(v.id, None) - leave_room("chat") - refresh_online() + if v.username in typing[room]: + typing[room].remove(v.username) + + leave_room(room) + refresh_online(room) return '' @socketio.on('heartbeat') @auth_required_socketio def heartbeat(v): + if request.referrer and request.referrer.startswith(f'{SITE_FULL}/chat/'): + room = request.referrer + else: + room = "chat" + + if not online.get(room): + online[room] = {} + expire_utc = int(time.time()) + 3610 - already_there = online.get(v.id) - online[v.id] = (expire_utc, v.username, v.name_color, v.patron, v.id, bool(v.has_badge(303))) + already_there = online[room].get(v.id) + online[room][v.id] = (expire_utc, v.username, v.name_color, v.patron, v.id, bool(v.has_badge(303))) if not already_there: - refresh_online() + refresh_online(room) + return '' @socketio.on('typing') @is_not_banned_socketio def typing_indicator(data, v): - if data and v.username not in typing: - typing.append(v.username) - elif not data and v.username in typing: - typing.remove(v.username) + if request.referrer and request.referrer.startswith(f'{SITE_FULL}/chat/'): + room = request.referrer + else: + room = "chat" - emit('typing', typing, room="chat", broadcast=True) + if not typing.get(room): + typing[room] = [] + + if data and v.username not in typing[room]: + typing[room].append(v.username) + elif not data and v.username in typing[room]: + typing[room].remove(v.username) + + emit('typing', typing[room], room=room, broadcast=True) return '' @@ -329,7 +461,7 @@ def messagereply(v): execute_blackjack(v, c, c.body_html, 'message') execute_under_siege(v, c, c.body_html, 'message') - if user_id and user_id not in {v.id, MODMAIL_ID} | BOT_IDs: + if user_id and user_id not in {v.id, MODMAIL_ID} | BOT_IDs and user_id not in online["messages"]: if can_see(user, v): notif = g.db.query(Notification).filter_by(comment_id=c.id, user_id=user_id).one_or_none() if not notif: diff --git a/files/routes/notifications.py b/files/routes/notifications.py index 674d49eac..ec7ee3615 100644 --- a/files/routes/notifications.py +++ b/files/routes/notifications.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import load_only from files.classes.mod_logs import ModAction from files.classes.hole_logs import HoleAction +from files.classes.private_chats import * from files.helpers.config.const import * from files.helpers.config.modaction_types import * from files.helpers.get import * @@ -24,11 +25,14 @@ def clear(v): Notification.read == False, Notification.user_id == v.id, ).options(load_only(Notification.comment_id)).all() - for n in notifs: n.read = True g.db.add(n) + chat_notifs = g.db.query(ChatNotification).filter_by(user_id=v.id) + for chat_notif in chat_notifs: + g.db.delete(chat_notif) + v.last_viewed_modmail_notifs = int(time.time()) v.last_viewed_post_notifs = int(time.time()) v.last_viewed_log_notifs = int(time.time()) @@ -131,6 +135,16 @@ def notifications_messages(v): ) +@app.get("/notifications/chats") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def notifications_chats(v): + criteria1 = (Chat.id == ChatMembership.chat_id, ChatMembership.user_id == v.id) + criteria2 = (Chat.id == ChatNotification.chat_id, ChatNotification.user_id == v.id) + chats = g.db.query(Chat, func.count(ChatNotification.chat_id)).join(ChatMembership, and_(*criteria1)).outerjoin(ChatNotification, and_(*criteria2)).group_by(Chat).order_by(func.count(ChatNotification.chat_id).desc(), Chat.name).all() + return render_template("notifications.html", v=v, chats=chats) + @app.get("/notifications/modmail") @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) diff --git a/files/routes/private_chats.py b/files/routes/private_chats.py new file mode 100644 index 000000000..eed6abe63 --- /dev/null +++ b/files/routes/private_chats.py @@ -0,0 +1,122 @@ + +from files.classes.private_chats import * +from files.routes.wrappers import * +from files.helpers.config.const import * +from files.helpers.get import * + +from files.__main__ import app, limiter + +@app.post("/@/chat") +@limiter.limit('1/second', scope=rpath) +@limiter.limit('1/second', scope=rpath, key_func=get_ID) +@limiter.limit("10/minute;20/hour;50/day", deduct_when=lambda response: response.status_code < 400) +@limiter.limit("10/minute;20/hour;50/day", deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def chat_user(v, username): + user = get_user(username, v=v, include_blocks=True) + + if hasattr(user, 'is_blocking') and user.is_blocking: + abort(403, f"You're blocking @{user.username}") + + if v.admin_level <= PERMS['MESSAGE_BLOCKED_USERS'] and hasattr(user, 'is_blocked') and user.is_blocked: + abort(403, f"@{user.username} is blocking you!") + + if user.has_muted(v): + abort(403, f"@{user.username} is muting notifications from you, so you can't chat with them!") + + + sq = g.db.query(Chat.id).join(Chat.memberships).filter(ChatMembership.user_id.in_((v.id, user.id))).group_by(Chat.id).having(func.count(Chat.id) == 2).subquery() + existing = g.db.query(Chat.id).join(Chat.memberships).filter(Chat.id == sq.c.id).group_by(Chat.id).having(func.count(Chat.id) == 2).one_or_none() + if existing: + return redirect(f"/chat/{existing.id}") + + chat = Chat(owner_id=v.id, name=f"Chat with @{user.username}") + g.db.add(chat) + g.db.flush() + + chat_membership = ChatMembership( + user_id=v.id, + chat_id=chat.id, + ) + g.db.add(chat_membership) + + chat_membership = ChatMembership( + user_id=user.id, + chat_id=chat.id, + ) + g.db.add(chat_membership) + + return redirect(f"/chat/{chat.id}") + + +@app.get("/chat/") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def private_chat(v, chat_id): + chat = g.db.get(Chat, chat_id) + if not chat: + abort(404, "Chat not found!") + + if v.admin_level < PERMS['VIEW_CHATS']: + is_member = g.db.query(ChatMembership.user_id).filter_by(user_id=v.id, chat_id=chat_id).one_or_none() + if not is_member: + abort(403, "You're not a member of this chat!") + + displayed_messages = g.db.query(ChatMessage).filter_by(chat_id=chat.id).limit(250).all() + + notifs_msgs = g.db.query(ChatNotification, ChatMessage).join(ChatNotification.chat_message).filter( + ChatNotification.user_id == v.id, + ChatNotification.chat_id == chat.id, + ).all() + for notif, msg in notifs_msgs: + msg.unread = True + g.db.delete(notif) + + g.db.commit() #to clear notif count + return render_template("private_chat.html", v=v, messages=displayed_messages, chat=chat) + + +@app.post("/chat//name") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def change_chat_name(v, chat_id): + chat = g.db.get(Chat, chat_id) + if not chat: + abort(404, "Chat not found!") + + if v.id != chat.owner_id: + abort(403, "Only the chat owner can change its name!") + + new_name = request.values.get("new_name").strip() + chat.name = new_name + g.db.add(chat) + + return redirect(f"/chat/{chat.id}") + +@app.post("/chat//leave") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def leave_chat(v, chat_id): + chat = g.db.get(Chat, chat_id) + if not chat: + abort(404, "Chat not found!") + + if v.id == chat.owner_id: + abort(403, "The chat owner can't leave it!") + + membership = g.db.query(ChatMembership).filter_by(user_id=v.id, chat_id=chat_id).one_or_none() + if not membership: + abort(400, "You're not a member of this chat!") + + g.db.delete(membership) + + chat_leave = ChatLeave( + user_id=v.id, + chat_id=chat_id, + ) + g.db.add(chat_leave) + + return {"message": "Chat left successfully!"} diff --git a/files/templates/chat.html b/files/templates/chat.html index 22ee4288a..d5c0f4965 100644 --- a/files/templates/chat.html +++ b/files/templates/chat.html @@ -12,11 +12,10 @@
+ {{macros.chat_users_online()}} - {{macros.chat_users_online()}} - -
- {{macros.chat_group_template()}} +
+ {{macros.chat_group_template()}}
diff --git a/files/templates/header.html b/files/templates/header.html index 648286381..bae39e70c 100644 --- a/files/templates/header.html +++ b/files/templates/header.html @@ -533,6 +533,31 @@ {% endif %}
+







+ {% elif request.path.startswith('/chat/') %} +
+
Members
+
    + {% for membership in chat.memberships %} + {% set user = membership.user %} + {% set patron = '' %} + {% if user.patron %} + {% set patron = patron + 'class="patron chat-patron" style="background-color:#' ~ user.name_color ~ '"' %} + {% endif %} + {% if user.pride_username(None) %} + {% set patron = patron + ' pride_username' %} + {% endif %} +
  • + + + {{user.username}} + + +
  • + {% endfor %} +
+
+







{% elif has_sidebar %} {% include "sidebar_" ~ SITE_NAME ~ ".html" %} diff --git a/files/templates/notifications.html b/files/templates/notifications.html index 6b8207d32..70633d384 100644 --- a/files/templates/notifications.html +++ b/files/templates/notifications.html @@ -14,6 +14,11 @@ All {% if v.normal_notifications_count %}({{v.normal_notifications_count}}){% endif %} +
  • + + + {{user.username}} + + +
  • + {% endfor %} +
    +
    + + + + + + + + + + + + +{% endblock %} diff --git a/files/templates/userpage/banner.html b/files/templates/userpage/banner.html index a793ec26c..553a7e82b 100644 --- a/files/templates/userpage/banner.html +++ b/files/templates/userpage/banner.html @@ -187,6 +187,11 @@ +
    + + +
    + {% if FEATURES['USERS_SUICIDE'] -%} @@ -513,6 +518,11 @@ +
    + + +
    + {% if FEATURES['USERS_SUICIDE'] -%} diff --git a/files/templates/util/macros.html b/files/templates/util/macros.html index 07608b346..8f14ec968 100644 --- a/files/templates/util/macros.html +++ b/files/templates/util/macros.html @@ -351,17 +351,30 @@ {% macro chat_window(vlink)%}
    - {% set messages_list = messages.items()|list %} - {% for id, m in messages_list %} - {% set same = loop.index > 1 and m.user_id == messages_list[loop.index-2][1].user_id %} - {% if not same %} - {% if loop.index > 1 %} -
    + {% if request.path == '/chat' %} + {% for id, m in messages.items()|list %} + {% set same = loop.index > 1 and m.user_id == messages_list[loop.index-2][1].user_id %} + {% if not same %} + {% if loop.index > 1 %} +
    + {% endif %} + {{chat_group_template(id, m)}} {% endif %} - {{chat_group_template(id, m)}} - {% endif %} - {{chat_line_template(id, m, vlink)}} - {% endfor %} + {{chat_line_template(id, m, vlink)}} + {% endfor %} + {% else %} + {% for m in messages %} + {% set id = m.id %} + {% set same = loop.index > 1 and m.user_id == messages[loop.index-2].user_id %} + {% if not same %} + {% if loop.index > 1 %} + + {% endif %} + {{chat_group_template(id, m)}} + {% endif %} + {{chat_line_template(id, m, vlink)}} + {% endfor %} + {% endif %} diff --git a/migrations/20240309-add-private-chats.sql b/migrations/20240309-add-private-chats.sql new file mode 100644 index 000000000..b72da947a --- /dev/null +++ b/migrations/20240309-add-private-chats.sql @@ -0,0 +1,104 @@ +create table chats ( + id integer primary key, + owner_id integer not null, + name varchar(40) not null, + created_utc integer not null +); + +alter table chats + add constraint chats_owner_fkey foreign key (owner_id) references users(id); + +CREATE SEQUENCE public.chats_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.chats_id_seq OWNED BY public.chats.id; + +ALTER TABLE ONLY public.chats ALTER COLUMN id SET DEFAULT nextval('public.chats_id_seq'::regclass); + + +create table chat_memberships ( + user_id integer not null, + chat_id integer not null, + created_utc integer not null +); + +alter table chat_memberships + add constraint chat_memberships_pkey primary key (user_id, chat_id); + +alter table chat_memberships + add constraint chat_memberships_user_fkey foreign key (user_id) references users(id); + +alter table chat_memberships + add constraint chat_memberships_chat_fkey foreign key (chat_id) references chats(id); + + +create table chat_leaves ( + user_id integer not null, + chat_id integer not null, + created_utc integer not null +); + +alter table chat_leaves + add constraint chat_leaves_pkey primary key (user_id, chat_id); + +alter table chat_leaves + add constraint chat_leaves_user_fkey foreign key (user_id) references users(id); + +alter table chat_leaves + add constraint chat_leaves_chat_fkey foreign key (chat_id) references chats(id); + + +create table chat_messages ( + id integer primary key, + user_id integer not null, + chat_id integer not null, + quotes integer, + text varchar(1000) not null, + text_censored varchar(1200) not null, + text_html varchar(5000) not null, + text_html_censored varchar(6000) not null, + created_utc integer not null +); + +alter table chat_messages + add constraint chat_messages_user_fkey foreign key (user_id) references users(id); + +alter table chat_messages + add constraint chat_messages_chat_fkey foreign key (chat_id) references chats(id); + +alter table chat_messages + add constraint chat_messages_quotes_fkey foreign key (quotes) references chat_messages(id); + + +CREATE SEQUENCE public.chat_messages_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.chat_messages_id_seq OWNED BY public.chat_messages.id; + +ALTER TABLE ONLY public.chat_messages ALTER COLUMN id SET DEFAULT nextval('public.chat_messages_id_seq'::regclass); + +create table chat_notifications ( + user_id integer not null, + chat_message_id integer not null, + chat_id integer not null, + created_utc integer not null +); + +alter table chat_notifications + add constraint chat_notifications_user_fkey foreign key (user_id) references users(id); + +alter table chat_notifications + add constraint chat_notifications_message_fkey foreign key (chat_message_id) references chat_messages(id); + +alter table chat_notifications + add constraint chat_notifications_chat_fkey foreign key (chat_id) references chats(id);