"""PyQt6 GUI client for encrypted chat.""" import asyncio import json import logging import os logger = logging.getLogger(__name__) import re import sys from functools import partial from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer, QUrl, QSize from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QLabel, QListWidget, QListWidgetItem, QTextEdit, QSplitter, QMessageBox, QInputDialog, QMenu, QStackedWidget, QDialog, QFileDialog, QScrollArea, QTextBrowser, ) from PyQt6.QtGui import QFont, QAction, QPixmap, QImage, QDesktopServices, QIcon, QPainter, QColor, QBrush, QPen, QShortcut, QKeySequence from PyQt6.QtWidgets import QStyle from chat_core import ChatClient # H10: Image validation limits MAX_IMAGE_DATA_SIZE = 10 * 1024 * 1024 # 10 MB max raw image data MAX_IMAGE_DIMENSION = 8192 # 8K pixels max def _safe_load_image(data: bytes) -> QImage | None: """Load image with size and dimension validation (H10).""" if not data or len(data) > MAX_IMAGE_DATA_SIZE: return None qimg = QImage.fromData(data) if qimg.isNull(): return None if qimg.width() > MAX_IMAGE_DIMENSION or qimg.height() > MAX_IMAGE_DIMENSION: return None return qimg def _safe_filename(name: str, default: str = "file") -> str: """Sanitize filename — strip path components, prevent traversal (H11).""" name = os.path.basename(name) name = name.replace("\x00", "") return name if name else default # URL regex: matches http:// and https:// URLs in raw (not-yet-escaped) text _URL_RE = re.compile( r'(https?://[^\s<>"\')\]]+)', re.IGNORECASE, ) _URL_TRAILING_PUNCT = re.compile(r'[.,;:!?]+$') def _linkify_urls(raw_text: str) -> str: """HTML-escape text and convert URLs into clickable tags. HTTPS links get blue styling. HTTP links get orange + unlock icon warning. Processes raw (unescaped) text — returns HTML-safe string. """ def _esc(s): return s.replace("&", "&").replace("<", "<").replace(">", ">") parts = _URL_RE.split(raw_text) result = [] for i, part in enumerate(parts): if i % 2 == 1: # URL match — strip trailing sentence punctuation trail_m = _URL_TRAILING_PUNCT.search(part) if trail_m: url = part[:trail_m.start()] trail = part[trail_m.start():] else: url = part trail = "" url_esc = _esc(url) if url.lower().startswith("http://"): result.append( f'' f'\U0001f513 {url_esc}' ) else: result.append( f'' f'{url_esc}' ) if trail: result.append(_esc(trail)) else: result.append(_esc(part)) return "".join(result) def setup_logging(): level_name = os.getenv("LOG_LEVEL", "WARNING").upper() level = getattr(logging, level_name, logging.WARNING) logging.basicConfig(level=level, format="%(levelname)s: %(message)s") DARK_STYLE = """ QWidget { background-color: #1e1e2e; color: #cdd6f4; font-family: "Segoe UI", "DejaVu Sans", sans-serif; font-size: 11pt; } QLineEdit { background-color: #313244; border: 1px solid #45475a; border-radius: 6px; padding: 8px; color: #cdd6f4; } QLineEdit:focus { border: 1px solid #89b4fa; } QPushButton { background-color: #89b4fa; color: #1e1e2e; border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; } QPushButton:hover { background-color: #74c7ec; } QPushButton:pressed { background-color: #89dceb; } QPushButton#secondaryBtn { background-color: #45475a; color: #cdd6f4; } QPushButton#secondaryBtn:hover { background-color: #585b70; } QListWidget { background-color: #181825; border: none; border-radius: 6px; padding: 4px; } QListWidget::item { padding: 10px; border-radius: 4px; } QListWidget::item:selected { background-color: #313244; border-left: 3px solid #89b4fa; } QListWidget::item:hover { background-color: #252536; color: #cdd6f4; } QTextEdit, QTextBrowser { background-color: #1e1e2e; border: none; border-radius: 6px; padding: 8px; color: #cdd6f4; } QScrollBar:vertical { background: #1e1e2e; width: 10px; border-radius: 5px; } QScrollBar::handle:vertical { background: #45475a; border-radius: 5px; min-height: 30px; } QScrollBar::handle:vertical:hover { background: #585b70; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } QLabel#title { font-size: 15pt; font-weight: bold; color: #89b4fa; } QSplitter::handle { background-color: #45475a; width: 1px; } """ MAX_INPUT_CHARS = int(os.getenv("MAX_INPUT_CHARS", "2000")) class MessageInput(QTextEdit): """Multiline message input: Enter sends, Shift+Enter inserts newline.""" send_requested = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self.setAcceptRichText(False) self.setPlaceholderText("Type a message...") self.setFixedHeight(72) self.setStyleSheet( "QTextEdit { background-color: #313244; border: 1px solid #45475a; " "border-radius: 6px; padding: 8px; color: #cdd6f4; }" "QTextEdit:focus { border: 1px solid #89b4fa; }" ) def keyPressEvent(self, event): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: super().keyPressEvent(event) else: self.send_requested.emit() return super().keyPressEvent(event) class AsyncBridge(QThread): """Runs asyncio event loop in a background thread, emits Qt signals.""" connected = pyqtSignal() connection_error = pyqtSignal(str) login_result = pyqtSignal(bool, str) register_result = pyqtSignal(bool, str) conversations_loaded = pyqtSignal(list) messages_loaded = pyqtSignal(str, list) # conv_id, messages older_messages_loaded = pyqtSignal(str, list) # conv_id, older messages message_sent = pyqtSignal(bool, str) new_notification = pyqtSignal(dict) # decrypted payload pairing_code = pyqtSignal(str) pairing_complete = pyqtSignal(bool, str) add_member_result = pyqtSignal(bool, str) remove_member_result = pyqtSignal(bool, str) authorize_result = pyqtSignal(bool, str) rotate_result = pyqtSignal(bool, str) reencrypt_status = pyqtSignal(str) messages_read_notification = pyqtSignal(dict) message_deleted_notification = pyqtSignal(dict) image_sent = pyqtSignal(bool, str) image_downloaded = pyqtSignal(str, bytes) # file_id, decrypted bytes delete_message_result = pyqtSignal(bool, str) reconnected = pyqtSignal() conversation_updated = pyqtSignal() connection_state_changed = pyqtSignal(str) # "connected", "disconnected", "reconnecting" profile_loaded = pyqtSignal(dict) profile_updated = pyqtSignal(bool, str) avatar_loaded = pyqtSignal(str, bytes) # user_id, avatar_bytes online_status_changed = pyqtSignal(str, bool) # user_id, is_online online_users_loaded = pyqtSignal(list) # list of user_ids invitations_loaded = pyqtSignal(list) # list of invitation dicts invitation_result = pyqtSignal(bool, str) # ok, message invitation_received = pyqtSignal(dict) # invitation notification data group_avatar_loaded = pyqtSignal(str, bytes) # conv_id, avatar_bytes group_avatar_updated = pyqtSignal(bool, str) # ok, message session_reset_notification = pyqtSignal(str, str) # from_user_id, from_device_id def __init__(self): super().__init__() self.client = ChatClient() self.loop: asyncio.AbstractEventLoop | None = None self._running = True self.client._reencrypt_progress_cb = self._emit_reencrypt_status self._ready: asyncio.Event | None = None def _emit_reencrypt_status(self, message: str): self.reencrypt_status.emit(message) def run(self): if sys.platform == "win32": self.loop = asyncio.SelectorEventLoop() else: self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self._ready = asyncio.Event() try: self.loop.run_until_complete(self._run()) except Exception: pass finally: self.loop.close() async def _run(self): try: await self.client.connect() self.client._listener_task = asyncio.create_task(self.client._background_listener()) if self._ready: self._ready.set() self.connected.emit() self.connection_state_changed.emit("connected") except Exception as e: self.connection_error.emit(str(e)) return # Process notifications await self._notification_loop() async def _notification_loop(self): while self._running: try: # Check if listener task died (connection lost) if (self.client._listener_task and self.client._listener_task.done() and not self.client.connected): self.connection_state_changed.emit("disconnected") if self.client.session: await self._auto_reconnect() continue notif = await asyncio.wait_for( self.client._notification_queue.get(), timeout=0.5 ) notif_type = notif.get("type", "") data = notif.get("data", {}) if notif_type in ("conversation_created", "member_added", "member_removed", "conversation_renamed"): self.conversation_updated.emit() elif notif_type == "group_invitation": self.invitation_received.emit(data) elif notif_type == "user_online": self.online_status_changed.emit(data.get("user_id", ""), True) elif notif_type == "user_offline": self.online_status_changed.emit(data.get("user_id", ""), False) elif notif_type == "online_users": self.online_users_loaded.emit(data.get("user_ids", [])) elif notif_type == "messages_read": self.messages_read_notification.emit(data) elif notif_type == "message_deleted": self.message_deleted_notification.emit(data) elif notif_type == "session_reset": from_uid = data.get("from_user_id", "") from_did = data.get("from_device_id", "") self.client.handle_session_reset_notification(from_uid, from_did or None) self.session_reset_notification.emit(from_uid, from_did) elif notif_type == "new_message": payload = self.client.decrypt_notification(data) if payload: self.new_notification.emit(payload) # None = control message (e.g. sender key distribution), skip silently except asyncio.TimeoutError: continue except Exception: break async def _auto_reconnect(self): """Auto-reconnect with exponential backoff.""" delay = 1 while self._running and not self.client.connected: self.connection_state_changed.emit("reconnecting") try: await self.client.reconnect() if self.client.connected and self.client.session: self.connection_state_changed.emit("connected") self.conversation_updated.emit() return if self.client.login_rejected: self.connection_state_changed.emit("revoked") return except Exception: pass await asyncio.sleep(delay) delay = min(delay * 2, 30) def schedule(self, coro): """Schedule a coroutine on the asyncio loop from the Qt thread.""" if self.loop and self.loop.is_running(): asyncio.run_coroutine_threadsafe(coro, self.loop) else: # Avoid "coroutine was never awaited" warnings if loop is down. try: coro.close() except Exception: pass async def _do_register(self, username, password, email): if self._ready: await self._ready.wait() ok, code_or_msg = await self.client.register(username, password, email=email) self.register_result.emit(ok, code_or_msg) async def _do_login(self, email, password): if self._ready: await self._ready.wait() ok, msg = await self.client.login(email, password) self.login_result.emit(ok, msg) async def _do_logout(self): if self._ready: self._ready.clear() try: await self.client.close() except Exception: pass self.client = ChatClient() self.client._reencrypt_progress_cb = self._emit_reencrypt_status try: await self.client.connect() self.client._listener_task = asyncio.create_task(self.client._background_listener()) if self._ready: self._ready.set() self.reconnected.emit() except Exception as e: self.connection_error.emit(str(e)) async def _do_load_conversations(self): if self._ready: await self._ready.wait() convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) async def _do_load_messages(self, conv_id): if self._ready: await self._ready.wait() msgs = await self.client.get_messages(conv_id) self.messages_loaded.emit(conv_id, msgs) async def _do_load_older_messages(self, conv_id, offset): if self._ready: await self._ready.wait() msgs = await self.client.get_messages(conv_id, limit=50, offset=offset) self.older_messages_loaded.emit(conv_id, msgs) async def _do_send_message(self, conv_id, text, members, reply_to=None): if self._ready: await self._ready.wait() try: ok, msg = await self.client.send_message(conv_id, text, members, reply_to=reply_to) except Exception as e: logger.error("send_message exception: %s", e, exc_info=True) self.message_sent.emit(False, str(e)) return self.message_sent.emit(ok, msg) if ok: # Reload messages to get the server-assigned message_id and timestamp msgs = await self.client.get_messages(conv_id, limit=50) self.messages_loaded.emit(conv_id, msgs) async def _do_find_or_create_and_send(self, username, text): if self._ready: await self._ready.wait() try: conv_id, msg = await self.client.find_or_create_conversation(username) if not conv_id: self.message_sent.emit(False, msg) return convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) members = [] for c in convs: if c["conversation_id"] == conv_id: members = c["members"] break ok, msg = await self.client.send_message(conv_id, text, members) self.message_sent.emit(ok, msg) if ok: msgs = await self.client.get_messages(conv_id) self.messages_loaded.emit(conv_id, msgs) except Exception as e: logger.error("find_or_create_and_send exception: %s", e, exc_info=True) self.message_sent.emit(False, str(e)) async def _do_create_group(self, members, name=None): if self._ready: await self._ready.wait() conv_id, msg = await self.client.create_conversation(members, name=name) if conv_id: self.message_sent.emit(True, f"Group created") else: self.message_sent.emit(False, msg) convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) async def _do_link_device(self, username, password): if self._ready: await self._ready.wait() ok, code_or_msg = await self.client.pairing_start(username) if not ok: self.pairing_complete.emit(False, code_or_msg) return code = code_or_msg self.pairing_code.emit(code) ok2, msg2 = await self.client.pairing_wait(code, username, password) self.pairing_complete.emit(ok2, msg2) async def _do_authorize_device(self, code): if self._ready: await self._ready.wait() ok, msg = await self.client.authorize_device(code) self.authorize_result.emit(ok, msg) async def _do_rotate_keys(self, username, password): if self._ready: await self._ready.wait() ok, msg = await self.client.rotate_keys(username, password) self.rotate_result.emit(ok, msg) def do_register(self, username, password, email): self.schedule(self._do_register(username, password, email)) def do_login(self, email, password): self.schedule(self._do_login(email, password)) def load_conversations(self): self.schedule(self._do_load_conversations()) def load_messages(self, conv_id): self.schedule(self._do_load_messages(conv_id)) def load_older_messages(self, conv_id, offset): self.schedule(self._do_load_older_messages(conv_id, offset)) def send_message(self, conv_id, text, members, reply_to=None): self.schedule(self._do_send_message(conv_id, text, members, reply_to)) def send_new_chat(self, username, text): self.schedule(self._do_find_or_create_and_send(username, text)) def create_group(self, members, name=None): self.schedule(self._do_create_group(members, name=name)) async def _do_add_member(self, conv_id, email): if self._ready: await self._ready.wait() ok, msg = await self.client.add_member(conv_id, email) self.add_member_result.emit(ok, msg) if ok: convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def add_member(self, conv_id, email): self.schedule(self._do_add_member(conv_id, email)) async def _do_remove_member(self, conv_id, user_id): if self._ready: await self._ready.wait() ok, msg = await self.client.remove_member(conv_id, user_id) self.remove_member_result.emit(ok, msg) if ok: convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def remove_member(self, conv_id, user_id): self.schedule(self._do_remove_member(conv_id, user_id)) group_left = pyqtSignal(bool, str) group_renamed = pyqtSignal(bool, str) conversation_deleted = pyqtSignal(bool, str) async def _do_leave_group(self, conv_id): if self._ready: await self._ready.wait() ok, msg = await self.client.leave_group(conv_id) self.group_left.emit(ok, msg) if ok: convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def leave_group(self, conv_id): self.schedule(self._do_leave_group(conv_id)) async def _do_rename_conversation(self, conv_id, name): if self._ready: await self._ready.wait() ok, msg = await self.client.rename_conversation(conv_id, name) self.group_renamed.emit(ok, msg) if ok: convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def rename_conversation(self, conv_id, name): self.schedule(self._do_rename_conversation(conv_id, name)) async def _do_delete_conversation(self, conv_id): if self._ready: await self._ready.wait() ok, msg = await self.client.delete_conversation(conv_id) self.conversation_deleted.emit(ok, msg) if ok: convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def delete_conversation(self, conv_id): self.schedule(self._do_delete_conversation(conv_id)) def link_device(self, username, password): self.schedule(self._do_link_device(username, password)) def authorize_device(self, code): self.schedule(self._do_authorize_device(code)) def rotate_keys(self, username, password): self.schedule(self._do_rotate_keys(username, password)) async def _do_delete_message(self, message_id): if self._ready: await self._ready.wait() ok, msg = await self.client.delete_message(message_id) self.delete_message_result.emit(ok, msg) def delete_message(self, message_id): self.schedule(self._do_delete_message(message_id)) def reset_session(self, peer_user_id, peer_device_id=None): self.schedule(self.client.reset_session(peer_user_id, peer_device_id)) async def _do_send_image(self, conv_id, image_path, members, reply_to=None): if self._ready: await self._ready.wait() ok, msg = await self.client.send_image(conv_id, image_path, members, reply_to=reply_to) self.image_sent.emit(ok, msg) if ok: msgs = await self.client.get_messages(conv_id, limit=50) self.messages_loaded.emit(conv_id, msgs) def send_image(self, conv_id, image_path, members, reply_to=None): self.schedule(self._do_send_image(conv_id, image_path, members, reply_to)) async def _do_download_image(self, file_id, image_info): if self._ready: await self._ready.wait() data = await self.client.download_image(file_id, image_info) if data: self.image_downloaded.emit(file_id, data) def download_image(self, file_id, image_info): self.schedule(self._do_download_image(file_id, image_info)) file_sent = pyqtSignal(bool, str) file_downloaded = pyqtSignal(bytes, dict) # decrypted_bytes, file_info async def _do_send_file(self, conv_id, file_path, members, reply_to=None): if self._ready: await self._ready.wait() ok, msg = await self.client.send_file(conv_id, file_path, members, reply_to=reply_to) self.file_sent.emit(ok, msg) if ok: msgs = await self.client.get_messages(conv_id, limit=50) self.messages_loaded.emit(conv_id, msgs) def send_file(self, conv_id, file_path, members, reply_to=None): self.schedule(self._do_send_file(conv_id, file_path, members, reply_to)) async def _do_download_file(self, file_id, file_info): if self._ready: await self._ready.wait() data = await self.client.download_file(file_id, file_info) if data: self.file_downloaded.emit(data, file_info) def download_file(self, file_id, file_info): self.schedule(self._do_download_file(file_id, file_info)) async def _do_get_profile(self, user_id=None): if self._ready: await self._ready.wait() profile = await self.client.get_profile(user_id) if profile: self.profile_loaded.emit(profile) def get_profile(self, user_id=None): self.schedule(self._do_get_profile(user_id)) async def _do_update_profile(self, **fields): if self._ready: await self._ready.wait() ok, msg = await self.client.update_profile(**fields) self.profile_updated.emit(ok, msg) def update_profile(self, **fields): self.schedule(self._do_update_profile(**fields)) async def _do_update_avatar(self, image_data): if self._ready: await self._ready.wait() ok, msg = await self.client.update_avatar(image_data) self.profile_updated.emit(ok, msg) def update_avatar(self, image_data): self.schedule(self._do_update_avatar(image_data)) async def _do_get_avatar(self, user_id): if self._ready: await self._ready.wait() data = await self.client.get_avatar(user_id) if data: self.avatar_loaded.emit(user_id, data) def get_avatar(self, user_id): self.schedule(self._do_get_avatar(user_id)) async def _do_list_invitations(self): if self._ready: await self._ready.wait() invitations = await self.client.list_invitations() self.invitations_loaded.emit(invitations) def list_invitations(self): self.schedule(self._do_list_invitations()) async def _do_accept_invitation(self, conv_id): if self._ready: await self._ready.wait() ok, msg = await self.client.accept_invitation(conv_id) self.invitation_result.emit(ok, msg) if ok: invitations = await self.client.list_invitations() self.invitations_loaded.emit(invitations) convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def accept_invitation(self, conv_id): self.schedule(self._do_accept_invitation(conv_id)) async def _do_decline_invitation(self, conv_id): if self._ready: await self._ready.wait() ok, msg = await self.client.decline_invitation(conv_id) self.invitation_result.emit(ok, msg) if ok: invitations = await self.client.list_invitations() self.invitations_loaded.emit(invitations) def decline_invitation(self, conv_id): self.schedule(self._do_decline_invitation(conv_id)) async def _do_update_group_avatar(self, conv_id, image_data): if self._ready: await self._ready.wait() ok, msg = await self.client.update_group_avatar(conv_id, image_data) self.group_avatar_updated.emit(ok, msg) if ok: convs = await self.client.list_conversations() self.conversations_loaded.emit(convs) def update_group_avatar(self, conv_id, image_data): self.schedule(self._do_update_group_avatar(conv_id, image_data)) async def _do_get_group_avatar(self, conv_id): if self._ready: await self._ready.wait() data = await self.client.get_group_avatar(conv_id) if data: self.group_avatar_loaded.emit(conv_id, data) def get_group_avatar(self, conv_id): self.schedule(self._do_get_group_avatar(conv_id)) def logout(self): self.schedule(self._do_logout()) def stop(self): self._running = False if self.loop: asyncio.run_coroutine_threadsafe(self.client.close(), self.loop) class UserProfileDialog(QDialog): """Dialog for viewing/editing user profiles.""" def __init__(self, bridge: AsyncBridge, user_id: str, editable: bool = False, parent=None): super().__init__(parent) self.bridge = bridge self.user_id = user_id self.editable = editable self.setWindowTitle("User Profile" if not editable else "Edit Profile") self.setMinimumWidth(400) self._build_ui() self._connect_signals() self.bridge.get_profile(user_id) def _build_ui(self): self.layout_main = QVBoxLayout(self) self.layout_main.setSpacing(12) self.layout_main.setContentsMargins(24, 20, 24, 20) # Avatar self.avatar_label = QLabel() self.avatar_label.setFixedSize(80, 80) self.avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.avatar_label.setStyleSheet( "background-color: #313244; border-radius: 40px; " "font-size: 21pt; color: #89b4fa;" ) self.avatar_label.setText("?") self.layout_main.addWidget(self.avatar_label, alignment=Qt.AlignmentFlag.AlignCenter) if self.editable: avatar_btn = QPushButton("Change Avatar") avatar_btn.setObjectName("secondaryBtn") avatar_btn.clicked.connect(self._on_change_avatar) self.layout_main.addWidget(avatar_btn, alignment=Qt.AlignmentFlag.AlignCenter) # Info fields self.username_label = QLabel("") self.username_label.setStyleSheet("font-size: 14pt; font-weight: bold; color: #89b4fa;") self.username_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout_main.addWidget(self.username_label) self.info_area = QVBoxLayout() self.layout_main.addLayout(self.info_area) # Editable fields (only shown in edit mode) if self.editable: self.layout_main.addSpacing(8) form_label = QLabel("Profile Settings") form_label.setStyleSheet("font-weight: bold; color: #89b4fa;") self.layout_main.addWidget(form_label) self.phone_input = QLineEdit() self.phone_input.setPlaceholderText("Phone number") self.layout_main.addWidget(self.phone_input) self.location_input = QLineEdit() self.location_input.setPlaceholderText("Location") self.layout_main.addWidget(self.location_input) from PyQt6.QtWidgets import QCheckBox self.email_visible_cb = QCheckBox("Email visible to others") self.email_visible_cb.setStyleSheet("color: #cdd6f4;") self.layout_main.addWidget(self.email_visible_cb) self.phone_visible_cb = QCheckBox("Phone visible to others") self.phone_visible_cb.setStyleSheet("color: #cdd6f4;") self.layout_main.addWidget(self.phone_visible_cb) self.location_visible_cb = QCheckBox("Location visible to others") self.location_visible_cb.setStyleSheet("color: #cdd6f4;") self.layout_main.addWidget(self.location_visible_cb) save_btn = QPushButton("Save") save_btn.clicked.connect(self._on_save) self.layout_main.addWidget(save_btn) close_btn = QPushButton("Close") close_btn.setObjectName("secondaryBtn") close_btn.clicked.connect(self.accept) self.layout_main.addWidget(close_btn) def _connect_signals(self): self.bridge.profile_loaded.connect(self._on_profile_loaded) self.bridge.avatar_loaded.connect(self._on_avatar_loaded) self.bridge.profile_updated.connect(self._on_profile_updated) def _on_profile_loaded(self, profile): if profile.get("user_id") != self.user_id: return username = profile.get("username", "?") self.username_label.setText(username) # Set avatar initial self.avatar_label.setText(username[0].upper() if username else "?") # Clear info area while self.info_area.count(): item = self.info_area.takeAt(0) if item.widget(): item.widget().deleteLater() # Email email = profile.get("email") if email: self.info_area.addWidget(QLabel(f"Email: {email}")) # Phone phone = profile.get("phone") if phone: self.info_area.addWidget(QLabel(f"Phone: {phone}")) # Location location = profile.get("location") if location: self.info_area.addWidget(QLabel(f"Location: {location}")) # Member since created_at = profile.get("created_at", "") if created_at: date_str = created_at[:10] if len(created_at) >= 10 else created_at label = QLabel(f"Member since: {date_str}") label.setStyleSheet("color: #6c7086;") self.info_area.addWidget(label) # Populate editable fields if self.editable: self.phone_input.setText(phone or "") self.location_input.setText(location or "") self.email_visible_cb.setChecked(bool(profile.get("email_visible", 1))) self.phone_visible_cb.setChecked(bool(profile.get("phone_visible", 0))) self.location_visible_cb.setChecked(bool(profile.get("location_visible", 0))) # Try to load avatar if profile.get("avatar_file"): self.bridge.get_avatar(self.user_id) def _on_avatar_loaded(self, user_id, data): if user_id != self.user_id: return qimg = _safe_load_image(data) if qimg is not None: pixmap = QPixmap.fromImage(qimg) # Circular crop size = 80 scaled = pixmap.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) result = QPixmap(size, size) result.fill(QColor(0, 0, 0, 0)) painter = QPainter(result) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QBrush(scaled)) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(0, 0, size, size) painter.end() self.avatar_label.setPixmap(result) def _on_change_avatar(self): path, _ = QFileDialog.getOpenFileName( self, "Select Avatar", "", "Images (*.png *.jpg *.jpeg);;All Files (*)", ) if not path: return try: with open(path, "rb") as f: data = f.read() if len(data) > 2 * 1024 * 1024: QMessageBox.warning(self, "Error", "Avatar too large (max 2 MB).") return self.bridge.update_avatar(data) except Exception as e: QMessageBox.warning(self, "Error", f"Failed to read file: {e}") def _on_save(self): fields = { "phone": self.phone_input.text().strip() or None, "location": self.location_input.text().strip() or None, "email_visible": 1 if self.email_visible_cb.isChecked() else 0, "phone_visible": 1 if self.phone_visible_cb.isChecked() else 0, "location_visible": 1 if self.location_visible_cb.isChecked() else 0, } self.bridge.update_profile(**fields) def _on_profile_updated(self, ok, msg): if ok: # Refresh profile self.bridge.get_profile(self.user_id) else: QMessageBox.warning(self, "Error", msg) def closeEvent(self, event): # Disconnect signals to avoid stale references try: self.bridge.profile_loaded.disconnect(self._on_profile_loaded) self.bridge.avatar_loaded.disconnect(self._on_avatar_loaded) self.bridge.profile_updated.disconnect(self._on_profile_updated) except Exception: pass super().closeEvent(event) def reject(self): try: self.bridge.profile_loaded.disconnect(self._on_profile_loaded) self.bridge.avatar_loaded.disconnect(self._on_avatar_loaded) self.bridge.profile_updated.disconnect(self._on_profile_updated) except Exception: pass super().reject() def accept(self): try: self.bridge.profile_loaded.disconnect(self._on_profile_loaded) self.bridge.avatar_loaded.disconnect(self._on_avatar_loaded) self.bridge.profile_updated.disconnect(self._on_profile_updated) except Exception: pass super().accept() class LoginWindow(QWidget): def __init__(self, bridge: AsyncBridge): super().__init__() self.bridge = bridge self.setWindowTitle("Encrypted Chat - Login") self.setFixedSize(500, 480) self._pair_email = "" self._pair_password = "" self._build_ui() def _build_ui(self): outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) self.stack = QStackedWidget() outer.addWidget(self.stack) # --- Page 0: Login / Register form --- page0 = QWidget() layout = QVBoxLayout(page0) layout.setSpacing(14) layout.setContentsMargins(50, 40, 50, 40) title = QLabel("Encrypted Chat") title.setObjectName("title") title.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(title) subtitle = QLabel("End-to-end encrypted messaging") subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) subtitle.setStyleSheet("color: #6c7086; font-size: 9pt; margin-bottom: 8px;") layout.addWidget(subtitle) layout.addSpacing(6) self.username_input = QLineEdit() self.username_input.setPlaceholderText("Username (display)") self.username_input.returnPressed.connect(self._on_login) layout.addWidget(self.username_input) self.email_input = QLineEdit() self.email_input.setPlaceholderText("Email") layout.addWidget(self.email_input) self.password_input = QLineEdit() self.password_input.setPlaceholderText("Password") self.password_input.setEchoMode(QLineEdit.EchoMode.Password) self.password_input.returnPressed.connect(self._on_login) layout.addWidget(self.password_input) btn_row = QHBoxLayout() self.register_btn = QPushButton("Register") self.register_btn.setObjectName("secondaryBtn") self.register_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) self.register_btn.clicked.connect(self._on_register) btn_row.addWidget(self.register_btn) self.login_btn = QPushButton("Login") self.login_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton)) self.login_btn.clicked.connect(self._on_login) btn_row.addWidget(self.login_btn) layout.addLayout(btn_row) self.link_btn = QPushButton("Link Device") self.link_btn.setObjectName("secondaryBtn") self.link_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOpenButton)) self.link_btn.clicked.connect(self._on_link_device) layout.addWidget(self.link_btn) self.status_label = QLabel("") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setWordWrap(True) layout.addWidget(self.status_label) layout.addStretch() self.stack.addWidget(page0) # --- Page 1: Verification code form --- page1 = QWidget() vl = QVBoxLayout(page1) vl.setSpacing(14) vl.setContentsMargins(50, 40, 50, 40) step_label = QLabel("Step 2 of 2") step_label.setStyleSheet("color: #89b4fa; font-weight: bold; font-size: 10pt;") step_label.setAlignment(Qt.AlignmentFlag.AlignCenter) vl.addWidget(step_label) info_label = QLabel("Enter the 6-digit verification code sent to your email") info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) info_label.setWordWrap(True) info_label.setStyleSheet("color: #cdd6f4; font-size: 10pt;") vl.addWidget(info_label) vl.addSpacing(12) self.code_input = QLineEdit() self.code_input.setPlaceholderText("000000") self.code_input.setMaxLength(6) self.code_input.setAlignment(Qt.AlignmentFlag.AlignCenter) self.code_input.setStyleSheet( "QLineEdit { font-size: 16pt; letter-spacing: 8px; text-align: center; " "background-color: #313244; border: 1px solid #45475a; border-radius: 6px; padding: 12px; }" "QLineEdit:focus { border: 1px solid #89b4fa; }" ) self.code_input.returnPressed.connect(self._on_confirm_code) vl.addWidget(self.code_input) vl.addSpacing(8) self.code_status_label = QLabel("") self.code_status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.code_status_label.setWordWrap(True) vl.addWidget(self.code_status_label) code_btn_row = QHBoxLayout() self.back_btn = QPushButton("Back") self.back_btn.setObjectName("secondaryBtn") self.back_btn.clicked.connect(self._on_back_to_login) code_btn_row.addWidget(self.back_btn) self.confirm_btn = QPushButton("Confirm") self.confirm_btn.clicked.connect(self._on_confirm_code) code_btn_row.addWidget(self.confirm_btn) vl.addLayout(code_btn_row) vl.addStretch() self.stack.addWidget(page1) def show_verification_page(self, message=""): """Switch to verification code page.""" self.code_input.clear() self.code_status_label.setText(message) self.code_status_label.setStyleSheet("color: #a6e3a1;") self.stack.setCurrentIndex(1) self.code_input.setFocus() def _on_confirm_code(self): code = self.code_input.text().strip() if not code: self.code_status_label.setText("Please enter the code.") self.code_status_label.setStyleSheet("color: #f38ba8;") return self.code_status_label.setText("Confirming...") self.code_status_label.setStyleSheet("color: #a6e3a1;") self.confirm_btn.setEnabled(False) self.back_btn.setEnabled(False) # Callback set by main() to handle confirmation if hasattr(self, '_confirm_callback'): self._confirm_callback(code) def _on_back_to_login(self): self.stack.setCurrentIndex(0) self._set_enabled(True) self.status_label.setText("") self.status_label.setStyleSheet("") def _on_register(self): username = self.username_input.text().strip() password = self.password_input.text() email = self.email_input.text().strip() if not username: return if not email or not password: self.show_error("Email and password required.") return self.status_label.setText("Registering...") self._set_enabled(False) self.bridge.do_register(username, password, email) def _on_login(self): email = self.email_input.text().strip() password = self.password_input.text() if not email or not password: self.show_error("Email and password required.") return self.status_label.setText("Logging in...") self._set_enabled(False) self.bridge.do_login(email, password) def _on_link_device(self): email = self.email_input.text().strip() password = self.password_input.text() if not email or not password: self.show_error("Email and password required.") return self._pair_email = email self._pair_password = password self.status_label.setText("Generating pairing code...") self._set_enabled(False) self.bridge.link_device(email, password) def _set_enabled(self, enabled): self.username_input.setEnabled(enabled) self.email_input.setEnabled(enabled) self.password_input.setEnabled(enabled) self.register_btn.setEnabled(enabled) self.login_btn.setEnabled(enabled) self.link_btn.setEnabled(enabled) def show_error(self, msg): if self.stack.currentIndex() == 1: self.code_status_label.setText(msg) self.code_status_label.setStyleSheet("color: #f38ba8;") self.confirm_btn.setEnabled(True) self.back_btn.setEnabled(True) else: self.status_label.setText(msg) self.status_label.setStyleSheet("color: #f38ba8;") self._set_enabled(True) def show_success(self, msg): if self.stack.currentIndex() == 1: self.code_status_label.setText(msg) self.code_status_label.setStyleSheet("color: #a6e3a1;") else: self.status_label.setText(msg) self.status_label.setStyleSheet("color: #a6e3a1;") def reset(self): self.stack.setCurrentIndex(0) self.status_label.setText("") self.status_label.setStyleSheet("") self.code_status_label.setText("") self.code_status_label.setStyleSheet("") self.username_input.clear() self.email_input.clear() self.password_input.clear() self.code_input.clear() self._set_enabled(True) self.confirm_btn.setEnabled(True) self.back_btn.setEnabled(True) class MainWindow(QWidget): def __init__(self, bridge: AsyncBridge, on_logout): super().__init__() self.bridge = bridge self._on_logout_cb = on_logout self.setWindowTitle(f"Encrypted Chat - {bridge.client.username}") self.resize(900, 600) self.conversations: list[dict] = [] self.current_conv_id: str | None = None self.current_messages: list[dict] = [] self.reply_to_id: str | None = None self._unread_counts: dict[str, int] = {} self._has_more_messages: bool = True self._pending_image_download: dict | None = None # {file_id, image_info} self._is_dm: bool = False self._online_users: set[str] = set() self._is_logout = False self._avatar_cache: dict[str, QPixmap] = {} # user_id -> pixmap self._group_avatar_cache: dict[str, QPixmap] = {} # conv_id -> pixmap self._avatar_requested: set[str] = set() self._group_avatar_requested: set[str] = set() self._pending_invitations: list[dict] = [] self._favorites: set[str] = self._load_favorites() # Search state self._search_results: list[int] = [] # indices into current_messages self._search_current: int = -1 self._search_query: str = "" self._search_active: bool = False self._build_ui() self._connect_signals() # Keyboard shortcuts QShortcut(QKeySequence("Ctrl+F"), self).activated.connect(self._toggle_search) self.bridge.load_conversations() self.bridge.list_invitations() # Periodic refresh: re-download avatars and conversation data self._refresh_timer = QTimer(self) self._refresh_timer.timeout.connect(self._on_periodic_refresh) self._refresh_timer.start(120_000) # every 2 minutes def _bold_font(self) -> QFont: """Return a bold font with a valid size (avoids QFont pointSize=-1 warnings).""" f = QFont(self.conv_list.font()) f.setBold(True) # Stylesheet sets font-size in px so pointSize is -1; fix by using pixelSize if f.pointSize() <= 0: px = f.pixelSize() if px > 0: f.setPixelSize(px) else: f.setPointSize(10) return f def _make_circular_avatar(self, pixmap: QPixmap, size: int = 32) -> QPixmap: """Crop a pixmap into a circle.""" scaled = pixmap.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) result = QPixmap(size, size) result.fill(QColor(0, 0, 0, 0)) painter = QPainter(result) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QBrush(scaled)) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(0, 0, size, size) painter.end() return result def _make_default_avatar(self, username: str, size: int = 32) -> QPixmap: """Generate a colored circle with the first letter of the username.""" # Deterministic color from username hue = (hash(username) % 360) color = QColor.fromHsv(hue, 120, 200) result = QPixmap(size, size) result.fill(QColor(0, 0, 0, 0)) painter = QPainter(result) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QBrush(color)) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(0, 0, size, size) # Draw letter painter.setPen(QColor(255, 255, 255)) font = QFont("Segoe UI", int(size * 0.4)) font.setBold(True) painter.setFont(font) letter = username[0].upper() if username else "?" painter.drawText(0, 0, size, size, Qt.AlignmentFlag.AlignCenter, letter) painter.end() return result def _add_online_dot(self, avatar: QPixmap) -> QPixmap: """Overlay a green dot on the bottom-right of an avatar pixmap.""" result = QPixmap(avatar) painter = QPainter(result) painter.setRenderHint(QPainter.RenderHint.Antialiasing) dot_size = max(8, avatar.width() // 4) x = avatar.width() - dot_size y = avatar.height() - dot_size # Dark border painter.setBrush(QBrush(QColor(0x1e, 0x1e, 0x2e))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(x - 1, y - 1, dot_size + 2, dot_size + 2) # Green dot painter.setBrush(QBrush(QColor(0xa6, 0xe3, 0xa1))) painter.drawEllipse(x, y, dot_size, dot_size) painter.end() return result def _get_conv_avatar(self, conv: dict) -> QIcon: """Get avatar icon for a conversation list item.""" is_dm = len(conv["members"]) == 2 and not conv.get("name") if is_dm: other = None for m in conv["members"]: if m.get("email") != self.bridge.client.email: other = m break if other: uid = other.get("user_id") or other.get("id") or "" uname = other.get("username") or other.get("email") or "?" if uid in self._avatar_cache: avatar = self._make_circular_avatar(self._avatar_cache[uid]) else: avatar = self._make_default_avatar(uname) # Request avatar download if not yet requested if uid and uid not in self._avatar_requested: self._avatar_requested.add(uid) self.bridge.get_avatar(uid) if uid in self._online_users: avatar = self._add_online_dot(avatar) return QIcon(avatar) # Group: use group avatar if available conv_id = conv.get("conversation_id") or "" if conv_id in self._group_avatar_cache: return QIcon(self._make_circular_avatar(self._group_avatar_cache[conv_id])) gname = conv.get("name") or "G" # Request group avatar download if has avatar_file if conv.get("avatar_file") and conv_id and conv_id not in self._group_avatar_requested: self._group_avatar_requested.add(conv_id) self.bridge.get_group_avatar(conv_id) return QIcon(self._make_default_avatar(gname)) def _build_ui(self): main_layout = QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) splitter = QSplitter(Qt.Orientation.Horizontal) # Left panel - conversations left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(8, 8, 4, 8) header_row = QHBoxLayout() conv_label = QLabel("Conversations") conv_label.setStyleSheet("font-weight: bold; font-size: 12pt; color: #89b4fa;") header_row.addWidget(conv_label) header_row.addStretch() new_chat_btn = QPushButton("") new_chat_btn.setFixedSize(32, 32) new_chat_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder)) new_chat_btn.setToolTip("New Chat") new_chat_btn.clicked.connect(self._on_new_chat) header_row.addWidget(new_chat_btn) group_btn = QPushButton("") group_btn.setFixedSize(32, 32) group_btn.setObjectName("secondaryBtn") group_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)) group_btn.setToolTip("New Group") group_btn.clicked.connect(self._on_new_group) header_row.addWidget(group_btn) auth_btn = QPushButton("") auth_btn.setFixedSize(32, 32) auth_btn.setObjectName("secondaryBtn") auth_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) auth_btn.setToolTip("Authorize Device") auth_btn.clicked.connect(self._on_authorize_device) header_row.addWidget(auth_btn) rotate_btn = QPushButton("") rotate_btn.setFixedSize(32, 32) rotate_btn.setObjectName("secondaryBtn") rotate_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) rotate_btn.setToolTip("Rotate Keys") rotate_btn.clicked.connect(self._on_rotate_keys) header_row.addWidget(rotate_btn) profile_btn = QPushButton("") profile_btn.setFixedSize(32, 32) profile_btn.setObjectName("secondaryBtn") profile_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogInfoView)) profile_btn.setToolTip("My Profile") profile_btn.clicked.connect(self._on_my_profile) header_row.addWidget(profile_btn) logout_btn = QPushButton("") logout_btn.setFixedSize(32, 32) logout_btn.setObjectName("secondaryBtn") logout_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton)) logout_btn.setToolTip("Logout") logout_btn.clicked.connect(self._on_logout) header_row.addWidget(logout_btn) left_layout.addLayout(header_row) # Invitation section (hidden when empty) self.inv_label = QLabel("Pending Invitations") self.inv_label.setStyleSheet("font-weight: bold; font-size: 9pt; color: #f9e2af; margin-top: 4px;") self.inv_label.setVisible(False) left_layout.addWidget(self.inv_label) self.inv_list = QListWidget() self.inv_list.setMaximumHeight(120) self.inv_list.setVisible(False) self.inv_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.inv_list.customContextMenuRequested.connect(self._on_inv_context_menu) self.inv_list.setStyleSheet( "QListWidget { background-color: #1e1e2e; border: 1px solid #f9e2af; border-radius: 6px; padding: 2px; }" "QListWidget::item { padding: 6px; color: #cdd6f4; }" "QListWidget::item:hover { background-color: #252536; color: #cdd6f4; }" ) left_layout.addWidget(self.inv_list) self.conv_list = QListWidget() from PyQt6.QtCore import QSize self.conv_list.setIconSize(QSize(32, 32)) self.conv_list.currentRowChanged.connect(self._on_conv_selected) self.conv_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.conv_list.customContextMenuRequested.connect(self._on_conv_list_context_menu) left_layout.addWidget(self.conv_list) # Right panel - messages right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(4, 8, 8, 8) chat_header_row = QHBoxLayout() self.chat_header_avatar = QLabel() self.chat_header_avatar.setFixedSize(28, 28) self.chat_header_avatar.setVisible(False) chat_header_row.addWidget(self.chat_header_avatar) self.chat_header = QLabel("Select a conversation") self.chat_header.setStyleSheet("font-weight: bold; font-size: 12pt; color: #89b4fa;") chat_header_row.addWidget(self.chat_header) self.connection_dot = QLabel("\u25cf") self.connection_dot.setFixedSize(16, 16) self.connection_dot.setAlignment(Qt.AlignmentFlag.AlignCenter) self.connection_dot.setStyleSheet("color: #a6e3a1; font-size: 11pt;") self.connection_dot.setToolTip("Connected") chat_header_row.addWidget(self.connection_dot) chat_header_row.addStretch() self.group_info_btn = QPushButton("") self.group_info_btn.setFixedSize(32, 32) self.group_info_btn.setObjectName("secondaryBtn") self.group_info_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)) self.group_info_btn.setToolTip("Group Info") self.group_info_btn.clicked.connect(self._on_group_info) self.group_info_btn.setVisible(False) chat_header_row.addWidget(self.group_info_btn) self.user_info_btn = QPushButton("") self.user_info_btn.setFixedSize(32, 32) self.user_info_btn.setObjectName("secondaryBtn") self.user_info_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogInfoView)) self.user_info_btn.setToolTip("User Info") self.user_info_btn.clicked.connect(self._on_dm_user_info) self.user_info_btn.setVisible(False) chat_header_row.addWidget(self.user_info_btn) self.delete_conv_btn = QPushButton("") self.delete_conv_btn.setFixedSize(32, 32) self.delete_conv_btn.setStyleSheet( "QPushButton { background-color: #45475a; color: #f38ba8; border: none; border-radius: 6px; }" "QPushButton:hover { background-color: #f38ba8; color: #1e1e2e; }" ) self.delete_conv_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) self.delete_conv_btn.setToolTip("Delete conversation") self.delete_conv_btn.clicked.connect(self._on_delete_conv_btn) self.delete_conv_btn.setVisible(False) chat_header_row.addWidget(self.delete_conv_btn) self.add_member_btn = QPushButton("") self.add_member_btn.setFixedSize(32, 32) self.add_member_btn.setObjectName("secondaryBtn") self.add_member_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogNewFolder)) self.add_member_btn.setToolTip("Add Member") self.add_member_btn.clicked.connect(self._on_add_member) self.add_member_btn.setVisible(False) chat_header_row.addWidget(self.add_member_btn) self.search_btn = QPushButton("") self.search_btn.setFixedSize(32, 32) self.search_btn.setObjectName("secondaryBtn") self.search_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView)) self.search_btn.setToolTip("Search messages (Ctrl+F)") self.search_btn.clicked.connect(self._toggle_search) self.search_btn.setVisible(False) chat_header_row.addWidget(self.search_btn) right_layout.addLayout(chat_header_row) # Search bar (hidden by default) self.search_widget = QWidget() search_row = QHBoxLayout(self.search_widget) search_row.setContentsMargins(0, 2, 0, 2) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Search messages...") self.search_input.setStyleSheet( "QLineEdit { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; " "border-radius: 4px; padding: 4px 8px; font-size: 10pt; }" ) self.search_input.textChanged.connect(self._on_search_text_changed) self.search_input.returnPressed.connect(self._on_search_next) # Escape in search input closes search QShortcut(QKeySequence("Escape"), self.search_input).activated.connect(self._close_search) search_row.addWidget(self.search_input, stretch=1) self.search_prev_btn = QPushButton("\u25b2") self.search_prev_btn.setFixedSize(28, 28) self.search_prev_btn.setObjectName("secondaryBtn") self.search_prev_btn.setToolTip("Previous match") self.search_prev_btn.clicked.connect(self._on_search_prev) search_row.addWidget(self.search_prev_btn) self.search_next_btn = QPushButton("\u25bc") self.search_next_btn.setFixedSize(28, 28) self.search_next_btn.setObjectName("secondaryBtn") self.search_next_btn.setToolTip("Next match") self.search_next_btn.clicked.connect(self._on_search_next) search_row.addWidget(self.search_next_btn) self.search_count_label = QLabel("0/0") self.search_count_label.setStyleSheet("color: #6c7086; font-size: 9pt; min-width: 40px;") self.search_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) search_row.addWidget(self.search_count_label) self.search_close_btn = QPushButton("\u2715") self.search_close_btn.setFixedSize(28, 28) self.search_close_btn.setObjectName("secondaryBtn") self.search_close_btn.setToolTip("Close search") self.search_close_btn.clicked.connect(self._close_search) search_row.addWidget(self.search_close_btn) self.search_widget.setVisible(False) right_layout.addWidget(self.search_widget) self.load_more_btn = QPushButton("Load older messages") self.load_more_btn.setObjectName("secondaryBtn") self.load_more_btn.clicked.connect(self._on_load_more) self.load_more_btn.setVisible(False) right_layout.addWidget(self.load_more_btn) self.message_area = QTextBrowser() self.message_area.setReadOnly(True) self.message_area.setOpenExternalLinks(False) self.message_area.setOpenLinks(False) self.message_area.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.message_area.customContextMenuRequested.connect(self._on_message_context_menu) self.message_area.anchorClicked.connect(self._on_anchor_clicked) right_layout.addWidget(self.message_area, stretch=1) # Smart scroll: track if user is near bottom self._is_near_bottom = True self.message_area.verticalScrollBar().valueChanged.connect(self._on_scroll_changed) # "New messages" floating button (hidden by default) self.jump_btn = QPushButton("New messages \u2193") self.jump_btn.setParent(self.message_area) self.jump_btn.setVisible(False) self.jump_btn.setFixedHeight(28) self.jump_btn.setStyleSheet( "QPushButton { background-color: #89b4fa; color: #1e1e2e; border-radius: 14px; " "padding: 0 16px; font-size: 8pt; font-weight: bold; }" "QPushButton:hover { background-color: #74c7ec; }" ) self.jump_btn.clicked.connect(self._scroll_to_bottom) self.reply_label = QLabel("") self.reply_label.setStyleSheet("color: #89b4fa; font-style: italic; padding: 2px 4px;") self.reply_label.setVisible(False) right_layout.addWidget(self.reply_label) # Input row input_row = QHBoxLayout() self.msg_input = MessageInput() self.msg_input.send_requested.connect(self._on_send) self.msg_input.textChanged.connect(self._on_input_changed) input_row.addWidget(self.msg_input) attach_btn = QPushButton("") attach_btn.setFixedSize(52, 72) attach_btn.setObjectName("secondaryBtn") attach_btn.setIconSize(QSize(24, 24)) attach_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon)) attach_menu = QMenu(attach_btn) attach_menu.addAction("Image", self._on_attach_image) attach_menu.addAction("File", self._on_attach_file) attach_btn.setMenu(attach_menu) input_row.addWidget(attach_btn) send_btn = QPushButton("Send") send_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight)) send_btn.clicked.connect(self._on_send) send_btn.setFixedHeight(72) input_row.addWidget(send_btn) right_layout.addLayout(input_row) self.char_counter = QLabel(f"0 / {MAX_INPUT_CHARS}") self.char_counter.setStyleSheet("color: #6c7086; font-size: 8pt; padding: 0 4px;") self.char_counter.setAlignment(Qt.AlignmentFlag.AlignRight) right_layout.addWidget(self.char_counter) self.reencrypt_label = QLabel("") self.reencrypt_label.setStyleSheet( "background-color: #313244; border-radius: 6px; " "padding: 8px 12px; color: #a6e3a1; font-weight: bold;" ) self.reencrypt_label.setVisible(False) right_layout.addWidget(self.reencrypt_label) splitter.addWidget(left) splitter.addWidget(right) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 3) # Wrap splitter + status bar in vertical layout for full-width status bar wrapper = QVBoxLayout() wrapper.setContentsMargins(0, 0, 0, 0) wrapper.setSpacing(0) wrapper.addWidget(splitter) # Status bar (permanent, fixed height, full width — no layout jumping) self.status_bar = QLabel("") self.status_bar.setFixedHeight(24) self.status_bar.setStyleSheet( "background-color: #181825; border-radius: 0px; " "padding: 0 8px; color: #a6e3a1; font-size: 8pt;" ) self.status_bar.setCursor(Qt.CursorShape.PointingHandCursor) self.status_bar.mousePressEvent = self._on_status_bar_click self._status_bar_conv_id = None wrapper.addWidget(self.status_bar) main_layout.addLayout(wrapper) def _connect_signals(self): self.bridge.conversations_loaded.connect(self._on_conversations_loaded) self.bridge.messages_loaded.connect(self._on_messages_loaded) self.bridge.older_messages_loaded.connect(self._on_older_messages_loaded) self.bridge.message_sent.connect(self._on_message_sent) self.bridge.new_notification.connect(self._on_notification) self.bridge.add_member_result.connect(self._on_add_member_result) self.bridge.authorize_result.connect(self._on_authorize_result) self.bridge.rotate_result.connect(self._on_rotate_result) self.bridge.reencrypt_status.connect(self._on_reencrypt_status) self.bridge.messages_read_notification.connect(self._on_messages_read) self.bridge.remove_member_result.connect(self._on_remove_member_result) self.bridge.message_deleted_notification.connect(self._on_message_deleted) self.bridge.delete_message_result.connect(self._on_delete_message_result) self.bridge.image_sent.connect(self._on_image_sent) self.bridge.image_downloaded.connect(self._on_image_downloaded) self.bridge.file_sent.connect(self._on_file_sent) self.bridge.file_downloaded.connect(self._on_file_downloaded) self.bridge.conversation_updated.connect(self._on_conversation_updated) self.bridge.connection_state_changed.connect(self._on_connection_state_changed) self.bridge.group_left.connect(self._on_group_left) self.bridge.group_renamed.connect(self._on_group_renamed) self.bridge.conversation_deleted.connect(self._on_conversation_deleted) self.bridge.avatar_loaded.connect(self._on_avatar_for_conv_list) self.bridge.invitations_loaded.connect(self._on_invitations_loaded) self.bridge.invitation_result.connect(self._on_invitation_result) self.bridge.invitation_received.connect(self._on_invitation_received) self.bridge.online_status_changed.connect(self._on_online_status_changed) self.bridge.online_users_loaded.connect(self._on_online_users_loaded) self.bridge.group_avatar_loaded.connect(self._on_group_avatar_for_conv_list) self.bridge.group_avatar_updated.connect(self._on_group_avatar_updated) self.bridge.session_reset_notification.connect(self._on_session_reset) # ------------------------------------------------------------------ # Favorites # ------------------------------------------------------------------ def _favorites_path(self): from chat_core import get_key_dir return get_key_dir(self.bridge.client.email) / "favorites.json" def _load_favorites(self) -> set[str]: try: p = self._favorites_path() if p.exists(): return set(json.loads(p.read_text())) except Exception: pass return set() def _save_favorites(self): try: self._favorites_path().write_text(json.dumps(list(self._favorites))) except Exception: pass def _on_conv_list_context_menu(self, pos): item = self.conv_list.itemAt(pos) if not item: return conv_id = item.data(Qt.ItemDataRole.UserRole) if not conv_id: return from PyQt6.QtWidgets import QMenu menu = QMenu(self) is_fav = conv_id in self._favorites action = menu.addAction("Odebrat z oblibených" if is_fav else "Přidat do oblíbených") result = menu.exec(self.conv_list.mapToGlobal(pos)) if result == action: if is_fav: self._favorites.discard(conv_id) else: self._favorites.add(conv_id) self._save_favorites() self._rebuild_conv_list() # ------------------------------------------------------------------ # Conversation list helpers # ------------------------------------------------------------------ def _get_conv_display_name(self, conv: dict) -> str: """Get display name for a conversation (used for sorting and labels).""" others = [m.get("username") or m.get("email") or "?" for m in conv["members"] if m.get("email") != self.bridge.client.email] return conv.get("name") or (", ".join(others) if others else self.bridge.client.username) def _get_conv_other_user_id(self, conv: dict) -> str: """Get the other user's ID in a DM conversation (empty string for groups).""" is_dm = len(conv["members"]) == 2 and not conv.get("name") if not is_dm: return "" for m in conv["members"]: if m.get("email") != self.bridge.client.email: return m.get("user_id") or m.get("id") or "" return "" def _get_conv_sort_key(self, conv: dict) -> tuple: """Sort key: favorites first, then online DMs, then rest — alphabetically within each.""" conv_id = conv.get("conversation_id", "") is_fav = 0 if conv_id in self._favorites else 1 other_uid = self._get_conv_other_user_id(conv) is_online = 0 if other_uid and other_uid in self._online_users else 1 name = self._get_conv_display_name(conv).lower() return (is_fav, is_online, name) def _on_conversations_loaded(self, convs): self.conversations = convs # Populate unread counts from server (covers messages received while offline) for c in convs: cid = c["conversation_id"] server_unread = c.get("unread_count", 0) # Use the higher of server vs local (local may have newer real-time notifications) if server_unread > self._unread_counts.get(cid, 0): self._unread_counts[cid] = server_unread self._rebuild_conv_list() def _rebuild_conv_list(self): """Sort and rebuild the conversation list widget.""" if not self.conversations: return # Sort: unread first, then online DMs, then rest — alphabetically within each group self.conversations.sort(key=self._get_conv_sort_key) prev_id = self.current_conv_id self.conv_list.blockSignals(True) self.conv_list.clear() select_row = -1 for i, c in enumerate(self.conversations): conv_id = c["conversation_id"] base_label = self._get_conv_display_name(c) star = "\u2605 " if conv_id in self._favorites else "" count = self._unread_counts.get(conv_id, 0) label = f"{star}({count}) {base_label}" if count > 0 else f"{star}{base_label}" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, conv_id) item.setIcon(self._get_conv_avatar(c)) if count > 0: item.setData(Qt.ItemDataRole.FontRole, self._bold_font()) item.setForeground(Qt.GlobalColor.white) self.conv_list.addItem(item) if conv_id == prev_id: select_row = i self.conv_list.blockSignals(False) if select_row >= 0: self.conv_list.setCurrentRow(select_row) def _on_conversation_updated(self): """Refresh conversation list when a conversation is created/member added/removed.""" self.bridge.load_conversations() def _on_periodic_refresh(self): """Periodic refresh: re-download avatars for known users and reload invitations.""" # Re-request avatars for all cached users (server returns latest) for uid in list(self._avatar_requested): self.bridge.get_avatar(uid) # Re-request group avatars for conv_id in list(self._group_avatar_requested): self.bridge.get_group_avatar(conv_id) self.bridge.list_invitations() def _on_online_users_loaded(self, user_ids): self._online_users = set(user_ids) self._rebuild_conv_list() def _on_online_status_changed(self, user_id, is_online): if is_online: self._online_users.add(user_id) else: self._online_users.discard(user_id) self._rebuild_conv_list() def _on_avatar_for_conv_list(self, user_id, data): """Cache downloaded avatar and refresh conversation list icons + chat header.""" qimg = _safe_load_image(data) if qimg is not None: self._avatar_cache[user_id] = QPixmap.fromImage(qimg) self._update_conv_list_styles() # Refresh chat header avatar if current conv uses this user's avatar self._refresh_chat_header_avatar() def _on_group_avatar_for_conv_list(self, conv_id, data): """Cache downloaded group avatar and refresh conversation list icons + chat header.""" qimg = _safe_load_image(data) if qimg is not None: self._group_avatar_cache[conv_id] = QPixmap.fromImage(qimg) self._update_conv_list_styles() # Refresh chat header avatar if current conv is this group self._refresh_chat_header_avatar() def _on_group_avatar_updated(self, ok, msg): if not ok: QMessageBox.warning(self, "Group Avatar", msg) def _on_invitations_loaded(self, invitations): self._pending_invitations = invitations self.inv_list.clear() if not invitations: self.inv_label.setVisible(False) self.inv_list.setVisible(False) return self.inv_label.setVisible(True) self.inv_list.setVisible(True) for inv in invitations: conv_name = inv.get("conversation_name") or "Unnamed group" inviter = inv.get("invited_by_username", "someone") label = f"{conv_name} (from {inviter})" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, inv["conversation_id"]) self.inv_list.addItem(item) def _on_invitation_result(self, ok, msg): if not ok: QMessageBox.warning(self, "Invitation", msg) def _on_invitation_received(self, data): """New invitation received via push notification.""" self.bridge.list_invitations() conv_name = data.get("conversation_name") or "a group" inviter = data.get("invited_by_username", "Someone") self.status_bar.setText(f"{inviter} invited you to {conv_name}") self.status_bar.setStyleSheet( "background-color: #181825; border-radius: 0px; " "padding: 0 8px; color: #f9e2af; font-size: 8pt; font-weight: bold;" ) self._status_bar_conv_id = None QTimer.singleShot(5000, self._clear_status_bar) def _on_inv_context_menu(self, pos): item = self.inv_list.itemAt(pos) if not item: return conv_id = item.data(Qt.ItemDataRole.UserRole) if not conv_id: return menu = QMenu(self) accept_action = menu.addAction("Accept") decline_action = menu.addAction("Decline") chosen = menu.exec(self.inv_list.mapToGlobal(pos)) if chosen == accept_action: self.bridge.accept_invitation(conv_id) elif chosen == decline_action: self.bridge.decline_invitation(conv_id) def _on_connection_state_changed(self, state): if state == "connected": self.connection_dot.setStyleSheet("color: #a6e3a1; font-size: 11pt;") self.connection_dot.setToolTip("Connected") self.status_bar.setText("Connected") self.status_bar.setStyleSheet( "background-color: #181825; border-radius: 0px; " "padding: 0 8px; color: #a6e3a1; font-size: 8pt;" ) QTimer.singleShot(3000, self._clear_status_bar) elif state == "disconnected": self.connection_dot.setStyleSheet("color: #f38ba8; font-size: 11pt;") self.connection_dot.setToolTip("Disconnected") self.status_bar.setText("Disconnected from server") self.status_bar.setStyleSheet( "background-color: #181825; border-radius: 0px; " "padding: 0 8px; color: #f38ba8; font-size: 8pt; font-weight: bold;" ) self._status_bar_conv_id = None elif state == "reconnecting": self.connection_dot.setStyleSheet("color: #fab387; font-size: 11pt;") self.connection_dot.setToolTip("Reconnecting...") self.status_bar.setText("Reconnecting...") self.status_bar.setStyleSheet( "background-color: #181825; border-radius: 0px; " "padding: 0 8px; color: #fab387; font-size: 8pt;" ) self._status_bar_conv_id = None elif state == "revoked": self.connection_dot.setStyleSheet("color: #f38ba8; font-size: 11pt;") self.connection_dot.setToolTip("Access revoked") # Clear conversation list self.conv_list.clear() self.conversations = [] self._unread_counts.clear() # Clear open conversation self.current_conv_id = None self.chat_header.setText("Select a conversation") self.chat_header_avatar.setVisible(False) self.message_area.clear() self.msg_input.setEnabled(False) self.group_info_btn.setVisible(False) self.user_info_btn.setVisible(False) self.add_member_btn.setVisible(False) self.delete_conv_btn.setVisible(False) QMessageBox.warning(self, "Access Revoked", "Your keys were rotated on another device. " "This session is no longer valid.") def _on_scroll_changed(self, value): sb = self.message_area.verticalScrollBar() self._is_near_bottom = (sb.maximum() - value) < 60 if self._is_near_bottom: self.jump_btn.setVisible(False) def _scroll_to_bottom(self): sb = self.message_area.verticalScrollBar() sb.setValue(sb.maximum()) self.jump_btn.setVisible(False) def _position_jump_btn(self): w = self.message_area.width() btn_w = self.jump_btn.sizeHint().width() self.jump_btn.move((w - btn_w) // 2, self.message_area.height() - 40) def _clear_status_bar(self): self.status_bar.setText("") self.status_bar.setStyleSheet( "background-color: #181825; border-radius: 0px; " "padding: 0 8px; color: #a6e3a1; font-size: 8pt;" ) self._status_bar_conv_id = None def _on_status_bar_click(self, event): conv_id = self._status_bar_conv_id if conv_id: for i, c in enumerate(self.conversations): if c["conversation_id"] == conv_id: self.conv_list.setCurrentRow(i) self._clear_status_bar() break def _update_chat_header_avatar(self, conv): """Set the circular avatar next to the conversation name in the chat header.""" is_dm = len(conv["members"]) == 2 and not conv.get("name") size = 28 if is_dm: other = None for m in conv["members"]: if m.get("email") != self.bridge.client.email: other = m break if other: uid = other.get("user_id") or other.get("id") or "" uname = other.get("username") or other.get("email") or "?" if uid in self._avatar_cache: avatar = self._make_circular_avatar(self._avatar_cache[uid], size) else: avatar = self._make_default_avatar(uname, size) self.chat_header_avatar.setPixmap(avatar) self.chat_header_avatar.setVisible(True) else: self.chat_header_avatar.setVisible(False) else: conv_id = conv.get("conversation_id") or "" gname = conv.get("name") or "G" if conv_id in self._group_avatar_cache: avatar = self._make_circular_avatar(self._group_avatar_cache[conv_id], size) else: avatar = self._make_default_avatar(gname, size) self.chat_header_avatar.setPixmap(avatar) self.chat_header_avatar.setVisible(True) def _refresh_chat_header_avatar(self): """Re-render chat header avatar for the currently selected conversation.""" if not self.current_conv_id: return for c in self.conversations: if c["conversation_id"] == self.current_conv_id: self._update_chat_header_avatar(c) return def _update_conv_list_styles(self): for i in range(self.conv_list.count()): item = self.conv_list.item(i) conv_id = item.data(Qt.ItemDataRole.UserRole) count = self._unread_counts.get(conv_id, 0) others = [] conv_name = None conv = None for c in self.conversations: if c["conversation_id"] == conv_id: others = [m.get("username") or m.get("email") or "?" for m in c["members"] if m.get("email") != self.bridge.client.email] conv_name = c.get("name") conv = c break base_label = conv_name or (", ".join(others) if others else self.bridge.client.username) star = "\u2605 " if conv_id in self._favorites else "" item.setText(f"{star}({count}) {base_label}" if count > 0 else f"{star}{base_label}") if conv: item.setIcon(self._get_conv_avatar(conv)) if count > 0: item.setData(Qt.ItemDataRole.FontRole, self._bold_font()) else: item.setData(Qt.ItemDataRole.FontRole, None) def _on_conv_selected(self, row): if row < 0 or row >= len(self.conversations): return conv = self.conversations[row] self.current_conv_id = conv["conversation_id"] others = [m.get("username") or m.get("email") or "?" for m in conv["members"] if m.get("email") != self.bridge.client.email] header = conv.get("name") or (", ".join(others) if others else self.bridge.client.username) self.chat_header.setText(header) # Set avatar in chat header self._update_chat_header_avatar(conv) is_group = len(conv["members"]) > 2 or conv.get("name") self._is_dm = not is_group self.add_member_btn.setVisible(bool(is_group)) self.group_info_btn.setVisible(bool(is_group)) self.user_info_btn.setVisible(self._is_dm) # DMs: always show delete. Groups: only show for creator. if self._is_dm: self.delete_conv_btn.setVisible(True) else: my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" self.delete_conv_btn.setVisible(conv.get("created_by") == my_user_id) self.reply_to_id = None self.reply_label.setVisible(False) self._has_more_messages = True self.load_more_btn.setVisible(False) self._unread_counts.pop(self.current_conv_id, None) self._update_conv_list_styles() self.search_btn.setVisible(True) self._close_search() self.bridge.load_messages(self.current_conv_id) def _on_messages_loaded(self, conv_id, messages): if conv_id != self.current_conv_id: return self.current_messages = messages # Show "Load older" if we got a full batch (there may be more) self._has_more_messages = len(messages) >= 50 self.load_more_btn.setVisible(self._has_more_messages) self._render_messages() def _render_messages(self, scroll_to_bottom=True): html_parts = [] for i, m in enumerate(self.current_messages): html_parts.append(self._render_single_message_html(m, i)) self.message_area.setHtml("".join(html_parts)) # Register thumbnail images as document resources self._register_thumbnails() # Re-set HTML so images resolve self.message_area.setHtml("".join(html_parts)) if scroll_to_bottom: sb = self.message_area.verticalScrollBar() sb.setValue(sb.maximum()) def _register_thumbnails(self): """Add thumbnail images as resources to the QTextDocument.""" from PyQt6.QtCore import QUrl doc = self.message_area.document() for m in self.current_messages: image_info = m.get("image") if not image_info: continue thumbnail_b64 = image_info.get("thumbnail", "") file_id = image_info.get("file_id", "") if not thumbnail_b64 or not file_id: continue from protocol import decode_binary try: thumb_bytes = decode_binary(thumbnail_b64) qimg = _safe_load_image(thumb_bytes) if qimg is not None: url = QUrl(f"thumb://{file_id}") doc.addResource( doc.ResourceType.ImageResource.value, url, qimg, ) except Exception: pass def _register_single_thumbnail(self, m): """Register a single message's thumbnail as a document resource.""" image_info = m.get("image") if not image_info: return thumbnail_b64 = image_info.get("thumbnail", "") file_id = image_info.get("file_id", "") if not thumbnail_b64 or not file_id: return from PyQt6.QtCore import QUrl from protocol import decode_binary try: thumb_bytes = decode_binary(thumbnail_b64) qimg = _safe_load_image(thumb_bytes) if qimg is not None: doc = self.message_area.document() url = QUrl(f"thumb://{file_id}") doc.addResource( doc.ResourceType.ImageResource.value, url, qimg, ) except Exception: pass def _on_older_messages_loaded(self, conv_id, messages): if conv_id != self.current_conv_id: return if not messages: self._has_more_messages = False self.load_more_btn.setVisible(False) return self._has_more_messages = len(messages) >= 50 self.load_more_btn.setVisible(self._has_more_messages) # Prepend older messages and re-render self.current_messages = messages + self.current_messages self._render_messages(scroll_to_bottom=False) def _on_load_more(self): if not self.current_conv_id or not self._has_more_messages: return offset = len(self.current_messages) self.bridge.load_older_messages(self.current_conv_id, offset) def _render_single_message_html(self, m, index): """Render HTML for a single message.""" is_dm = self._is_dm # Handle deleted messages if m.get("deleted"): timestamp = m.get("created_at", "") time_str = "" if timestamp: time_short = timestamp[11:16] if len(timestamp) >= 16 else timestamp time_str = f' \u00b7 {time_short}' prefix = "" is_me = m.get("sender") == self.bridge.client.username or m.get("sender_id") == ( self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "") if is_me: del_align = "right" del_border = "border-right:3px solid #6c7086; border-left:none;" else: del_align = "left" del_border = "border-left:3px solid #6c7086;" return ( f'
' ) sender = m.get("sender", "???") text = m.get("text", "") timestamp = m.get("created_at", "") text = _linkify_urls(text) text = text.replace("\n", "