From 82a408072da036a25e8f78a44b4e38f46cbed6b0 Mon Sep 17 00:00:00 2001 From: Xevion Date: Fri, 22 Jan 2021 11:19:22 -0600 Subject: [PATCH] fix message adding/removing with textBox total refresh, compiled message formats, add proper html escaping, separate ReceiveWorker into worker.py --- client/gui.py | 126 ++++++++++++++--------------------------------- client/worker.py | 87 ++++++++++++++++++++++++++++++++ helpers.py | 8 +++ 3 files changed, 132 insertions(+), 89 deletions(-) create mode 100644 client/worker.py diff --git a/client/gui.py b/client/gui.py index bff2966..f03dcc4 100644 --- a/client/gui.py +++ b/client/gui.py @@ -1,15 +1,17 @@ -import json import logging import socket -import traceback +from pprint import pprint -from PyQt5.QtCore import QThread, pyqtSignal, Qt, QEvent, QTimer +from PyQt5.QtCore import Qt, QEvent, QTimer from PyQt5.QtWidgets import QMainWindow +from sortedcontainers import SortedList + import constants import helpers from client.MainWindow import Ui_MainWindow from client.dialog import NicknameDialog +from client.worker import ReceiveWorker IP = '127.0.0.1' PORT = 55555 @@ -18,87 +20,6 @@ logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(threadName)s] %(message)s') logger = logging.getLogger('gui') -HEADER_LENGTH = 10 - - -class ReceiveWorker(QThread): - messages = pyqtSignal(dict) - client_list = pyqtSignal(list) - error = pyqtSignal() - sent_nick = pyqtSignal() - logs = pyqtSignal(dict) - - def __init__(self, client: socket.socket, nickname: str, parent=None): - QThread.__init__(self, parent) - self.client = client - self.nickname = nickname - self.__isRunning = True - - def stop(self) -> None: - self.__isRunning = False - - def __extract_message(self, data) -> dict: - return { - 'nickname': data['nickname'], - 'message': data['content'], - 'color': data['color'], - 'time': data['time'], - 'id': data['id'] - } - - def log(self, message: str, level: int = logging.INFO, error: Exception = None): - """Send a log message out from this QThread to the MainThread""" - self.logs.emit( - { - 'message': message, - 'level': level, - 'error': error - } - ) - - def run(self): - try: - while self.__isRunning: - try: - raw_length = self.client.recv(HEADER_LENGTH).decode('utf-8') - if not raw_length: - continue - raw = self.client.recv(int(raw_length)).decode('utf-8') - if not raw: - continue - message = json.loads(raw) - - if message['type'] == constants.Types.REQUEST: - self.log(f'Data[{int(raw_length)}] received, {message["type"]}/{message["request"]}.', level=logging.DEBUG) - else: - self.log(f'Data[{int(raw_length)}] received, {message["type"]}.', level=logging.DEBUG) - - - if message['type'] == constants.Types.REQUEST: - if message['request'] == constants.Requests.REQUEST_NICK: - self.client.send(helpers.prepare_json( - { - 'type': constants.Types.NICKNAME, - 'nickname': self.nickname - } - )) - self.sent_nick.emit() - elif message['type'] == constants.Types.MESSAGE: - self.messages.emit(self.__extract_message(message)) - elif message['type'] == constants.Types.USER_LIST: - self.client_list.emit(message['users']) - elif message['type'] == constants.Types.MESSAGE_HISTORY: - for submessage in message['messages']: - self.messages.emit(self.__extract_message(submessage)) - - except Exception as e: - self.log(str(e), level=logging.CRITICAL, error=e) - self.error.emit() - break - finally: - self.log('Closing socket.', level=logging.INFO) - self.client.close() - class MainWindow(QMainWindow, Ui_MainWindow): def __init__(self, *args, **kwargs): @@ -137,7 +58,9 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.messageBox.setPlaceholderText('Type your message here...') self.messageBox.installEventFilter(self) - self.messages = [] + self.messages = SortedList(key=lambda message: message['time']) + self.added_messages = [] + self.max_message_id = -1 def log(self, log_data: dict) -> None: logger.log(level=log_data['level'], msg=log_data['message'], exc_info=log_data['error']) @@ -170,15 +93,40 @@ class MainWindow(QMainWindow, Ui_MainWindow): return True return super().eventFilter(obj, event) + def refresh_messages(self) -> None: + """Completely refresh the chat box text.""" + min_time = min(map(lambda msg: msg['time'], self.messages)) + + scrollbar = self.messageHistory.verticalScrollBar() + lastPosition = scrollbar.value() + atMaximum = lastPosition == scrollbar.maximum() + + self.messageHistory.setText('
'.join( + msg['compiled'] for msg in self.messages + )) + + scrollbar.setValue(scrollbar.maximum() if atMaximum else lastPosition) + def addMessage(self, message: dict) -> None: - self.messages.append(message) - self.messageHistory.append( - f'<{message["nickname"]}> {message["message"]}') + message_id = message['id'] + if message_id not in self.added_messages: + message['compiled'] = helpers.formatted_message(message) + + if 0 <= message_id < self.max_message_id: + logger.info('Refreshing entire chatbox...') + self.max_message_id = message_id + self.messages.add(message) + return + else: + self.max_message_id = message_id + self.added_messages.append(message_id) + self.messages.add(message) + + self.refresh_messages() def sendMessage(self, message: str) -> None: message = message.strip() if len(message) > 0: - logger.info(f'Sending message of length {len(message)}') self.client.send(helpers.prepare_json( { 'type': constants.Types.MESSAGE, diff --git a/client/worker.py b/client/worker.py new file mode 100644 index 0000000..90d5a20 --- /dev/null +++ b/client/worker.py @@ -0,0 +1,87 @@ +import json +import logging +import socket + +from PyQt5.QtCore import QThread, pyqtSignal + +import constants +import helpers + + +class ReceiveWorker(QThread): + messages = pyqtSignal(dict) + client_list = pyqtSignal(list) + error = pyqtSignal() + sent_nick = pyqtSignal() + logs = pyqtSignal(dict) + + def __init__(self, client: socket.socket, nickname: str, parent=None): + QThread.__init__(self, parent) + self.client = client + self.nickname = nickname + self.__isRunning = True + + def stop(self) -> None: + self.__isRunning = False + + def __extract_message(self, data) -> dict: + return { + 'nickname': data['nickname'], + 'message': data['content'], + 'color': data['color'], + 'time': data['time'], + 'id': data['id'] + } + + def log(self, message: str, level: int = logging.INFO, error: Exception = None): + """Send a log message out from this QThread to the MainThread""" + self.logs.emit( + { + 'message': message, + 'level': level, + 'error': error + } + ) + + def run(self): + try: + while self.__isRunning: + try: + raw_length = self.client.recv(constants.HEADER_LENGTH).decode('utf-8') + if not raw_length: + continue + raw = self.client.recv(int(raw_length)).decode('utf-8') + if not raw: + continue + message = json.loads(raw) + + if message['type'] == constants.Types.REQUEST: + self.log(f'Data[{int(raw_length)}] received, {message["type"]}/{message["request"]}.', + level=logging.DEBUG) + else: + self.log(f'Data[{int(raw_length)}] received, {message["type"]}.', level=logging.DEBUG) + + if message['type'] == constants.Types.REQUEST: + if message['request'] == constants.Requests.REQUEST_NICK: + self.client.send(helpers.prepare_json( + { + 'type': constants.Types.NICKNAME, + 'nickname': self.nickname + } + )) + self.sent_nick.emit() + elif message['type'] == constants.Types.MESSAGE: + self.messages.emit(self.__extract_message(message)) + elif message['type'] == constants.Types.USER_LIST: + self.client_list.emit(message['users']) + elif message['type'] == constants.Types.MESSAGE_HISTORY: + for submessage in message['messages']: + self.messages.emit(self.__extract_message(submessage)) + + except Exception as e: + self.log(str(e), level=logging.CRITICAL, error=e) + self.error.emit() + break + finally: + self.log('Closing socket.', level=logging.INFO) + self.client.close() diff --git a/helpers.py b/helpers.py index 415ae48..9009429 100644 --- a/helpers.py +++ b/helpers.py @@ -1,3 +1,4 @@ +import html import json import time from typing import List, Tuple @@ -57,3 +58,10 @@ def prepare_message_history(messages: List[Tuple[int, str, str, str, int]]) -> b def prepare_request(request: str) -> bytes: """Helper function for creating a request message.""" return prepare_json({'type': constants.Types.REQUEST, 'request': request}) + + +def formatted_message(message: dict) -> str: + """Given a message dict object, return a color formatted and GUI ready string.""" + nick_esc = html.escape(message["nickname"]) + msg_esc = html.escape(message["message"]) + return f'<{nick_esc}> {msg_esc}'