"""PyQt6 GUI client for encrypted chat.""" import asyncio import json import logging import os from collections import OrderedDict logger = logging.getLogger(__name__) import re import sys from functools import partial from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer, QUrl, QSize, QRect, QPoint, QPointF from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, QLabel, QListWidget, QListWidgetItem, QTextEdit, QSplitter, QMessageBox, QInputDialog, QMenu, QStackedWidget, QDialog, QFileDialog, QScrollArea, QFrame, QSystemTrayIcon, QSizePolicy, QStyledItemDelegate, ) from PyQt6.QtGui import QFont, QFontMetricsF, QAction, QPixmap, QImage, QDesktopServices, QIcon, QPainter, QColor, QBrush, QPen, QShortcut, QKeySequence from PyQt6.QtWidgets import QGraphicsDropShadowEffect from PyQt6.QtWidgets import QStyle from chat_core import ChatClient, IdentityKeyChanged from theme import ThemeManager, c, qss, tm # 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 _AVATAR_CACHE_MAX = 512 # max cached avatar pixmaps per cache class _LRUPixmapCache: """Simple LRU cache for QPixmap objects with a fixed max size.""" def __init__(self, maxsize: int = _AVATAR_CACHE_MAX): self._data: OrderedDict[str, QPixmap] = OrderedDict() self._maxsize = maxsize def get(self, key: str) -> QPixmap | None: if key in self._data: self._data.move_to_end(key) return self._data[key] return None def put(self, key: str, value: QPixmap) -> None: if key in self._data: self._data.move_to_end(key) self._data[key] = value else: self._data[key] = value if len(self._data) > self._maxsize: self._data.popitem(last=False) def __contains__(self, key: str) -> bool: return key in self._data def __getitem__(self, key: str) -> QPixmap: self._data.move_to_end(key) return self._data[key] def __setitem__(self, key: str, value: QPixmap) -> None: self.put(key, value) def clear(self) -> None: self._data.clear() # 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, https_color: str | None = None, http_color: str | None = None) -> 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. """ _https = https_color or c().link_https _http = http_color or c().link_http 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") MAX_INPUT_CHARS = int(os.getenv("MAX_INPUT_CHARS", "2000")) # Custom item data roles for conversation list delegate ROLE_CONV_ID = Qt.ItemDataRole.UserRole ROLE_DISPLAY_NAME = Qt.ItemDataRole.UserRole + 1 ROLE_PREVIEW = Qt.ItemDataRole.UserRole + 2 # last message preview text ROLE_TIMESTAMP = Qt.ItemDataRole.UserRole + 3 # last message relative time ROLE_UNREAD = Qt.ItemDataRole.UserRole + 4 # int unread count ROLE_IS_FAV = Qt.ItemDataRole.UserRole + 5 # bool ROLE_AVATAR = Qt.ItemDataRole.UserRole + 6 # QPixmap (circular, 44px) ROLE_VERIFIED = Qt.ItemDataRole.UserRole + 7 # str: "verified", "trusted", "" (DMs only) ROLE_RECEIPT = Qt.ItemDataRole.UserRole + 8 # str: "read", "delivered", "sent", "" (own msgs only) def _relative_time(ts: str) -> str: """Convert ISO timestamp to relative time string for conversation list.""" if not ts or len(ts) < 16: return "" try: from datetime import datetime, timezone # Parse "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" clean = ts.replace("T", " ")[:19] msg_time = datetime.strptime(clean, "%Y-%m-%d %H:%M:%S").replace( tzinfo=timezone.utc ) now = datetime.now(timezone.utc) diff = now - msg_time secs = int(diff.total_seconds()) if secs < 60: return "now" if secs < 3600: return f"{secs // 60}m" if secs < 86400: return f"{secs // 3600}h" days = secs // 86400 if days == 1: return "Yesterday" if days < 7: return msg_time.strftime("%a") # Mon, Tue, ... return msg_time.strftime("%d/%m") except Exception: return ts[11:16] if len(ts) >= 16 else "" def _format_msg_time(ts: str) -> str: """Format timestamp for message bubbles: HH:MM today, 'Yesterday HH:MM', 'Mon HH:MM' this week, 'DD.MM. HH:MM' older.""" if not ts or len(ts) < 16: return "" try: from datetime import datetime, timezone clean = ts.replace("T", " ")[:19] msg_time = datetime.strptime(clean, "%Y-%m-%d %H:%M:%S").replace( tzinfo=timezone.utc ) now = datetime.now(timezone.utc) hhmm = msg_time.strftime("%H:%M") if msg_time.date() == now.date(): return hhmm diff_days = (now.date() - msg_time.date()).days if diff_days == 1: return f"Yesterday {hhmm}" if diff_days < 7: return f"{msg_time.strftime('%a')} {hhmm}" if msg_time.year == now.year: return f"{msg_time.day}.{msg_time.month}. {hhmm}" return f"{msg_time.day}.{msg_time.month}.{msg_time.year} {hhmm}" except Exception: return ts[11:16] if len(ts) >= 16 else "" class ConversationDelegate(QStyledItemDelegate): """Custom delegate that paints Signal/Telegram-style conversation rows.""" ITEM_HEIGHT = 68 AVATAR_SIZE = 44 BADGE_SIZE = 20 HPAD = 10 VPAD = 10 def sizeHint(self, option, index): return QSize(option.rect.width(), self.ITEM_HEIGHT) def paint(self, painter, option, index): painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing) t = c() rect = option.rect is_selected = bool(option.state & QStyle.StateFlag.State_Selected) is_hover = bool(option.state & QStyle.StateFlag.State_MouseOver) # Background if is_selected: painter.fillRect(rect, QColor(t.bg_selected)) elif is_hover: painter.fillRect(rect, QColor(t.bg_hover)) # Data from item roles name = index.data(ROLE_DISPLAY_NAME) or "" preview = index.data(ROLE_PREVIEW) or "" timestamp = index.data(ROLE_TIMESTAMP) or "" unread = index.data(ROLE_UNREAD) or 0 is_fav = index.data(ROLE_IS_FAV) or False avatar_pix = index.data(ROLE_AVATAR) verified = index.data(ROLE_VERIFIED) or "" x = rect.x() + self.HPAD y = rect.y() + self.VPAD avail_w = rect.width() - 2 * self.HPAD # -- Avatar (left) -- av_y = rect.y() + (rect.height() - self.AVATAR_SIZE) // 2 if avatar_pix and not avatar_pix.isNull(): painter.drawPixmap(x, av_y, self.AVATAR_SIZE, self.AVATAR_SIZE, avatar_pix) else: painter.setBrush(QBrush(QColor(t.bg_secondary))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(x, av_y, self.AVATAR_SIZE, self.AVATAR_SIZE) painter.setPen(QColor(t.text_muted)) f = QFont() f.setPointSize(14) f.setBold(True) painter.setFont(f) painter.drawText( QRect(x, av_y, self.AVATAR_SIZE, self.AVATAR_SIZE), Qt.AlignmentFlag.AlignCenter, name[0].upper() if name else "?", ) text_x = x + self.AVATAR_SIZE + 10 text_w = avail_w - self.AVATAR_SIZE - 10 # -- Timestamp (top-right) -- ts_w = 50 painter.setPen(QColor(t.text_muted)) ts_font = QFont() ts_font.setPointSize(8) painter.setFont(ts_font) ts_rect = QRect( rect.x() + rect.width() - self.HPAD - ts_w, y, ts_w, 18, ) painter.drawText(ts_rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, timestamp) name_w = text_w - ts_w - 8 # -- Name (line 1) -- name_font = QFont() name_font.setPointSize(10) if unread > 0: name_font.setBold(True) painter.setFont(name_font) painter.setPen(QColor(t.text_primary)) display_name = name if is_fav: display_name = f"\u2605 {name}" # Elide name to fit fm = painter.fontMetrics() elided_name = fm.elidedText(display_name, Qt.TextElideMode.ElideRight, name_w) painter.drawText(text_x, y, name_w, 22, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, elided_name) # -- Verification badge (after name) -- if verified == "verified": name_text_w = fm.horizontalAdvance(elided_name) badge_x = text_x + name_text_w + 4 badge_y_center = y + 11 painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(t.success))) painter.drawEllipse(badge_x, badge_y_center - 5, 10, 10) # Checkmark inside circle painter.setPen(QPen(QColor(t.bg_primary), 1.5)) painter.drawLine(badge_x + 2, badge_y_center, badge_x + 4, badge_y_center + 2) painter.drawLine(badge_x + 4, badge_y_center + 2, badge_x + 8, badge_y_center - 3) # -- Preview (line 2) -- preview_y = y + 24 preview_font = QFont() preview_font.setPointSize(9) painter.setFont(preview_font) preview_w = text_w - (self.BADGE_SIZE + 8 if unread > 0 else 0) fm2 = painter.fontMetrics() receipt = index.data(ROLE_RECEIPT) or "" preview_x = text_x if receipt: check_color = QColor(t.success) if receipt == "read" else QColor(t.text_muted) painter.setPen(check_color) single_w = fm2.horizontalAdvance("\u2713") overlap = single_w * 0.4 cy = preview_y + 10 # vertical center of 20px row # First check painter.drawText(int(preview_x), preview_y, int(single_w), 20, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, "\u2713") if receipt in ("delivered", "read"): # Second check, overlapping x2 = preview_x + single_w - overlap painter.drawText(int(x2), preview_y, int(single_w), 20, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, "\u2713") total_w = single_w * 2 - overlap + 4 else: total_w = single_w + 4 preview_x += total_w preview_w -= total_w preview_x = int(preview_x) preview_w = int(preview_w) painter.setPen(QColor(t.text_muted)) elided_preview = fm2.elidedText(preview, Qt.TextElideMode.ElideRight, preview_w) painter.drawText(preview_x, preview_y, preview_w, 20, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, elided_preview) # -- Unread badge (bottom-right) -- if unread > 0: badge_x = rect.x() + rect.width() - self.HPAD - self.BADGE_SIZE badge_y = preview_y + 1 painter.setBrush(QBrush(QColor(t.accent))) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(badge_x, badge_y, self.BADGE_SIZE, self.BADGE_SIZE, 10, 10) painter.setPen(QColor(t.accent_text)) badge_font = QFont() badge_font.setPointSize(7) badge_font.setBold(True) painter.setFont(badge_font) badge_text = str(unread) if unread < 100 else "99+" painter.drawText( QRect(badge_x, badge_y, self.BADGE_SIZE, self.BADGE_SIZE), Qt.AlignmentFlag.AlignCenter, badge_text, ) # -- Bottom separator line -- painter.setPen(QColor(t.separator)) painter.drawLine(text_x, rect.bottom(), rect.right() - self.HPAD, rect.bottom()) painter.restore() class _ReceiptFooter(QWidget): """Tiny widget that draws timestamp + receipt checkmarks with tight spacing.""" def __init__(self, time_str: str, status: str, time_color: str, check_color: str, read_color: str, parent=None): super().__init__(parent) self._time = time_str self._status = status # "", "sent", "delivered", "read" self._time_color = QColor(time_color) self._check_color = QColor(check_color) self._read_color = QColor(read_color) self._font = QFont() self._font.setPointSize(8) fm = QFontMetricsF(self._font) tw = fm.horizontalAdvance(self._time + " ") cw = fm.horizontalAdvance("\u2713") # 2nd check overlaps 1st by 40% of its width overlap = cw * 0.4 checks_w = 0.0 if status == "sent": checks_w = cw elif status in ("delivered", "read"): checks_w = cw * 2 - overlap total_w = tw + checks_w + 2 h = fm.height() + 2 self.setFixedSize(int(total_w + 1), int(h + 1)) def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setFont(self._font) fm = QFontMetricsF(self._font) y_base = fm.ascent() + 1 # Draw time p.setPen(self._time_color) p.drawText(QPointF(0, y_base), self._time) x = fm.horizontalAdvance(self._time) + 4 if not self._status: p.end() return cw = fm.horizontalAdvance("\u2713") overlap = cw * 0.4 color = self._read_color if self._status == "read" else self._check_color # First check p.setPen(color) p.drawText(QPointF(x, y_base), "\u2713") # Second check (tight overlap) if self._status in ("delivered", "read"): p.drawText(QPointF(x + cw - overlap, y_base), "\u2713") p.end() class MessageInput(QTextEdit): """Multiline message input: Enter sends, Shift+Enter inserts newline.""" send_requested = pyqtSignal() file_dropped = pyqtSignal(str) @staticmethod def _style_normal(): t = c() return ( f"QTextEdit {{ background-color: {t.bg_secondary}; border: 1px solid {t.border}; " f"border-radius: 18px; padding: 8px 14px; color: {t.text_primary}; }}" f"QTextEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) @staticmethod def _style_drop(): t = c() return ( f"QTextEdit {{ background-color: {t.bg_secondary}; border: 2px dashed {t.accent}; " f"border-radius: 18px; padding: 8px 14px; color: {t.text_primary}; }}" ) def __init__(self, parent=None): super().__init__(parent) self.setAcceptRichText(False) self.setPlaceholderText("Type a message...") self.setMinimumHeight(52) self.setMaximumHeight(120) self.setAcceptDrops(True) self.drop_enabled = False self.setStyleSheet(self._style_normal()) self.textChanged.connect(self._auto_resize) # Tight line spacing — set on document default cursor format from PyQt6.QtGui import QTextBlockFormat fmt = QTextBlockFormat() fmt.setTopMargin(0) fmt.setBottomMargin(0) fmt.setLineHeight(0, 0) # 0 = SingleHeight self._block_fmt = fmt # Apply to default block format cursor = self.textCursor() cursor.setBlockFormat(fmt) self.setTextCursor(cursor) def _auto_resize(self): doc_height = int(self.document().size().height()) + 16 # padding new_h = max(52, min(doc_height, 120)) self.setFixedHeight(new_h) def keyPressEvent(self, event): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: # Insert plain newline instead of new paragraph self.textCursor().insertText("\n") return else: self.send_requested.emit() return super().keyPressEvent(event) def dragEnterEvent(self, event): if not self.drop_enabled: event.ignore() return if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()): event.acceptProposedAction() self.setStyleSheet(self._style_drop()) else: super().dragEnterEvent(event) def dragMoveEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() else: super().dragMoveEvent(event) def dragLeaveEvent(self, event): self.setStyleSheet(self._style_normal()) super().dragLeaveEvent(event) def dropEvent(self, event): self.setStyleSheet(self._style_normal()) if event.mimeData().hasUrls(): for url in event.mimeData().urls(): if url.isLocalFile(): self.file_dropped.emit(url.toLocalFile()) event.acceptProposedAction() else: super().dropEvent(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) message_sent_payload = pyqtSignal(str, dict) # conv_id, message dict (for local append) 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_delivered_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 reaction_result = pyqtSignal(bool, str) # ok, message reaction_notification = pyqtSignal(dict) # {message_id, conversation_id, user_id, reaction, action} pin_notification = pyqtSignal(dict) # {message_id, conversation_id, user_id, action=pin} unpin_notification = pyqtSignal(dict) # {message_id, conversation_id, user_id, action=unpin} pinned_messages_loaded = pyqtSignal(str, list) # conv_id, list of pinned msg dicts forward_result = pyqtSignal(bool, str) # ok, message key_change_warning = pyqtSignal(str, str, str, bool, bytes) # user_id, username, old_key_hex, was_verified, new_key_bytes password_changed = pyqtSignal(bool, str) # ok, message username_changed = pyqtSignal(bool, str) # ok, message 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.client._key_change_cb = self._emit_key_change_warning self._ready: asyncio.Event | None = None self._avatar_inflight: set[str] = set() self._group_avatar_inflight: set[str] = set() self._invitations_inflight = False def _emit_reencrypt_status(self, message: str): self.reencrypt_status.emit(message) def _emit_key_change_warning(self, user_id: str, username: str, old_key_hex: str, was_verified: bool, new_key_bytes: bytes = b""): self.key_change_warning.emit(user_id, username, old_key_hex, was_verified, new_key_bytes) 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 as e: logger.error("AsyncBridge loop crashed: %s", e, exc_info=True) 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_delivered": self.message_delivered_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 == "username_changed": self.conversation_updated.emit() elif notif_type == "message_reacted": self.reaction_notification.emit(data) elif notif_type == "message_pinned": self.pin_notification.emit(data) elif notif_type == "message_unpinned": self.unpin_notification.emit(data) elif notif_type == "new_message": try: payload = self.client.decrypt_notification(data) except IdentityKeyChanged as ikc: cached = self.client._user_cache.get(ikc.user_id) uname = cached.get("username", "") if cached else "" old_hex = "" known = self.client._known_identity_keys.get(ikc.user_id) if known: old_hex = known.get("identity_key", "") was_verified = ikc.status == "changed_verified" self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes) continue if payload: self.new_notification.emit(payload) # None = control message (e.g. sender key distribution), skip silently except asyncio.TimeoutError: continue except Exception as e: logger.error("Notification loop exception: %s", e, exc_info=True) 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, result = await self.client.send_message(conv_id, text, members, reply_to=reply_to) except IdentityKeyChanged as ikc: cached = self.client._user_cache.get(ikc.user_id) uname = cached.get("username", "") if cached else "" old_hex = "" known = self.client._known_identity_keys.get(ikc.user_id) if known: old_hex = known.get("identity_key", "") was_verified = ikc.status == "changed_verified" self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes) self.message_sent.emit(False, "Identity key changed — accept new key first.") return except Exception as e: logger.error("send_message exception: %s", e, exc_info=True) self.message_sent.emit(False, str(e)) return if ok and isinstance(result, dict): self.message_sent.emit(True, "Message sent.") self.message_sent_payload.emit(conv_id, result) else: self.message_sent.emit(ok, result if isinstance(result, str) else "Message sent.") 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 cv in convs: if cv["conversation_id"] == conv_id: members = cv["members"] break ok, result = await self.client.send_message(conv_id, text, members) if ok and isinstance(result, dict): self.message_sent.emit(True, "Message sent.") self.message_sent_payload.emit(conv_id, result) else: self.message_sent.emit(ok, result if isinstance(result, str) else "Message sent.") except IdentityKeyChanged as ikc: cached = self.client._user_cache.get(ikc.user_id) uname = cached.get("username", "") if cached else "" old_hex = "" known = self.client._known_identity_keys.get(ikc.user_id) if known: old_hex = known.get("identity_key", "") was_verified = ikc.status == "changed_verified" self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes) self.message_sent.emit(False, "Identity key changed — accept new key first.") 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_change_password(self, old_password, new_password): if self._ready: await self._ready.wait() ok, msg = self.client.change_password(old_password, new_password) self.password_changed.emit(ok, msg) def change_password(self, old_password, new_password): self.schedule(self._do_change_password(old_password, new_password)) async def _do_change_username(self, new_username): if self._ready: await self._ready.wait() ok, msg = await self.client.change_username(new_username) self.username_changed.emit(ok, msg) def change_username(self, new_username): self.schedule(self._do_change_username(new_username)) 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() try: ok, result = await self.client.send_image(conv_id, image_path, members, reply_to=reply_to) except IdentityKeyChanged as ikc: cached = self.client._user_cache.get(ikc.user_id) uname = cached.get("username", "") if cached else "" old_hex = "" known = self.client._known_identity_keys.get(ikc.user_id) if known: old_hex = known.get("identity_key", "") was_verified = ikc.status == "changed_verified" self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes) self.image_sent.emit(False, "Identity key changed — accept new key first.") return except Exception as e: logger.error("send_image exception: %s", e, exc_info=True) self.image_sent.emit(False, str(e)) return if ok and isinstance(result, dict): self.image_sent.emit(True, "Image sent.") self.message_sent_payload.emit(conv_id, result) else: self.image_sent.emit(ok, result if isinstance(result, str) else "Image sent.") 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() try: ok, result = await self.client.send_file(conv_id, file_path, members, reply_to=reply_to) except IdentityKeyChanged as ikc: cached = self.client._user_cache.get(ikc.user_id) uname = cached.get("username", "") if cached else "" old_hex = "" known = self.client._known_identity_keys.get(ikc.user_id) if known: old_hex = known.get("identity_key", "") was_verified = ikc.status == "changed_verified" self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes) self.file_sent.emit(False, "Identity key changed — accept new key first.") return except Exception as e: logger.error("send_file exception: %s", e, exc_info=True) self.file_sent.emit(False, str(e)) return if ok and isinstance(result, dict): self.file_sent.emit(True, "File sent.") self.message_sent_payload.emit(conv_id, result) else: self.file_sent.emit(ok, result if isinstance(result, str) else "File sent.") 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() if not user_id or user_id in self._avatar_inflight: return self._avatar_inflight.add(user_id) try: data = await self.client.get_avatar(user_id) if data: self.avatar_loaded.emit(user_id, data) finally: self._avatar_inflight.discard(user_id) 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() if self._invitations_inflight: return self._invitations_inflight = True try: invitations = await self.client.list_invitations() self.invitations_loaded.emit(invitations) finally: self._invitations_inflight = False 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() if not conv_id or conv_id in self._group_avatar_inflight: return self._group_avatar_inflight.add(conv_id) try: data = await self.client.get_group_avatar(conv_id) if data: self.group_avatar_loaded.emit(conv_id, data) finally: self._group_avatar_inflight.discard(conv_id) def get_group_avatar(self, conv_id): self.schedule(self._do_get_group_avatar(conv_id)) # --- Reactions, Pins, Forwarding --- async def _do_react_message(self, message_id, reaction, action): if self._ready: await self._ready.wait() ok, msg = await self.client.react_message(message_id, reaction, action) self.reaction_result.emit(ok, msg) def react_message(self, message_id, reaction, action="add"): self.schedule(self._do_react_message(message_id, reaction, action)) async def _do_pin_message(self, message_id, conversation_id, action): if self._ready: await self._ready.wait() ok, msg = await self.client.pin_message(message_id, conversation_id, action) if not ok: self.reaction_result.emit(False, msg) def pin_message(self, message_id, conversation_id, action="pin"): self.schedule(self._do_pin_message(message_id, conversation_id, action)) async def _do_get_pinned_messages(self, conv_id): if self._ready: await self._ready.wait() pinned = await self.client.get_pinned_messages(conv_id) self.pinned_messages_loaded.emit(conv_id, pinned) def get_pinned_messages(self, conv_id): self.schedule(self._do_get_pinned_messages(conv_id)) async def _do_forward_message(self, target_conv_id, original_msg, target_members): if self._ready: await self._ready.wait() try: ok, result = await self.client.forward_message(target_conv_id, original_msg, target_members) except IdentityKeyChanged as ikc: cached = self.client._user_cache.get(ikc.user_id) uname = cached.get("username", "") if cached else "" old_hex = "" known = self.client._known_identity_keys.get(ikc.user_id) if known: old_hex = known.get("identity_key", "") was_verified = ikc.status == "changed_verified" self.key_change_warning.emit(ikc.user_id, uname, old_hex, was_verified, ikc.new_key_bytes) self.forward_result.emit(False, "Identity key changed — accept new key first.") return except Exception as e: logger.error("forward_message exception: %s", e, exc_info=True) self.forward_result.emit(False, str(e)) return if ok and isinstance(result, dict): self.forward_result.emit(True, "Message forwarded.") self.message_sent_payload.emit(target_conv_id, result) else: self.forward_result.emit(ok, result if isinstance(result, str) else "Forwarded.") def forward_message(self, target_conv_id, original_msg, target_members): self.schedule(self._do_forward_message(target_conv_id, original_msg, target_members)) 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) def _make_frameless(dlg: QDialog, title_text: str = ""): """Configure a QDialog as frameless with custom title bar, rounded container, and drop shadow. Returns a QVBoxLayout for the dialog content area — callers just add their widgets to the returned layout. Usage:: dlg = QDialog(self) dlg.setMinimumWidth(380) content_layout = _make_frameless(dlg, "My Title") content_layout.addWidget(QLabel("Hello!")) dlg.exec() """ dlg.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog ) dlg.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) dlg._drag_pos = None t = c() # -- Outer layout (transparent, holds container with margins for shadow) -- outer = QVBoxLayout(dlg) outer.setContentsMargins(12, 12, 12, 12) outer.setSpacing(0) # -- Rounded container -- container = QWidget() container.setObjectName("_framelessContainer") container.setStyleSheet( f"#_framelessContainer {{ background-color: {t.bg_primary}; border-radius: 12px; }}" ) shadow = QGraphicsDropShadowEffect(container) shadow.setBlurRadius(24) shadow.setOffset(0, 4) shadow.setColor(QColor(0, 0, 0, 80)) container.setGraphicsEffect(shadow) container_lay = QVBoxLayout(container) container_lay.setContentsMargins(0, 0, 0, 0) container_lay.setSpacing(0) # -- Title bar -- title_bar = QWidget() title_bar.setFixedHeight(40) title_bar.setStyleSheet( f"background-color: {t.bg_secondary}; " f"border-top-left-radius: 12px; border-top-right-radius: 12px;" ) bar_layout = QHBoxLayout(title_bar) bar_layout.setContentsMargins(16, 0, 8, 0) bar_layout.setSpacing(0) title_label = QLabel(title_text) title_label.setStyleSheet( f"color: {t.text_primary}; font-size: 11pt; font-weight: bold; " f"background: transparent;" ) bar_layout.addWidget(title_label) bar_layout.addStretch() close_btn = QPushButton("\u2715") close_btn.setFixedSize(28, 28) close_btn.setCursor(Qt.CursorShape.PointingHandCursor) close_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.text_muted}; " f"border: none; border-radius: 14px; font-size: 12pt; }}" f"QPushButton:hover {{ background-color: {t.error}; color: {t.accent_text}; }}" ) close_btn.clicked.connect(dlg.reject) bar_layout.addWidget(close_btn) # Dragging via title bar def _mouse_press(event): if event.button() == Qt.MouseButton.LeftButton: dlg._drag_pos = event.globalPosition().toPoint() - dlg.frameGeometry().topLeft() event.accept() def _mouse_move(event): if dlg._drag_pos is not None and event.buttons() & Qt.MouseButton.LeftButton: dlg.move(event.globalPosition().toPoint() - dlg._drag_pos) event.accept() def _mouse_release(event): dlg._drag_pos = None title_bar.mousePressEvent = _mouse_press title_bar.mouseMoveEvent = _mouse_move title_bar.mouseReleaseEvent = _mouse_release container_lay.addWidget(title_bar) # -- Content widget -- content = QWidget() content_layout = QVBoxLayout(content) content_layout.setContentsMargins(16, 12, 16, 16) content_layout.setSpacing(8) container_lay.addWidget(content) outer.addWidget(container) # Store refs for later theming dlg._frameless_container = container dlg._frameless_title_bar = title_bar dlg._frameless_title_label = title_label return content_layout 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.setMinimumWidth(400) self._build_ui() self._connect_signals() self.bridge.get_profile(user_id) def _build_ui(self): t = c() title_text = "Edit Profile" if self.editable else "User Profile" self.layout_main = _make_frameless(self, title_text) self.layout_main.setSpacing(12) # Avatar self.avatar_label = QLabel() self.avatar_label.setFixedSize(80, 80) self.avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter) t = c() self.avatar_label.setStyleSheet( f"background-color: {t.bg_secondary}; border-radius: 40px; " f"font-size: 21pt; color: {t.accent};" ) 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(f"font-size: 14pt; font-weight: bold; color: {t.accent};") 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(f"font-weight: bold; color: {t.accent};") 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(f"color: {t.text_primary};") self.layout_main.addWidget(self.email_visible_cb) self.phone_visible_cb = QCheckBox("Phone visible to others") self.phone_visible_cb.setStyleSheet(f"color: {t.text_primary};") self.layout_main.addWidget(self.phone_visible_cb) self.location_visible_cb = QCheckBox("Location visible to others") self.location_visible_cb.setStyleSheet(f"color: {t.text_primary};") 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) # Security section (only when viewing another user, not own profile) if not self.editable: self._security_section = QVBoxLayout() self.layout_main.addLayout(self._security_section) 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(f"color: {c().text_muted};") 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) # Security section (viewing another user, not self) my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" if not self.editable and hasattr(self, "_security_section") and self.user_id != my_uid: self._populate_security_section() def _populate_security_section(self): """Populate security/verification info for a peer user.""" t = c() sec = self._security_section # Clear previous contents while sec.count(): item = sec.takeAt(0) if item.widget(): item.widget().deleteLater() sec.addSpacing(8) header = QLabel("Security") header.setStyleSheet(f"font-weight: bold; color: {t.accent};") sec.addWidget(header) status = self.bridge.client.get_verification_status(self.user_id) if status == "verified": status_label = QLabel("\u2705 Identity verified") status_label.setStyleSheet(f"color: {t.success}; font-weight: bold;") elif status == "trusted": status_label = QLabel("\U0001f512 Trusted (first use)") status_label.setStyleSheet(f"color: {t.warning};") else: status_label = QLabel("\u26A0 Unverified") status_label.setStyleSheet(f"color: {t.text_muted};") sec.addWidget(status_label) fp = self.bridge.client.get_peer_fingerprint(self.user_id) if fp: fp_label = QLabel(f"Fingerprint:\n{fp}") fp_label.setStyleSheet( f"font-family: monospace; font-size: 8pt; color: {t.text_primary}; " f"background: {t.bg_secondary}; padding: 4px; border-radius: 4px;" ) fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) sec.addWidget(fp_label) 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 VerificationDialog(QDialog): """Dialog for viewing safety numbers, fingerprints, QR codes, and verifying contacts.""" def __init__(self, bridge: AsyncBridge, peer_user_id: str, peer_name: str, parent=None): super().__init__(parent) self.bridge = bridge self.peer_user_id = peer_user_id self.peer_name = peer_name self.setMinimumWidth(420) self._build_ui() def _build_ui(self): t = c() lay = _make_frameless(self, "Verify Contact") lay.setSpacing(10) # Peer name name_label = QLabel(self.peer_name) name_label.setStyleSheet(f"font-size: 14pt; font-weight: bold; color: {t.accent};") name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.addWidget(name_label) # Verification status status = self.bridge.client.get_verification_status(self.peer_user_id) if status == "verified": status_text = "\u2705 Verified" status_color = t.success elif status == "trusted": status_text = "\U0001f512 Trusted (TOFU)" status_color = t.warning else: status_text = "\u26A0 Unverified" status_color = t.error self._status_label = QLabel(status_text) self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {status_color};") self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.addWidget(self._status_label) lay.addSpacing(4) # Safety Number safety = self.bridge.client.get_safety_number(self.peer_user_id) if safety: sn_header = QLabel("Safety Number") sn_header.setStyleSheet(f"font-weight: bold; color: {t.text_secondary};") lay.addWidget(sn_header) sn_label = QLabel(safety) sn_label.setStyleSheet( f"font-family: monospace; font-size: 13pt; letter-spacing: 2px; " f"color: {t.text_primary}; background: {t.bg_secondary}; " f"padding: 12px; border-radius: 8px;" ) sn_label.setAlignment(Qt.AlignmentFlag.AlignCenter) sn_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) lay.addWidget(sn_label) lay.addSpacing(4) # QR Code qr_data = self.bridge.client.get_verification_qr_data() if qr_data: qr_header = QLabel("Your QR Code (for peer to scan)") qr_header.setStyleSheet(f"font-weight: bold; color: {t.text_secondary};") lay.addWidget(qr_header) qr_pixmap = self._generate_qr_pixmap(qr_data) if qr_pixmap: qr_label = QLabel() qr_label.setPixmap(qr_pixmap) qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.addWidget(qr_label) save_qr_btn = QPushButton("Save QR Code") save_qr_btn.setObjectName("secondaryBtn") save_qr_btn.clicked.connect(lambda: self._save_qr(qr_pixmap)) lay.addWidget(save_qr_btn) lay.addSpacing(4) # Fingerprints my_fp = self.bridge.client.get_my_fingerprint() peer_fp = self.bridge.client.get_peer_fingerprint(self.peer_user_id) if my_fp or peer_fp: fp_header = QLabel("Fingerprints") fp_header.setStyleSheet(f"font-weight: bold; color: {t.text_secondary};") lay.addWidget(fp_header) if my_fp: my_fp_label = QLabel(f"Yours:\n{my_fp}") my_fp_label.setStyleSheet( f"font-family: monospace; font-size: 9pt; color: {t.text_primary}; " f"background: {t.bg_secondary}; padding: 6px; border-radius: 4px;" ) my_fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) lay.addWidget(my_fp_label) if peer_fp: peer_name_esc = self.peer_name.replace("&", "&").replace("<", "<") peer_fp_label = QLabel(f"{peer_name_esc}:\n{peer_fp}") peer_fp_label.setStyleSheet( f"font-family: monospace; font-size: 9pt; color: {t.text_primary}; " f"background: {t.bg_secondary}; padding: 6px; border-radius: 4px;" ) peer_fp_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) lay.addWidget(peer_fp_label) lay.addSpacing(8) # Action buttons btn_row = QHBoxLayout() if status != "verified": verify_btn = QPushButton("Mark as Verified") verify_btn.setStyleSheet( f"QPushButton {{ background-color: {t.success}; color: {t.bg_primary}; " f"font-weight: bold; padding: 8px 16px; border-radius: 6px; }}" f"QPushButton:hover {{ opacity: 0.9; }}" ) verify_btn.clicked.connect(self._on_verify) btn_row.addWidget(verify_btn) else: unverify_btn = QPushButton("Remove Verification") unverify_btn.setStyleSheet( f"QPushButton {{ background-color: {t.warning}; color: {t.bg_primary}; " f"font-weight: bold; padding: 8px 16px; border-radius: 6px; }}" ) unverify_btn.clicked.connect(self._on_unverify) btn_row.addWidget(unverify_btn) # Scan QR button scan_btn = QPushButton("Scan QR Code") scan_btn.setObjectName("secondaryBtn") scan_btn.clicked.connect(self._on_scan_qr) btn_row.addWidget(scan_btn) lay.addLayout(btn_row) close_btn = QPushButton("Close") close_btn.setObjectName("secondaryBtn") close_btn.clicked.connect(self.accept) lay.addWidget(close_btn) def _generate_qr_pixmap(self, data: bytes) -> QPixmap | None: """Generate a QR code QPixmap from raw bytes (base64-encoded for scanner compat).""" try: import qrcode import base64 from io import BytesIO # Encode as base64 — raw binary gets corrupted by QR readers (UTF-8 re-encoding) qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=4, border=2) qr.add_data(base64.b64encode(data).decode("ascii")) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buf = BytesIO() img.save(buf, format="PNG") buf.seek(0) qimg = QImage() qimg.loadFromData(buf.getvalue()) if qimg.isNull(): return None return QPixmap.fromImage(qimg) except ImportError: return None except Exception: return None def _save_qr(self, pixmap: QPixmap): """Save QR code image to file.""" path, _ = QFileDialog.getSaveFileName( self, "Save QR Code", "verification_qr.png", "PNG Images (*.png);;All Files (*)", ) if path: pixmap.save(path, "PNG") def _on_verify(self): cached = self.bridge.client._user_cache.get(self.peer_user_id) if cached and cached.get("identity_key_bytes"): self.bridge.client.verify_contact( self.peer_user_id, cached["identity_key_bytes"], method="safety_number" ) t = c() self._status_label.setText("\u2705 Verified") self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {t.success};") def _on_unverify(self): self.bridge.client.unverify_contact(self.peer_user_id) t = c() self._status_label.setText("\U0001f512 Trusted (TOFU)") self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {t.warning};") def _on_scan_qr(self): """Open file picker for QR code image, decode and verify.""" path, _ = QFileDialog.getOpenFileName( self, "Select QR Code Image", "", "Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)", ) if not path: return try: from PIL import Image pil_img = Image.open(path) except Exception as e: QMessageBox.warning(self, "Error", f"Failed to open image: {e}") return # Try pyzbar first, fall back to manual decode qr_text = None try: from pyzbar.pyzbar import decode as pyzbar_decode results = pyzbar_decode(pil_img) if results: qr_text = results[0].data except ImportError: pass if qr_text is None: QMessageBox.information( self, "QR Scan", "Could not decode QR code. Install 'pyzbar' for QR scanning support, " "or verify manually using the safety number above." ) return # QR contains base64-encoded binary payload import base64 try: qr_data = base64.b64decode(qr_text) except Exception: QMessageBox.warning(self, "Error", "Invalid QR code format.") return ok, user_id, message = self.bridge.client.verify_qr_code(qr_data) if ok: t = c() self._status_label.setText("\u2705 Verified") self._status_label.setStyleSheet(f"font-size: 11pt; font-weight: bold; color: {t.success};") QMessageBox.information(self, "Verification", message) else: QMessageBox.warning(self, "Verification Failed", message) class LoginWindow(QWidget): def __init__(self, bridge: AsyncBridge): super().__init__() self.bridge = bridge self.setWindowTitle("Encrypted Chat - Login") self.setFixedSize(500, 540) self._pair_email = "" self._pair_password = "" self._build_ui() tm().on_change(self._apply_theme) def _login_card_qss(self): t = c() return ( f"#loginCard {{ background-color: {t.bg_primary}; border-radius: 16px; }}" f"#loginCard QWidget {{ background: transparent; }}" f"#loginCard QLabel {{ background: transparent; color: {t.text_primary}; }}" f"#loginCard QLineEdit {{" f" background-color: {t.bg_secondary}; color: {t.text_primary};" f" border: 1px solid {t.border}; border-radius: 6px; padding: 8px;" f"}}" f"#loginCard QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" f"#loginCard QPushButton {{" f" background-color: {t.accent}; color: {t.accent_text};" f" border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold;" f"}}" f"#loginCard QPushButton:hover {{ background-color: {t.accent_hover}; }}" ) def _tab_bar_qss(self): t = c() return ( f"QTabBar {{ background: transparent; border: none; }}" f"QTabBar::tab {{ background: transparent; color: {t.text_muted}; " f"padding: 10px 24px; font-size: 10pt; border: none; " f"border-bottom: 2px solid transparent; }}" f"QTabBar::tab:selected {{ color: {t.accent}; font-weight: bold; " f"border-bottom: 2px solid {t.accent}; }}" f"QTabBar::tab:hover {{ color: {t.text_primary}; }}" f"QTabWidget::pane {{ border: none; background: transparent; }}" ) def _apply_theme(self): QApplication.instance().setStyleSheet(qss()) t = c() self.setStyleSheet(f"background-color: {t.bg_tertiary};") self._card.setStyleSheet(self._login_card_qss()) self._subtitle.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; margin-bottom: 8px;") self._theme_btn.setText("\u2600" if tm().is_dark else "\U0001f319") self._theme_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.text_muted}; " f"border: none; font-size: 14pt; }}" f"QPushButton:hover {{ color: {t.accent}; }}" ) self._tabs.setStyleSheet(self._tab_bar_qss()) # Verification page self._step_label.setStyleSheet(f"color: {t.accent}; font-weight: bold; font-size: 10pt;") self._info_label.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;") self.code_input.setStyleSheet( f"QLineEdit {{ font-size: 16pt; letter-spacing: 8px; text-align: center; " f"background-color: {t.bg_secondary}; border: 1px solid {t.border}; " f"border-radius: 6px; padding: 12px; color: {t.text_primary}; }}" f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) def _build_ui(self): from PyQt6.QtWidgets import QTabWidget outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) t = c() # Background fills entire window self.setStyleSheet(f"background-color: {t.bg_tertiary};") # Theme toggle in top-right corner top_row = QHBoxLayout() top_row.setContentsMargins(12, 8, 12, 0) top_row.addStretch() self._theme_btn = QPushButton("\u2600" if tm().is_dark else "\U0001f319") self._theme_btn.setFixedSize(36, 36) self._theme_btn.setToolTip("Toggle light/dark mode") self._theme_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.text_muted}; " f"border: none; font-size: 14pt; }}" f"QPushButton:hover {{ color: {t.accent}; }}" ) self._theme_btn.clicked.connect(tm().toggle) top_row.addWidget(self._theme_btn) outer.addLayout(top_row) self.stack = QStackedWidget() outer.addWidget(self.stack) # --- Page 0: Login / Register form (card) --- page0 = QWidget() page0_layout = QVBoxLayout(page0) page0_layout.setContentsMargins(40, 0, 40, 20) page0_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self._card = QWidget() self._card.setObjectName("loginCard") self._card.setStyleSheet(self._login_card_qss()) card_layout = QVBoxLayout(self._card) card_layout.setSpacing(10) card_layout.setContentsMargins(36, 28, 36, 28) title = QLabel("Encrypted Chat") title.setObjectName("title") title.setAlignment(Qt.AlignmentFlag.AlignCenter) card_layout.addWidget(title) self._subtitle = QLabel("End-to-end encrypted messaging") self._subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) self._subtitle.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; margin-bottom: 8px;") card_layout.addWidget(self._subtitle) # --- Tabs: Login | Register | Link Device --- self._tabs = QTabWidget() self._tabs.setMinimumHeight(220) self._tabs.setStyleSheet(self._tab_bar_qss()) # == Login tab == login_tab = QWidget() login_lay = QVBoxLayout(login_tab) login_lay.setSpacing(12) login_lay.setContentsMargins(0, 12, 0, 4) self.email_input = QLineEdit() self.email_input.setPlaceholderText("Email") login_lay.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) login_lay.addWidget(self.password_input) login_lay.addStretch() self.login_btn = QPushButton("Login") self.login_btn.setMinimumHeight(40) self.login_btn.clicked.connect(self._on_login) login_lay.addWidget(self.login_btn) self._tabs.addTab(login_tab, "Login") # == Register tab == reg_tab = QWidget() reg_lay = QVBoxLayout(reg_tab) reg_lay.setSpacing(12) reg_lay.setContentsMargins(0, 12, 0, 4) self.username_input = QLineEdit() self.username_input.setPlaceholderText("Username (display name)") reg_lay.addWidget(self.username_input) self._reg_email_input = QLineEdit() self._reg_email_input.setPlaceholderText("Email") reg_lay.addWidget(self._reg_email_input) self._reg_password_input = QLineEdit() self._reg_password_input.setPlaceholderText("Password") self._reg_password_input.setEchoMode(QLineEdit.EchoMode.Password) self._reg_password_input.returnPressed.connect(self._on_register) reg_lay.addWidget(self._reg_password_input) reg_lay.addStretch() self.register_btn = QPushButton("Register") self.register_btn.setMinimumHeight(40) self.register_btn.clicked.connect(self._on_register) reg_lay.addWidget(self.register_btn) self._tabs.addTab(reg_tab, "Register") # == Link Device tab == link_tab = QWidget() link_lay = QVBoxLayout(link_tab) link_lay.setSpacing(12) link_lay.setContentsMargins(0, 12, 0, 4) self._link_email_input = QLineEdit() self._link_email_input.setPlaceholderText("Email") link_lay.addWidget(self._link_email_input) self._link_password_input = QLineEdit() self._link_password_input.setPlaceholderText("Password") self._link_password_input.setEchoMode(QLineEdit.EchoMode.Password) self._link_password_input.returnPressed.connect(self._on_link_device) link_lay.addWidget(self._link_password_input) link_lay.addStretch() self.link_btn = QPushButton("Link Device") self.link_btn.setMinimumHeight(40) self.link_btn.clicked.connect(self._on_link_device) link_lay.addWidget(self.link_btn) self._tabs.addTab(link_tab, "Link Device") card_layout.addWidget(self._tabs) self.status_label = QLabel("") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setWordWrap(True) card_layout.addWidget(self.status_label) page0_layout.addWidget(self._card) self.stack.addWidget(page0) # --- Page 1: Verification code form --- page1 = QWidget() vl = QVBoxLayout(page1) vl.setSpacing(14) vl.setContentsMargins(50, 40, 50, 40) self._step_label = QLabel("Step 2 of 2") self._step_label.setStyleSheet(f"color: {t.accent}; font-weight: bold; font-size: 10pt;") self._step_label.setAlignment(Qt.AlignmentFlag.AlignCenter) vl.addWidget(self._step_label) self._info_label = QLabel("Enter the 6-digit verification code sent to your email") self._info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._info_label.setWordWrap(True) self._info_label.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;") vl.addWidget(self._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( f"QLineEdit {{ font-size: 16pt; letter-spacing: 8px; text-align: center; " f"background-color: {t.bg_secondary}; border: 1px solid {t.border}; " f"border-radius: 6px; padding: 12px; color: {t.text_primary}; }}" f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) 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(f"color: {c().success};") 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(f"color: {c().error};") return self.code_status_label.setText("Confirming...") self.code_status_label.setStyleSheet(f"color: {c().success};") 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._reg_password_input.text() email = self._reg_email_input.text().strip() if not username: self.show_error("Username required.") 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._link_email_input.text().strip() password = self._link_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._tabs.setEnabled(enabled) self.username_input.setEnabled(enabled) self.email_input.setEnabled(enabled) self.password_input.setEnabled(enabled) self._reg_email_input.setEnabled(enabled) self._reg_password_input.setEnabled(enabled) self._link_email_input.setEnabled(enabled) self._link_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(f"color: {c().error};") self.confirm_btn.setEnabled(True) self.back_btn.setEnabled(True) else: self.status_label.setText(msg) self.status_label.setStyleSheet(f"color: {c().error};") 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(f"color: {c().success};") else: self.status_label.setText(msg) self.status_label.setStyleSheet(f"color: {c().success};") def reset(self): self.stack.setCurrentIndex(0) self._tabs.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._reg_email_input.clear() self._reg_password_input.clear() self._link_email_input.clear() self._link_password_input.clear() self.code_input.clear() self._set_enabled(True) self.confirm_btn.setEnabled(True) self.back_btn.setEnabled(True) class MessageBubble(QFrame): """Chat message bubble with rounded corners drawn via QPainter.""" def __init__(self, bg_color: str, parent=None): super().__init__(parent) self._bg_color = QColor(bg_color) self.setStyleSheet("background: transparent; border: none;") self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu) def set_bg_color(self, color: str): self._bg_color = QColor(color) self.update() def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setBrush(QBrush(self._bg_color)) p.setPen(Qt.PenStyle.NoPen) p.drawRoundedRect(self.rect(), 14, 14) p.end() def contextMenuEvent(self, event): # Walk up to find _msg_index, then call main window handler idx = getattr(self, '_msg_index', None) if idx is None: p = self.parentWidget() while p: idx = getattr(p, '_msg_index', None) if idx is not None: break p = p.parentWidget() main_win = self.window() if idx is not None and hasattr(main_win, '_show_msg_context_menu'): main_win._show_msg_context_menu(idx, event.globalPos()) event.accept() class MainWindow(QWidget): _AVATAR_REFRESH_BATCH = 8 _GROUP_AVATAR_REFRESH_BATCH = 4 _show_verification_dialog_signal = pyqtSignal(str, str) # peer_uid, peer_name 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 = _LRUPixmapCache() # user_id -> pixmap self._group_avatar_cache = _LRUPixmapCache() # conv_id -> pixmap self._avatar_requested: set[str] = set() self._group_avatar_requested: set[str] = set() self._avatar_refresh_cursor = 0 self._group_avatar_refresh_cursor = 0 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._privacy_enabled: bool = True # Privacy overlay on/off self._last_message_cache: dict[str, tuple[str, str, str]] = {} # conv_id -> (text, ts, receipt) self._build_ui() self._connect_signals() self._setup_tray_icon() self._setup_privacy_overlay() # Keyboard shortcuts QShortcut(QKeySequence("Ctrl+F"), self).activated.connect(self._toggle_search) QShortcut(QKeySequence("Ctrl+Shift+P"), self).activated.connect(self._toggle_privacy) 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 tm().on_change(self._apply_theme) # -- Theme switching ------------------------------------------------------- def _apply_theme(self): """Re-apply theme colours to all widgets after theme toggle.""" app = QApplication.instance() if app: app.setStyleSheet(qss()) t = c() # Sidebar self._sidebar_panel.setStyleSheet(f"#sidebarPanel {{ background-color: {t.bg_tertiary}; }}") self._conv_label.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent}; background: transparent;") self._settings_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.text_secondary}; border: none; border-radius: 6px; padding: 8px 16px; }}" f"QPushButton:hover {{ background-color: {t.bg_hover}; }}" ) self.conv_list.setStyleSheet( f"QListWidget {{ background-color: {t.bg_tertiary}; border: none; padding: 0px; }}" f"QListWidget::item {{ padding: 0px; border: none; }}" f"QListWidget::item:selected {{ background: transparent; border: none; }}" f"QListWidget::item:hover {{ background: transparent; }}" ) # Invitation list self.inv_label.setStyleSheet(f"font-weight: bold; font-size: 9pt; color: {t.warning}; margin-top: 4px;") self.inv_list.setStyleSheet( f"QListWidget {{ background-color: {t.bg_primary}; border: 1px solid {t.warning}; border-radius: 6px; padding: 2px; }}" f"QListWidget::item {{ padding: 6px; color: {t.text_primary}; }}" f"QListWidget::item:hover {{ background-color: {t.bg_hover}; color: {t.text_primary}; }}" ) # Chat header self._chat_header_widget.setStyleSheet( f"#chatHeader {{ border-bottom: 1px solid {t.separator}; }}" f"#chatHeader QLabel, #chatHeader QPushButton {{ border: none; }}" ) self.chat_header.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent};") self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;") self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;") self.connection_dot.setStyleSheet(f"color: {t.success}; font-size: 11pt;") self._logout_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.error}; border: none; " f"border-radius: 16px; font-size: 13pt; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.error}; color: {t.accent_text}; }}" ) self.delete_conv_btn.setStyleSheet( f"QPushButton {{ background: transparent; border: none; border-radius: 4px; padding: 4px; }}" f"QPushButton:hover {{ background-color: {t.error}; }}" ) # Search bar self.search_input.setStyleSheet( f"QLineEdit {{ background-color: {t.bg_secondary}; color: {t.text_primary}; " f"border: 1px solid {t.border}; border-radius: 4px; padding: 4px 8px; font-size: 10pt; }}" ) self.search_count_label.setStyleSheet(f"color: {t.text_muted}; font-size: 9pt; min-width: 40px;") # Pin banner self._pin_banner.setStyleSheet(f"background-color:{t.border}; border-bottom:2px solid {t.pin_color};") self._pin_banner_label.setStyleSheet(f"color:{t.text_primary}; font-size:9pt; background:transparent; border:none;") # Jump button self.jump_btn.setStyleSheet( f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; border-radius: 18px; " f"font-size: 14pt; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.accent_hover}; }}" ) # Reply label self.reply_label.setStyleSheet( f"color: {t.accent}; font-style: italic; font-size: 9pt; " f"padding: 2px 4px; background: transparent;" ) # Input area self._attach_btn.setStyleSheet( f"QPushButton {{ background-color: {t.bg_secondary}; border: none; " f"border-radius: 20px; font-size: 14pt; }}" f"QPushButton:hover {{ background-color: {t.bg_hover}; }}" ) self._send_btn.setStyleSheet( f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; " f"border: none; border-radius: 20px; font-size: 14pt; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.accent_hover}; }}" ) self.msg_input.setStyleSheet(self.msg_input._style_normal()) # Counters self.char_counter.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt; padding: 0 4px;") self.reencrypt_label.setStyleSheet( f"background-color: {t.bg_secondary}; border-radius: 6px; " f"padding: 8px 12px; color: {t.success}; font-weight: bold;" ) # Status bar self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.success}; font-size: 8pt;" ) # Privacy overlay if hasattr(self, "_privacy_overlay"): self._privacy_overlay.setStyleSheet(f"background-color: {t.overlay};") self._lock_hint.setStyleSheet(f"font-size: 12pt; color: {t.text_muted}; background: transparent;") self._lock_input.setStyleSheet( f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; " f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; " f"color: {t.text_primary}; }}" f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) self._lock_error.setStyleSheet(f"font-size: 9pt; color: {t.error}; background: transparent;") # Mention popup if hasattr(self, "_mention_popup"): self._mention_popup.setStyleSheet( f"QListWidget {{ background:{t.bg_secondary}; color:{t.text_primary}; border:1px solid {t.border}; " f"border-radius:4px; font-size:10pt; }}" f"QListWidget::item {{ padding:4px 8px; }}" f"QListWidget::item:selected {{ background:{t.border}; }}" ) # Message scroll area if hasattr(self, '_msg_scroll_area'): self._msg_scroll_area.setStyleSheet( f"QScrollArea {{ background-color: {t.bg_primary}; border: none; }}" ) self._msg_container.setStyleSheet( f"background-color: {t.bg_primary};" ) # Re-render messages and conversation list self._rebuild_conv_list() if self.current_messages: self._render_messages(scroll_to_bottom=False) # -- Privacy Overlay (lock screen) ---------------------------------------- _LOCK_TIMEOUT_MS = 30_000 # 30 s unfocused → lock (require password) def _setup_privacy_overlay(self): """Create overlay that hides content on focus loss; locks after timeout.""" self._privacy_locked = False # Check if identity key is password-encrypted (ECP1 format) # If not, lock feature is disabled (no password to verify against) self._lock_capable = False try: from chat_core import get_key_dir key_path = get_key_dir(self.bridge.client.email) / "identity_private.bin" if key_path.exists(): self._lock_capable = key_path.read_bytes()[:4] == b"ECP1" except Exception: pass t = c() # -- overlay widget -- self._privacy_overlay = QWidget(self) self._privacy_overlay.setStyleSheet( f"background-color: {t.overlay};" ) self._privacy_overlay.hide() overlay_layout = QVBoxLayout(self._privacy_overlay) overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) lock_icon = QLabel("\U0001f512") lock_icon.setStyleSheet("font-size: 36pt; background: transparent;") lock_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) overlay_layout.addWidget(lock_icon) self._lock_hint = QLabel("Encrypted Chat") self._lock_hint.setStyleSheet( f"font-size: 12pt; color: {t.text_muted}; background: transparent;" ) self._lock_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) overlay_layout.addWidget(self._lock_hint) # Password input (hidden until locked) self._lock_input = QLineEdit() self._lock_input.setPlaceholderText("Enter password to unlock") self._lock_input.setEchoMode(QLineEdit.EchoMode.Password) self._lock_input.setMaximumWidth(280) self._lock_input.setStyleSheet( f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; " f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; " f"color: {t.text_primary}; }}" f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) self._lock_input.returnPressed.connect(self._on_unlock_attempt) self._lock_input.hide() overlay_layout.addWidget(self._lock_input, alignment=Qt.AlignmentFlag.AlignCenter) self._lock_error = QLabel("") self._lock_error.setStyleSheet( f"font-size: 9pt; color: {t.error}; background: transparent;" ) self._lock_error.setAlignment(Qt.AlignmentFlag.AlignCenter) self._lock_error.hide() overlay_layout.addWidget(self._lock_error) # Timer: after N seconds unfocused → require password self._lock_timer = QTimer(self) self._lock_timer.setSingleShot(True) self._lock_timer.timeout.connect(self._on_lock_timeout) def _toggle_privacy(self): """Toggle privacy overlay on/off (Ctrl+Shift+P).""" self._privacy_enabled = not self._privacy_enabled if not self._privacy_enabled: self._privacy_locked = False self._lock_timer.stop() self._hide_privacy_overlay() state = "ON" if self._privacy_enabled else "OFF" base_title = f"Encrypted Chat - {self.bridge.client.username}" self.setWindowTitle(f"{base_title} [Privacy: {state}]") QTimer.singleShot(2000, lambda: self.setWindowTitle(base_title)) def _show_privacy_overlay(self): if not self._privacy_enabled: return if not self._privacy_overlay.isVisible(): self._privacy_overlay.setGeometry(self.rect()) self._privacy_overlay.raise_() self._privacy_overlay.show() # Start lock countdown self._lock_timer.start(self._LOCK_TIMEOUT_MS) def _hide_privacy_overlay(self): self._lock_timer.stop() self._lock_input.hide() self._lock_input.clear() self._lock_error.hide() self._lock_hint.setText("Encrypted Chat") if self._privacy_overlay.isVisible(): self._privacy_overlay.hide() def _on_lock_timeout(self): """Window unfocused too long — require password.""" if self._privacy_overlay.isVisible() and self._lock_capable: self._privacy_locked = True self._lock_hint.setText("Session locked") self._lock_input.show() self._lock_input.setFocus() def _on_unlock_attempt(self): """Verify password by decrypting identity key from disk.""" from chat_core import get_key_dir, _check_lockout, _record_failed_attempt, _clear_lockout from crypto_utils import _decrypt_private_key pwd = self._lock_input.text() if not pwd: return email = self.bridge.client.email remaining = _check_lockout(email) if remaining > 0: self._lock_error.setText(f"Too many attempts. Wait {remaining:.0f}s.") self._lock_error.show() self._lock_input.clear() self._lock_input.setFocus() return try: key_path = get_key_dir(email) / "identity_private.bin" data = key_path.read_bytes() _decrypt_private_key(data, pwd.encode("utf-8")) # Success — unlock _clear_lockout(email) self._privacy_locked = False self._hide_privacy_overlay() except Exception: _record_failed_attempt(email) remaining = _check_lockout(email) if remaining > 0: self._lock_error.setText(f"Wrong password. Wait {remaining:.0f}s.") else: self._lock_error.setText("Wrong password") self._lock_error.show() self._lock_input.clear() self._lock_input.setFocus() def changeEvent(self, event): """Handle window state changes — tray minimize + privacy overlay.""" from PyQt6.QtCore import QEvent if event.type() == QEvent.Type.WindowStateChange: if self.isMinimized() and self._tray_icon is not None: event.ignore() self.hide() return if event.type() == QEvent.Type.ActivationChange: if self.isActiveWindow(): if not self._privacy_locked: self._hide_privacy_overlay() else: # Locked — keep overlay, focus password input self._lock_input.setFocus() else: self._show_privacy_overlay() super().changeEvent(event) def resizeEvent(self, event): """Keep privacy overlay sized to window.""" super().resizeEvent(event) if hasattr(self, "_privacy_overlay"): self._privacy_overlay.setGeometry(self.rect()) # -- System Tray ---------------------------------------------------------- def _make_tray_icon(self) -> QIcon: """Create a simple app icon (blue chat bubble) for the system tray.""" px = QPixmap(64, 64) px.fill(QColor(0, 0, 0, 0)) p = QPainter(px) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setBrush(QBrush(QColor(c().accent))) p.setPen(Qt.PenStyle.NoPen) p.drawRoundedRect(4, 4, 56, 48, 12, 12) # small triangle (tail) from PyQt6.QtGui import QPolygon from PyQt6.QtCore import QPoint p.drawPolygon(QPolygon([QPoint(14, 52), QPoint(24, 44), QPoint(30, 52)])) # lock icon (E2E indicator) p.setBrush(QBrush(QColor(c().accent_text))) p.drawRoundedRect(24, 16, 16, 14, 3, 3) p.setPen(QPen(QColor(c().accent_text), 3)) p.setBrush(Qt.BrushStyle.NoBrush) p.drawArc(27, 10, 10, 12, 0, 180 * 16) p.end() return QIcon(px) def _setup_tray_icon(self): """Initialize the system tray icon with context menu.""" if not QSystemTrayIcon.isSystemTrayAvailable(): self._tray_icon = None return self._tray_icon = QSystemTrayIcon(self._make_tray_icon(), self) self._tray_icon.setToolTip(f"Encrypted Chat - {self.bridge.client.username}") self._tray_icon.activated.connect(self._on_tray_activated) tray_menu = QMenu() show_action = tray_menu.addAction("Show") show_action.triggered.connect(self._restore_from_tray) tray_menu.addSeparator() quit_action = tray_menu.addAction("Quit") quit_action.triggered.connect(self._quit_from_tray) self._tray_icon.setContextMenu(tray_menu) self._tray_icon.show() def _on_tray_activated(self, reason): """Handle tray icon click — restore window on double-click or single click.""" if reason in (QSystemTrayIcon.ActivationReason.Trigger, QSystemTrayIcon.ActivationReason.DoubleClick): self._restore_from_tray() def _restore_from_tray(self): """Restore window from system tray.""" self.showNormal() self.activateWindow() self.raise_() def _quit_from_tray(self): """Quit the application from tray menu.""" if self._tray_icon: self._tray_icon.hide() self._is_logout = False self.close() def _show_tray_notification(self, title: str, text: str): """Show a system tray toast notification if the window is not in the foreground.""" if not self._tray_icon: logger.debug("Tray notification skipped: no tray icon") return if self.isVisible() and self.isActiveWindow() and not self._privacy_locked: return # user is looking at the app (and it's not locked) if len(text) > 120: text = text[:117] + "..." logger.info("Tray notification: %s — %s", title, text[:50]) self._tray_icon.showMessage( title, text, QSystemTrayIcon.MessageIcon.Information, 4000, ) # -- End Tray --------------------------------------------------------------- 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 — higher saturation in light mode hue = (hash(username) % 360) sat = 160 if not tm().is_dark else 120 val = 180 if not tm().is_dark else 200 color = QColor.fromHsv(hue, sat, val) 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 Variable", int(size * 0.4)) if not font.exactMatch(): 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 # Border ring (matches sidebar background) painter.setBrush(QBrush(QColor(c().online_dot_border))) painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(x - 1, y - 1, dot_size + 2, dot_size + 2) # Green dot painter.setBrush(QBrush(QColor(c().online_dot))) 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 _get_conv_avatar_pixmap(self, conv: dict, size: int = 44) -> QPixmap: """Get avatar QPixmap for delegate painting.""" 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], size) else: avatar = self._make_default_avatar(uname, size) 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 avatar conv_id = conv.get("conversation_id") or "" if conv_id in self._group_avatar_cache: return self._make_circular_avatar(self._group_avatar_cache[conv_id], size) gname = conv.get("name") or "G" 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 self._make_default_avatar(gname, size) 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) t = c() left.setObjectName("sidebarPanel") left.setStyleSheet(f"#sidebarPanel {{ background-color: {t.bg_tertiary}; }}") self._sidebar_panel = left header_row = QHBoxLayout() self._conv_label = QLabel("Conversations") self._conv_label.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent}; background: transparent;") header_row.addWidget(self._conv_label) header_row.addStretch() new_chat_btn = QPushButton("") new_chat_btn.setFixedSize(32, 32) new_chat_btn.setObjectName("toolBtn") 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("toolBtn") 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) profile_btn = QPushButton("") profile_btn.setFixedSize(32, 32) profile_btn.setObjectName("toolBtn") 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) left_layout.addLayout(header_row) # Invitation section (hidden when empty) self.inv_label = QLabel("Pending Invitations") self.inv_label.setStyleSheet(f"font-weight: bold; font-size: 9pt; color: {t.warning}; 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( f"QListWidget {{ background-color: {t.bg_primary}; border: 1px solid {t.warning}; border-radius: 6px; padding: 2px; }}" f"QListWidget::item {{ padding: 6px; color: {t.text_primary}; }}" f"QListWidget::item:hover {{ background-color: {t.bg_hover}; color: {t.text_primary}; }}" ) left_layout.addWidget(self.inv_list) self.conv_list = QListWidget() self.conv_list.setIconSize(QSize(44, 44)) # Override global QSS item styles — delegate handles all painting self.conv_list.setStyleSheet( f"QListWidget {{ background-color: {t.bg_tertiary}; border: none; padding: 0px; }}" f"QListWidget::item {{ padding: 0px; border: none; }}" f"QListWidget::item:selected {{ background: transparent; border: none; }}" f"QListWidget::item:hover {{ background: transparent; }}" ) self._conv_delegate = ConversationDelegate(self.conv_list) self.conv_list.setItemDelegate(self._conv_delegate) self.conv_list.setMouseTracking(True) # for hover painting 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) # Bottom toolbar row: settings + logout bottom_row = QHBoxLayout() bottom_row.setContentsMargins(0, 4, 0, 0) settings_btn = QPushButton("\u2699 Settings") settings_btn.setObjectName("sidebarBtn") settings_btn.setToolTip("Open settings") settings_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.text_secondary}; border: none; border-radius: 6px; padding: 8px 16px; }}" f"QPushButton:hover {{ background-color: {t.bg_hover}; }}" ) settings_btn.clicked.connect(self._on_open_settings) self._settings_btn = settings_btn bottom_row.addWidget(settings_btn) logout_btn = QPushButton("\u2715") logout_btn.setFixedSize(32, 32) logout_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {t.error}; border: none; " f"border-radius: 16px; font-size: 13pt; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.error}; color: {t.accent_text}; }}" ) logout_btn.setToolTip("Logout") logout_btn.clicked.connect(self._on_logout) self._logout_btn = logout_btn bottom_row.addWidget(logout_btn) left_layout.addLayout(bottom_row) # Right panel - messages right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(4, 8, 8, 8) # Chat header bar (56px height) chat_header_widget = QWidget() chat_header_widget.setFixedHeight(56) self._chat_header_widget = chat_header_widget chat_header_widget.setObjectName("chatHeader") chat_header_widget.setStyleSheet( f"#chatHeader {{ border-bottom: 1px solid {t.separator}; }}" f"#chatHeader QLabel, #chatHeader QPushButton {{ border: none; }}" ) chat_header_row = QHBoxLayout(chat_header_widget) chat_header_row.setContentsMargins(12, 4, 8, 4) self.chat_header_avatar = QLabel() self.chat_header_avatar.setFixedSize(40, 40) self.chat_header_avatar.setStyleSheet("background: transparent;") self.chat_header_avatar.setVisible(False) chat_header_row.addWidget(self.chat_header_avatar) # Name + status text vertical stack name_status_layout = QVBoxLayout() name_status_layout.setSpacing(0) name_status_layout.setContentsMargins(6, 0, 0, 0) self.chat_header = QLabel("Select a conversation") self.chat_header.setStyleSheet(f"font-weight: bold; font-size: 12pt; color: {t.accent};") name_status_layout.addWidget(self.chat_header) self._chat_header_status = QLabel("") self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;") self._chat_header_status.setVisible(False) name_status_layout.addWidget(self._chat_header_status) chat_header_row.addLayout(name_status_layout) # E2E lock indicator self._e2e_label = QLabel("\U0001f512 End-to-end encrypted") self._e2e_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._e2e_label.setToolTip("End-to-end encrypted") self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;") self._e2e_label.setCursor(Qt.CursorShape.PointingHandCursor) self._e2e_label.mousePressEvent = self._on_e2e_label_clicked self._e2e_label.setVisible(False) chat_header_row.addWidget(self._e2e_label) self.connection_dot = QLabel("\u25cf") self.connection_dot.setFixedSize(16, 16) self.connection_dot.setAlignment(Qt.AlignmentFlag.AlignCenter) self.connection_dot.setStyleSheet(f"color: {t.success}; 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("toolBtn") 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("toolBtn") 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( f"QPushButton {{ background: transparent; border: none; border-radius: 4px; padding: 4px; }}" f"QPushButton:hover {{ background-color: {t.error}; }}" ) 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("toolBtn") 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.pin_list_btn = QPushButton("\U0001f4cc") self.pin_list_btn.setFixedSize(32, 32) self.pin_list_btn.setObjectName("toolBtn") self.pin_list_btn.setToolTip("Pinned messages") self.pin_list_btn.clicked.connect(self._show_pinned_messages) self.pin_list_btn.setVisible(False) chat_header_row.addWidget(self.pin_list_btn) self.search_btn = QPushButton("") self.search_btn.setFixedSize(32, 32) self.search_btn.setObjectName("toolBtn") 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.addWidget(chat_header_widget) # 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( f"QLineEdit {{ background-color: {t.bg_secondary}; color: {t.text_primary}; " f"border: 1px solid {t.border}; 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("toolBtn") 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("toolBtn") 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(f"color: {t.text_muted}; 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("toolBtn") 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) # --- Pinned message banner --- self._pin_banner = QWidget() self._pin_banner.setStyleSheet( f"background-color:{t.border}; border-bottom:2px solid {t.pin_color};" ) pin_banner_layout = QHBoxLayout(self._pin_banner) pin_banner_layout.setContentsMargins(10, 6, 10, 6) pin_icon = QLabel("\U0001f4cc") pin_icon.setStyleSheet("font-size:12pt; background:transparent; border:none;") pin_banner_layout.addWidget(pin_icon) self._pin_banner_label = QLabel("") self._pin_banner_label.setStyleSheet( f"color:{t.text_primary}; font-size:9pt; background:transparent; border:none;" ) self._pin_banner_label.setCursor(Qt.CursorShape.PointingHandCursor) self._pin_banner_label.setWordWrap(False) pin_banner_layout.addWidget(self._pin_banner_label, stretch=1) pin_banner_close = QPushButton("\u2715") pin_banner_close.setFixedSize(20, 20) pin_banner_close.setStyleSheet( f"QPushButton {{ background:transparent; color:{t.text_muted}; border:none; font-size:10pt; }}" f"QPushButton:hover {{ color:{t.text_primary}; }}" ) pin_banner_close.clicked.connect(lambda: self._pin_banner.setVisible(False)) pin_banner_layout.addWidget(pin_banner_close) self._pin_banner.setVisible(False) self._pin_banner.setCursor(Qt.CursorShape.PointingHandCursor) self._pin_banner.mousePressEvent = self._on_pin_banner_clicked self._pin_banner_msg_id = None right_layout.addWidget(self._pin_banner) 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) # Message display area — QScrollArea with widget-based bubbles self._msg_scroll_area = QScrollArea() self._msg_scroll_area.setWidgetResizable(True) self._msg_scroll_area.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) self._msg_scroll_area.setStyleSheet( f"QScrollArea {{ background-color: {t.bg_primary}; border: none; }}" ) self._msg_scroll_area.setAcceptDrops(True) self._msg_scroll_area.installEventFilter(self) self._msg_scroll_area.viewport().setContextMenuPolicy( Qt.ContextMenuPolicy.NoContextMenu ) self._msg_container = QWidget() self._msg_container.setStyleSheet(f"background-color: {t.bg_primary};") self._msg_layout = QVBoxLayout(self._msg_container) self._msg_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self._msg_layout.setContentsMargins(0, 8, 0, 8) self._msg_layout.setSpacing(2) self._msg_scroll_area.setWidget(self._msg_container) self._msg_widgets = [] self.message_area = self._msg_scroll_area # alias for scroll/jump/drop right_layout.addWidget(self._msg_scroll_area, stretch=1) # Smart scroll: track if user is near bottom self._is_near_bottom = True self._msg_scroll_area.verticalScrollBar().valueChanged.connect( self._on_scroll_changed ) # Scroll-to-bottom floating button (hidden by default) self.jump_btn = QPushButton("\u2193") self.jump_btn.setParent(self.message_area) self.jump_btn.setVisible(False) self.jump_btn.setFixedSize(36, 36) self.jump_btn.setStyleSheet( f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; border-radius: 18px; " f"font-size: 14pt; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.accent_hover}; }}" ) self.jump_btn.clicked.connect(self._scroll_to_bottom) # Reply preview (above input, blue left bar) self._reply_widget = QWidget() self._reply_widget.setVisible(False) reply_row = QHBoxLayout(self._reply_widget) reply_row.setContentsMargins(8, 4, 8, 0) reply_row.setSpacing(4) reply_bar = QFrame() reply_bar.setFixedWidth(3) reply_bar.setStyleSheet(f"background-color: {t.accent}; border-radius: 1px;") reply_row.addWidget(reply_bar) self.reply_label = QLabel("") self.reply_label.setStyleSheet( f"color: {t.accent}; font-style: italic; font-size: 9pt; " f"padding: 2px 4px; background: transparent;" ) self.reply_label.setWordWrap(True) reply_row.addWidget(self.reply_label, stretch=1) reply_dismiss = QPushButton("\u2715") reply_dismiss.setFixedSize(20, 20) reply_dismiss.setStyleSheet( f"QPushButton {{ background:transparent; color:{t.text_muted}; border:none; font-size:10pt; }}" f"QPushButton:hover {{ color:{t.text_primary}; }}" ) reply_dismiss.clicked.connect(self._cancel_reply) reply_row.addWidget(reply_dismiss) right_layout.addWidget(self._reply_widget) # Input row: [attach] [input] [send] input_row = QHBoxLayout() input_row.setSpacing(8) input_row.setContentsMargins(8, 4, 8, 4) self._attach_btn = QPushButton("\U0001f4ce") attach_btn = self._attach_btn attach_btn.setFixedSize(40, 40) attach_btn.setCursor(Qt.CursorShape.PointingHandCursor) attach_btn.setStyleSheet( f"QPushButton {{ background-color: {t.bg_secondary}; border: none; " f"border-radius: 20px; font-size: 14pt; }}" f"QPushButton:hover {{ background-color: {t.bg_hover}; }}" ) self._attach_menu = QMenu(attach_btn) self._attach_menu.addAction("\U0001f5bc Image", self._on_attach_image) self._attach_menu.addAction("\U0001f4c4 File", self._on_attach_file) attach_btn.clicked.connect(lambda: self._attach_menu.exec( attach_btn.mapToGlobal(attach_btn.rect().topLeft() - QPoint(0, self._attach_menu.sizeHint().height())) )) input_row.addWidget(attach_btn) self.msg_input = MessageInput() self.msg_input.send_requested.connect(self._on_send) self.msg_input.textChanged.connect(self._on_input_changed) self.msg_input.file_dropped.connect(self._on_file_dropped) input_row.addWidget(self.msg_input, stretch=1) self._send_btn = QPushButton("\u27a4") send_btn = self._send_btn send_btn.setFixedSize(40, 40) send_btn.setCursor(Qt.CursorShape.PointingHandCursor) send_btn.setStyleSheet( f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; " f"border: none; border-radius: 20px; font-size: 14pt; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.accent_hover}; }}" ) send_btn.clicked.connect(self._on_send) input_row.addWidget(send_btn) right_layout.addLayout(input_row) self.char_counter = QLabel(f"0 / {MAX_INPUT_CHARS}") self.char_counter.setStyleSheet(f"color: {t.text_muted}; 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( f"background-color: {t.bg_secondary}; border-radius: 6px; " f"padding: 8px 12px; color: {t.success}; 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( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.success}; 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.message_sent_payload.connect(self._on_message_sent_payload) 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.password_changed.connect(self._on_password_changed) self.bridge.username_changed.connect(self._on_username_changed) self.bridge.reencrypt_status.connect(self._on_reencrypt_status) self.bridge.messages_read_notification.connect(self._on_messages_read) self.bridge.message_delivered_notification.connect(self._on_message_delivered) 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) self.bridge.reaction_notification.connect(self._on_reaction_notification) self.bridge.pin_notification.connect(self._on_pin_notification) self.bridge.unpin_notification.connect(self._on_unpin_notification) self.bridge.pinned_messages_loaded.connect(self._on_pinned_messages_loaded) self.bridge.forward_result.connect(self._on_forward_result) self.bridge.key_change_warning.connect(self._on_key_change_warning) self._show_verification_dialog_signal.connect(self._show_verification_dialog) # ------------------------------------------------------------------ # 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 cv in convs: cid = cv["conversation_id"] server_unread = cv.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: favorites 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, cv in enumerate(self.conversations): conv_id = cv["conversation_id"] name = self._get_conv_display_name(cv) count = self._unread_counts.get(conv_id, 0) is_fav = conv_id in self._favorites # Preview + timestamp from last-message cache preview_text, preview_ts, receipt_st = self._last_message_cache.get(conv_id, ("", "", "")) rel_ts = _relative_time(preview_ts) # Avatar pixmap avatar_pix = self._get_conv_avatar_pixmap(cv) # Verification status (DMs only) verified_status = "" is_dm = len(cv["members"]) == 2 and not cv.get("name") if is_dm: peer_uid = self._get_conv_other_user_id(cv) if peer_uid: verified_status = self.bridge.client.get_verification_status(peer_uid) item = QListWidgetItem() item.setSizeHint(QSize(0, ConversationDelegate.ITEM_HEIGHT)) item.setData(ROLE_CONV_ID, conv_id) item.setData(ROLE_DISPLAY_NAME, name) item.setData(ROLE_PREVIEW, preview_text) item.setData(ROLE_TIMESTAMP, rel_ts) item.setData(ROLE_UNREAD, count) item.setData(ROLE_IS_FAV, is_fav) item.setData(ROLE_AVATAR, avatar_pix) item.setData(ROLE_VERIFIED, verified_status) item.setData(ROLE_RECEIPT, receipt_st) 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: reload invitations and refresh avatars in small batches.""" # Keep this first so invitations are not queued behind a large avatar burst. self.bridge.list_invitations() uids = list(self._avatar_requested) if uids: n = len(uids) batch = min(self._AVATAR_REFRESH_BATCH, n) start = self._avatar_refresh_cursor % n for i in range(batch): uid = uids[(start + i) % n] self.bridge.get_avatar(uid) self._avatar_refresh_cursor = (start + batch) % n conv_ids = list(self._group_avatar_requested) if conv_ids: n = len(conv_ids) batch = min(self._GROUP_AVATAR_REFRESH_BATCH, n) start = self._group_avatar_refresh_cursor % n for i in range(batch): conv_id = conv_ids[(start + i) % n] self.bridge.get_group_avatar(conv_id) self._group_avatar_refresh_cursor = (start + batch) % n 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") t = c() self.status_bar.setText(f"{inviter} invited you to {conv_name}") self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.warning}; 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): t = c() if state == "connected": self.connection_dot.setStyleSheet(f"color: {t.success}; font-size: 11pt;") self.connection_dot.setToolTip("Connected") self.status_bar.setText("Connected") self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.success}; font-size: 8pt;" ) QTimer.singleShot(3000, self._clear_status_bar) elif state == "disconnected": self.connection_dot.setStyleSheet(f"color: {t.error}; font-size: 11pt;") self.connection_dot.setToolTip("Disconnected") self.status_bar.setText("Disconnected from server") self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.error}; font-size: 8pt; font-weight: bold;" ) self._status_bar_conv_id = None elif state == "reconnecting": self.connection_dot.setStyleSheet(f"color: {t.warning}; font-size: 11pt;") self.connection_dot.setToolTip("Reconnecting...") self.status_bar.setText("Reconnecting...") self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.warning}; font-size: 8pt;" ) self._status_bar_conv_id = None elif state == "revoked": self.connection_dot.setStyleSheet(f"color: {t.error}; 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.msg_input.drop_enabled = False self.chat_header.setText("Select a conversation") self.chat_header_avatar.setVisible(False) self._clear_message_area() 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) elif sb.maximum() > 0: self.jump_btn.setText("\u2193") self.jump_btn.setVisible(True) self._position_jump_btn() 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() self.jump_btn.move((w - 36) // 2, self.message_area.height() - 48) def _clear_status_bar(self): self.status_bar.setText("") t = c() self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.success}; 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 = 40 t = c() 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) # Online status text if uid in self._online_users: self._chat_header_status.setText("Online") self._chat_header_status.setStyleSheet(f"color: {t.success}; font-size: 8pt;") else: self._chat_header_status.setText("Offline") self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;") self._chat_header_status.setVisible(True) else: self.chat_header_avatar.setVisible(False) self._chat_header_status.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) # Member count for groups member_count = len(conv.get("members", [])) self._chat_header_status.setText(f"{member_count} members") self._chat_header_status.setStyleSheet(f"color: {t.text_muted}; font-size: 8pt;") self._chat_header_status.setVisible(True) # Show E2E indicator with verification status if is_dm: peer_uid = "" for m in conv["members"]: if m.get("email") != self.bridge.client.email: peer_uid = m.get("user_id") or m.get("id") or "" break v_status = self.bridge.client.get_verification_status(peer_uid) if peer_uid else "" if v_status == "verified": self._e2e_label.setText("\u2705 Verified") self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.success}; background: transparent;") self._e2e_label.setToolTip("Identity verified — click to view safety number") elif v_status == "trusted": self._e2e_label.setText("\U0001f512 Encrypted") self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;") self._e2e_label.setToolTip("End-to-end encrypted (not verified) — click to verify") else: self._e2e_label.setText("\U0001f512 Encrypted") self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;") self._e2e_label.setToolTip("End-to-end encrypted — click to verify") else: self._e2e_label.setText("\U0001f512 End-to-end encrypted") self._e2e_label.setStyleSheet(f"font-size: 8pt; color: {t.text_muted}; background: transparent;") self._e2e_label.setToolTip("End-to-end encrypted") self._e2e_label.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 cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: self._update_chat_header_avatar(cv) return def _update_conv_list_styles(self): """Update delegate data roles for all items (after avatar/unread changes).""" for i in range(self.conv_list.count()): item = self.conv_list.item(i) conv_id = item.data(ROLE_CONV_ID) count = self._unread_counts.get(conv_id, 0) conv = None for cv in self.conversations: if cv["conversation_id"] == conv_id: conv = cv break if conv: item.setData(ROLE_DISPLAY_NAME, self._get_conv_display_name(conv)) item.setData(ROLE_AVATAR, self._get_conv_avatar_pixmap(conv)) item.setData(ROLE_IS_FAV, conv_id in self._favorites) item.setData(ROLE_UNREAD, count) # Update preview/timestamp from cache preview_text, preview_ts, receipt_st = self._last_message_cache.get(conv_id, ("", "", "")) item.setData(ROLE_PREVIEW, preview_text) item.setData(ROLE_TIMESTAMP, _relative_time(preview_ts)) item.setData(ROLE_RECEIPT, receipt_st) 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"] self.msg_input.drop_enabled = True 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_widget.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.pin_list_btn.setVisible(True) self._pin_banner.setVisible(False) self._close_search() self.bridge.load_messages(self.current_conv_id) def _on_e2e_label_clicked(self, event): """Open VerificationDialog when E2E label is clicked (DMs only).""" if not self.current_conv_id or not getattr(self, "_is_dm", False): return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return peer_uid = self._get_conv_other_user_id(conv) if not peer_uid: return peer_name = "" for m in conv["members"]: if m.get("email") != self.bridge.client.email: peer_name = m.get("username") or m.get("email") or "?" break # Ensure identity key is in cache self.bridge.schedule(self._ensure_and_show_verification(peer_uid, peer_name)) async def _ensure_and_show_verification(self, peer_uid: str, peer_name: str): """Ensure we have the peer's identity key, then show verification dialog.""" await self.bridge.client._get_user_info(user_id=peer_uid) # Emit signal back to Qt thread self._show_verification_dialog_signal.emit(peer_uid, peer_name) def _show_verification_dialog(self, peer_uid: str, peer_name: str): dlg = VerificationDialog(self.bridge, peer_uid, peer_name, parent=self) dlg.exec() # Refresh conv list and header to reflect any verification changes self._rebuild_conv_list() if self.current_conv_id: for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: self._update_chat_header_avatar(cv) break def _on_key_change_warning(self, user_id: str, username: str, old_key_hex: str, was_verified: bool, new_key_bytes: bytes = b""): """Show warning dialog when a contact's identity key has changed.""" t = c() severity = "CRITICAL" if was_verified else "WARNING" name = username or user_id[:8] msg = ( f"The identity key for {name} has changed!\n\n" f"This could mean:\n" f"- They re-installed the app or got a new device\n" f"- Someone may be intercepting your messages\n\n" ) if was_verified: msg += "This contact was previously verified. You should re-verify." dlg = QDialog(self) dlg.setMinimumWidth(400) lay = _make_frameless(dlg, f"Identity Key Changed ({severity})") warning_label = QLabel(msg) warning_label.setWordWrap(True) warning_label.setStyleSheet(f"color: {t.text_primary};") lay.addWidget(warning_label) btn_row = QHBoxLayout() accept_btn = QPushButton("Accept New Key") accept_btn.setStyleSheet( f"QPushButton {{ background-color: {t.warning}; color: {t.bg_primary}; " f"font-weight: bold; padding: 8px 16px; border-radius: 6px; }}" ) accept_btn.clicked.connect(lambda: self._accept_key_change(user_id, new_key_bytes, dlg)) btn_row.addWidget(accept_btn) close_btn = QPushButton("Dismiss") close_btn.setObjectName("secondaryBtn") close_btn.clicked.connect(dlg.accept) btn_row.addWidget(close_btn) lay.addLayout(btn_row) dlg.exec() def _accept_key_change(self, user_id: str, new_key_bytes: bytes, dlg: QDialog): if new_key_bytes: self.bridge.client.accept_key_change(user_id, new_key_bytes) dlg.accept() self._rebuild_conv_list() def _on_messages_loaded(self, conv_id, messages): if conv_id != self.current_conv_id: return self.current_messages = messages # Update last-message cache for conversation list preview if messages: last = messages[-1] preview = last.get("text", "") if last.get("deleted"): preview = "Message deleted" elif last.get("image") and not preview: preview = "Sent an image" elif last.get("file") and not preview: preview = "Sent a file" receipt = self._compute_receipt_status(last) self._last_message_cache[conv_id] = (preview[:60], last.get("created_at", ""), receipt) self._update_conv_list_styles() # 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() self._update_pin_banner() def _compute_receipt_status(self, m) -> str: """Return receipt status for a message: 'read', 'delivered', 'sent', or ''.""" my_uid = (self.bridge.client.session.get("user_id", "") if self.bridge and self.bridge.client and self.bridge.client.session else "") if not my_uid or m.get("sender_id") != my_uid: return "" read_by = m.get("read_by", []) if any(r.get("user_id") != my_uid for r in read_by): return "read" delivered_to = m.get("delivered_to", []) if any(d.get("user_id") != my_uid for d in delivered_to): return "delivered" return "sent" def _render_messages(self, scroll_to_bottom=True): """Clear and rebuild all message bubble widgets.""" self._msg_widgets = [] while self._msg_layout.count() > 0: item = self._msg_layout.takeAt(0) w = item.widget() if w: w.deleteLater() for i, m in enumerate(self.current_messages): w = self._create_message_widget(m, i) self._msg_layout.addWidget(w) self._msg_widgets.append(w) if scroll_to_bottom: QTimer.singleShot(10, self._scroll_to_bottom) def _clear_message_area(self): """Remove all message widgets from the scroll area.""" self._msg_widgets = [] self.current_messages = [] while self._msg_layout.count() > 0: item = self._msg_layout.takeAt(0) w = item.widget() if w: w.deleteLater() def _decode_thumbnail(self, image_info): """Decode base64 thumbnail to QPixmap.""" thumbnail_b64 = image_info.get("thumbnail", "") if not thumbnail_b64: return None from protocol import decode_binary try: thumb_bytes = decode_binary(thumbnail_b64) qimg = _safe_load_image(thumb_bytes) if qimg is not None: return QPixmap.fromImage(qimg) except Exception: pass return None def _create_message_widget(self, m, index): """Create a widget tree for a single message bubble.""" t = c() my_uid = (self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "") is_me = (m.get("sender_id") == my_uid if my_uid else m.get("sender") == self.bridge.client.username) # -- Wrapper for left/right alignment -- wrapper = QWidget() wrapper.setStyleSheet("background: transparent;") wrapper._msg_index = index wlay = QHBoxLayout(wrapper) wlay.setContentsMargins(12, 2, 12, 2) wlay.setSpacing(0) # -- Deleted message -- if m.get("deleted"): ts = m.get("created_at", "") time_str = _format_msg_time(ts) del_bubble = MessageBubble(t.bg_secondary) del_bubble._msg_index = index dlay = QVBoxLayout(del_bubble) dlay.setContentsMargins(14, 8, 14, 8) dl = QLabel(f"\u00b7 {time_str} Message deleted") dl.setStyleSheet( f"color: {t.text_muted}; font-style: italic; " f"font-size: 10pt; background: transparent;" ) dlay.addWidget(dl) if is_me: wlay.addStretch(1) wlay.addWidget(del_bubble) if not is_me: wlay.addStretch(1) return wrapper sender = m.get("sender", "???") text = m.get("text", "") # Determine colours if is_me: bubble_bg = t.bubble_sent_bg text_color = t.bubble_sent_text meta_color = t.bubble_sent_meta sender_color = t.accent else: bubble_bg = t.bubble_recv_bg text_color = t.bubble_recv_text meta_color = t.bubble_recv_meta sender_color = t.sender_name_other if is_me: wlay.addStretch(1) # -- Bubble -- bubble = MessageBubble(bubble_bg) bubble._msg_index = index bubble.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum ) bubble.setMaximumWidth(600) bubble.setMinimumWidth(80) blay = QVBoxLayout(bubble) blay.setContentsMargins(14, 8, 14, 8) blay.setSpacing(3) # -- Forwarded header -- fwd = m.get("forwarded_from") if fwd: fwd_sender = fwd.get("sender", "???") fwd_esc = (fwd_sender.replace("&", "&") .replace("<", "<").replace(">", ">")) fwd_label = QLabel( f'' f'Forwarded from {fwd_esc}' f'' ) fwd_label.setTextFormat(Qt.TextFormat.RichText) fwd_label.setStyleSheet( f"background: transparent; border-left: 2px solid {t.info}; " f"padding-left: 6px; margin-bottom: 2px;" ) blay.addWidget(fwd_label) # -- Reply context -- if m.get("reply_to"): for orig in self.current_messages: if orig.get("message_id") == m.get("reply_to"): orig_sender = orig.get("sender", "???") orig_esc = (orig_sender.replace("&", "&") .replace("<", "<").replace(">", ">")) orig_text = orig.get("text", "")[:50] orig_text_esc = (orig_text.replace("&", "&") .replace("<", "<").replace(">", ">")) reply_lbl = QLabel( f'{orig_esc}
' f'' f'{orig_text_esc}' ) reply_lbl.setTextFormat(Qt.TextFormat.RichText) reply_lbl.setWordWrap(True) reply_lbl.setStyleSheet( f"background: transparent; " f"border-left: 2px solid {t.scrollbar}; " f"padding-left: 6px; margin-bottom: 2px;" ) blay.addWidget(reply_lbl) break # -- Header (sender name + pin — groups only) -- timestamp = m.get("created_at", "") sender_esc = (sender.replace("&", "&") .replace("<", "<").replace(">", ">")) pin_html = "" if m.get("pinned_at"): pin_html = f' \U0001f4cc' is_dm = self._is_dm if not is_dm: header_html = ( f'{sender_esc}' f'{pin_html}' ) header_label = QLabel(header_html) header_label.setTextFormat(Qt.TextFormat.RichText) header_label.setStyleSheet("background: transparent;") blay.addWidget(header_label) elif pin_html: pin_label = QLabel(pin_html) pin_label.setTextFormat(Qt.TextFormat.RichText) pin_label.setStyleSheet("background: transparent;") blay.addWidget(pin_label) # -- Suppress image placeholder text -- image_info = m.get("image") if image_info: fname_raw = image_info.get("filename", "image") if text == f"[Image: {fname_raw}]": text = "" # -- Message text -- if text: text_html = _linkify_urls(text) text_html = text_html.replace("\n", "
") # Search highlighting if (self._search_active and self._search_query and index in self._search_results): is_current = ( 0 <= self._search_current < len(self._search_results) and self._search_results[self._search_current] == index ) bg = t.search_current if is_current else t.search_highlight text_html = self._highlight_search_text( text_html, self._search_query, bg ) # @Mentions highlighting import re as _re mention_c = t.mention text_html = _re.sub( r'@(\w+)', lambda mt: ( f'' f'@{mt.group(1)}' ), text_html, ) text_label = QLabel(text_html) text_label.setTextFormat(Qt.TextFormat.RichText) text_label.setWordWrap(True) text_label.setStyleSheet( f"color: {text_color}; background: transparent; " f"font-size: 11pt;" ) text_label.setTextInteractionFlags( Qt.TextInteractionFlag.LinksAccessibleByMouse ) text_label.linkActivated.connect(self._on_link_clicked) blay.addWidget(text_label) # -- Image thumbnail -- if image_info: thumb_pixmap = self._decode_thumbnail(image_info) file_id = image_info.get("file_id", "") filename = image_info.get("filename", "image") size_bytes = image_info.get("size", 0) size_str = self._human_file_size(size_bytes) if thumb_pixmap: img_label = QLabel() scaled = thumb_pixmap.scaledToWidth( min(200, thumb_pixmap.width()), Qt.TransformationMode.SmoothTransformation, ) img_label.setPixmap(scaled) img_label.setStyleSheet("background: transparent;") img_label.setCursor(Qt.CursorShape.PointingHandCursor) img_label.mousePressEvent = ( lambda e, fid=file_id: self._on_image_click(fid) ) blay.addWidget(img_label) link_color = text_color if is_me else t.accent fname_esc = (filename.replace("&", "&") .replace("<", "<").replace(">", ">")) info_label = QLabel( f'' f'{fname_esc} ({size_str}) \u2014 Click to view' ) info_label.setTextFormat(Qt.TextFormat.RichText) info_label.setStyleSheet( f"font-size: 9pt; background: transparent;" ) info_label.setTextInteractionFlags( Qt.TextInteractionFlag.LinksAccessibleByMouse ) info_label.linkActivated.connect(self._on_link_clicked) blay.addWidget(info_label) # -- File card -- file_info = m.get("file") if file_info: fname = file_info.get("filename", "file") fname_esc = (fname.replace("&", "&") .replace("<", "<").replace(">", ">")) fsize = file_info.get("size", 0) size_str = self._human_file_size(fsize) f_id = file_info.get("file_id", "") icon = self._file_icon(fname) file_link_color = text_color if is_me else t.accent file_frame = QFrame() file_frame.setStyleSheet( f"QFrame {{ background: transparent; " f"border: 1px solid {meta_color}; " f"border-radius: 6px; padding: 8px; }}" ) flay = QHBoxLayout(file_frame) flay.setContentsMargins(0, 0, 0, 0) file_label = QLabel( f'{icon} {fname_esc}' f' ({size_str})' ) file_label.setTextFormat(Qt.TextFormat.RichText) file_label.setStyleSheet("background: transparent; border: none;") file_label.setTextInteractionFlags( Qt.TextInteractionFlag.LinksAccessibleByMouse ) file_label.linkActivated.connect(self._on_link_clicked) flay.addWidget(file_label) blay.addWidget(file_frame) # -- Reaction badges -- reactions = m.get("reactions", []) if reactions: _REACTION_EMOJI = { "thumbsup": "\U0001f44d", "heart": "\u2764\ufe0f", "laugh": "\U0001f602", "surprised": "\U0001f62e", "sad": "\U0001f622", "thumbsdown": "\U0001f44e", } grouped = {} for r in reactions: grouped.setdefault(r["reaction"], []).append(r["user_id"]) my_id = (self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "") react_widget = QWidget() react_widget.setStyleSheet("background: transparent;") react_lay = QHBoxLayout(react_widget) react_lay.setContentsMargins(0, 2, 0, 0) react_lay.setSpacing(4) for rkey, uids in grouped.items(): emoji = _REACTION_EMOJI.get(rkey, rkey) count = len(uids) is_mine = my_id in uids bg = t.reaction_bg_own if is_mine else t.reaction_bg bdr = t.reaction_border_own if is_mine else t.reaction_border badge = QLabel(f"{emoji} {count}") badge.setStyleSheet( f"background-color: {bg}; color: {t.text_primary}; " f"border: 1px solid {bdr}; " f"border-radius: 10px; padding: 2px 6px; font-size: 10pt;" ) react_lay.addWidget(badge) react_lay.addStretch() blay.addWidget(react_widget) # -- Footer (timestamp + receipt, right-aligned, below content) -- time_str = _format_msg_time(timestamp) receipt_status = "" if is_me: read_by = m.get("read_by", []) others_read = [r for r in read_by if r.get("user_id") != my_uid] delivered_to = m.get("delivered_to", []) others_delivered = [d for d in delivered_to if d.get("user_id") != my_uid] if others_read: receipt_status = "read" elif others_delivered: receipt_status = "delivered" else: receipt_status = "sent" footer_w = _ReceiptFooter(time_str, receipt_status, meta_color, t.bubble_sent_text, t.success) footer_w.setStyleSheet("background: transparent;") blay.addWidget(footer_w, alignment=Qt.AlignmentFlag.AlignRight) wlay.addWidget(bubble) if not is_me: wlay.addStretch(1) # Install event filter on all child widgets for context menu propagation for child in bubble.findChildren(QWidget): child.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) child.installEventFilter(self) bubble.installEventFilter(self) wrapper.installEventFilter(self) return wrapper def _on_image_click(self, file_id): """Handle click on an image thumbnail in a message bubble.""" for msg in self.current_messages: image_info = msg.get("image") if image_info and image_info.get("file_id") == file_id: self._view_image(msg) return def _find_msg_index_at_widget(self, widget): """Walk up the widget tree to find the _msg_index attribute.""" while widget: if hasattr(widget, '_msg_index'): return widget._msg_index widget = widget.parentWidget() return None 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 _on_message_context_menu(self, pos): if not self.current_messages: return global_pos = self._msg_scroll_area.viewport().mapToGlobal(pos) widget = QApplication.widgetAt(global_pos) idx = self._find_msg_index_at_widget(widget) if idx is not None: self._show_msg_context_menu(idx, global_pos) def _show_msg_context_menu(self, idx, global_pos): if idx < 0 or idx >= len(self.current_messages): return m = self.current_messages[idx] if m.get("deleted"): return my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" menu = QMenu(self) reply_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack) reply_action = menu.addAction(reply_icon, "Reply") # Reaction submenu react_menu = menu.addMenu("React") _REACTION_LABELS = { "thumbsup": "\U0001f44d +1", "heart": "\u2764\ufe0f Heart", "laugh": "\U0001f602 Haha", "surprised": "\U0001f62e Wow", "sad": "\U0001f622 Sad", "thumbsdown": "\U0001f44e -1", } react_actions = {} for rkey, rlabel in _REACTION_LABELS.items(): act = react_menu.addAction(rlabel) react_actions[act] = rkey # Forward fwd_action = menu.addAction("Forward") # Pin / Unpin pin_action = None if m.get("pinned_at"): pin_action = menu.addAction("\U0001f4cc Unpin") else: pin_action = menu.addAction("\U0001f4cc Pin") menu.addSeparator() # Delete option for own messages del_action = None if m.get("sender_id") == my_user_id: del_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon) del_action = menu.addAction(del_icon, "Delete") # View image option img_action = None if m.get("image"): img_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogContentsView) img_action = menu.addAction(img_icon, "View image") # Download file option file_action = None if m.get("file"): file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton) file_action = menu.addAction(file_icon, "Download file") # Reset session option for undecryptable messages reset_action = None if m.get("text", "").startswith("[Decryption failed"): reset_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) reset_action = menu.addAction(reset_icon, "Reset session with sender") chosen = menu.exec(global_pos) if not chosen: return if chosen == reply_action: self.reply_to_id = m["message_id"] sender = m.get("sender", "???") preview = m.get("text", "")[:40] self.reply_label.setText(f"Replying to {sender}: {preview}") self._reply_widget.setVisible(True) self.msg_input.setFocus() elif chosen in react_actions: rkey = react_actions[chosen] existing = m.get("reactions", []) has_it = any(r["user_id"] == my_user_id and r["reaction"] == rkey for r in existing) if has_it: m["reactions"] = [r for r in existing if r["user_id"] != my_user_id] self.bridge.react_message(m["message_id"], rkey, "remove") else: new_reactions = [r for r in existing if r["user_id"] != my_user_id] new_reactions.append({"user_id": my_user_id, "reaction": rkey, "created_at": ""}) m["reactions"] = new_reactions self.bridge.react_message(m["message_id"], rkey, "add") # Persist to local cache so reactions survive conversation switch self.bridge.client.update_message_in_cache( self.current_conv_id, m["message_id"], {"reactions": m["reactions"]}) self._render_messages(scroll_to_bottom=self._is_near_bottom) elif chosen == fwd_action: self._show_forward_dialog(m) elif chosen == pin_action: if m.get("pinned_at"): m.pop("pinned_at", None) m.pop("pinned_by", None) self.bridge.pin_message(m["message_id"], self.current_conv_id, "unpin") self.bridge.client.update_message_in_cache( self.current_conv_id, m["message_id"], {"pinned_at": None, "pinned_by": None}) else: my_user_id_pin = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" m["pinned_at"] = "now" m["pinned_by"] = my_user_id_pin self.bridge.pin_message(m["message_id"], self.current_conv_id, "pin") self.bridge.client.update_message_in_cache( self.current_conv_id, m["message_id"], {"pinned_at": "now", "pinned_by": my_user_id_pin}) self._render_messages(scroll_to_bottom=self._is_near_bottom) self._update_pin_banner() elif chosen == del_action: if self._confirm_dialog("Delete Message", "Delete this message? This cannot be undone."): # Apply locally immediately (server notification only goes to others) m["deleted"] = True m["text"] = "" m["image"] = None m["file"] = None self.bridge.client.update_message_in_cache( self.current_conv_id, m["message_id"], {"deleted": True}) self._render_messages(scroll_to_bottom=self._is_near_bottom) self.bridge.delete_message(m["message_id"]) elif chosen == img_action: self._view_image(m) elif chosen == file_action: file_info = m.get("file") if file_info: self.bridge.download_file(file_info["file_id"], file_info) elif chosen == reset_action: sender_id = m.get("sender_id", "") if sender_id: if self._confirm_dialog("Reset Session", "Reset encryption session with this sender? " "A new session will be created on the next message."): self.bridge.reset_session(sender_id) # ------------------------------------------------------------------ # Search # ------------------------------------------------------------------ def _toggle_search(self): if self._search_active: self._close_search() else: if not self.current_conv_id: return self._search_active = True self.search_widget.setVisible(True) self.search_input.setFocus() self.search_input.selectAll() def _close_search(self): self._search_active = False self._search_query = "" self._search_results = [] self._search_current = -1 self.search_widget.setVisible(False) self.search_input.clear() self.search_count_label.setText("0/0") # Re-render to remove highlights if self.current_messages: self._render_messages(scroll_to_bottom=False) def _on_search_text_changed(self, text): self._search_query = text.strip() if not self._search_query: self._search_results = [] self._search_current = -1 self.search_count_label.setText("0/0") self._render_messages(scroll_to_bottom=False) return query_lower = self._search_query.lower() self._search_results = [] for i, m in enumerate(self.current_messages): if m.get("deleted"): continue msg_text = m.get("text", "") if query_lower in msg_text.lower(): self._search_results.append(i) if self._search_results: self._search_current = 0 self.search_count_label.setText(f"1/{len(self._search_results)}") else: self._search_current = -1 self.search_count_label.setText("0/0") self._render_messages(scroll_to_bottom=False) if self._search_results: self._scroll_to_message(self._search_results[self._search_current]) def _on_search_next(self): if not self._search_results: return self._search_current = (self._search_current + 1) % len(self._search_results) self.search_count_label.setText(f"{self._search_current + 1}/{len(self._search_results)}") self._render_messages(scroll_to_bottom=False) self._scroll_to_message(self._search_results[self._search_current]) def _on_search_prev(self): if not self._search_results: return self._search_current = (self._search_current - 1) % len(self._search_results) self.search_count_label.setText(f"{self._search_current + 1}/{len(self._search_results)}") self._render_messages(scroll_to_bottom=False) self._scroll_to_message(self._search_results[self._search_current]) def _scroll_to_message(self, index): if 0 <= index < len(self._msg_widgets): self._msg_scroll_area.ensureWidgetVisible( self._msg_widgets[index], 0, 50 ) @staticmethod def _highlight_search_text(html_text: str, query: str, bg_color: str) -> str: """Highlight matching text in HTML, skipping content inside tags.""" query_esc = query.replace("&", "&").replace("<", "<").replace(">", ">") if not query_esc: return html_text result = [] i = 0 q_lower = query_esc.lower() q_len = len(query_esc) while i < len(html_text): if html_text[i] == '<': # Skip HTML tags end = html_text.find('>', i) if end == -1: result.append(html_text[i:]) break result.append(html_text[i:end + 1]) i = end + 1 else: # Look for match in text content chunk_end = html_text.find('<', i) if chunk_end == -1: chunk_end = len(html_text) chunk = html_text[i:chunk_end] # Case-insensitive replace within this chunk chunk_lower = chunk.lower() out = [] j = 0 while j < len(chunk): pos = chunk_lower.find(q_lower, j) if pos == -1: out.append(chunk[j:]) break out.append(chunk[j:pos]) matched = chunk[pos:pos + q_len] out.append(f'{matched}') j = pos + q_len result.append("".join(out)) i = chunk_end return "".join(result) # ------------------------------------------------------------------ # Session reset # ------------------------------------------------------------------ def _on_session_reset(self, from_user_id, from_device_id): # Find username for the user username = from_user_id[:8] for cv in self.conversations: for m in cv.get("members", []): uid = m.get("user_id") or m.get("id") if uid == from_user_id: username = m.get("username") or m.get("email") or username break self.status_bar.setText(f"Session with {username} was reset. New session will be created on next message.") t = c() self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.warning}; font-size: 8pt; font-weight: bold;" ) QTimer.singleShot(8000, self._clear_status_bar) # ------------------------------------------------------------------ # Reactions / Pins / Forward notification handlers # ------------------------------------------------------------------ def _on_reaction_notification(self, data): """Handle message_reacted push notification.""" conv_id = data.get("conversation_id", "") msg_id = data.get("message_id", "") user_id = data.get("user_id", "") reaction = data.get("reaction", "") action = data.get("action", "add") if conv_id != self.current_conv_id: return for msg in self.current_messages: if msg.get("message_id") == msg_id: reactions = msg.get("reactions", []) if action == "add": reactions = [r for r in reactions if r["user_id"] != user_id] reactions.append({"user_id": user_id, "reaction": reaction, "created_at": ""}) else: reactions = [r for r in reactions if r["user_id"] != user_id] msg["reactions"] = reactions self.bridge.client.update_message_in_cache( conv_id, msg_id, {"reactions": reactions}) break self._render_messages(scroll_to_bottom=self._is_near_bottom) def _on_pin_notification(self, data): """Handle message_pinned push notification.""" conv_id = data.get("conversation_id", "") msg_id = data.get("message_id", "") user_id = data.get("user_id", "") if conv_id != self.current_conv_id: return for msg in self.current_messages: if msg.get("message_id") == msg_id: msg["pinned_at"] = "now" msg["pinned_by"] = user_id self.bridge.client.update_message_in_cache( conv_id, msg_id, {"pinned_at": "now", "pinned_by": user_id}) break self._render_messages(scroll_to_bottom=self._is_near_bottom) self._update_pin_banner() username = data.get("username", user_id[:8] if user_id else "?") self.status_bar.setText(f"{username} pinned a message") QTimer.singleShot(3000, self._clear_status_bar) def _on_unpin_notification(self, data): """Handle message_unpinned push notification.""" conv_id = data.get("conversation_id", "") msg_id = data.get("message_id", "") if conv_id != self.current_conv_id: return for msg in self.current_messages: if msg.get("message_id") == msg_id: msg.pop("pinned_at", None) msg.pop("pinned_by", None) self.bridge.client.update_message_in_cache( conv_id, msg_id, {"pinned_at": None, "pinned_by": None}) break self._render_messages(scroll_to_bottom=self._is_near_bottom) self._update_pin_banner() def _on_pinned_messages_loaded(self, conv_id, pinned): """Show pinned messages dialog.""" if conv_id != self.current_conv_id: return if not pinned: dlg = QDialog(self) dlg.setMinimumWidth(300) lay = _make_frameless(dlg, "Pinned Messages") t = c() lbl = QLabel("No pinned messages in this conversation.") lbl.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;") lbl.setWordWrap(True) lay.addWidget(lbl) ok_btn = QPushButton("OK") ok_btn.clicked.connect(dlg.accept) lay.addWidget(ok_btn) dlg.exec() return dlg = QDialog(self) dlg.setMinimumSize(360, 300) t = c() layout = _make_frameless(dlg, "Pinned Messages") lw = QListWidget() lw.setStyleSheet( f"QListWidget {{ background-color:{t.bg_secondary}; border:1px solid {t.border}; border-radius:6px; }}" f"QListWidget::item {{ padding:8px; color:{t.text_primary}; border-bottom:1px solid {t.border}; }}" f"QListWidget::item:selected {{ background-color:{t.bg_hover}; }}" ) # Build a sender map from current messages sender_map = {} for m in self.current_messages: sid = m.get("sender_id", "") if sid and sid not in sender_map: sender_map[sid] = m.get("sender", sid[:8]) for p in pinned: sender = sender_map.get(p.get("sender_id", ""), p.get("sender_id", "?")[:8]) # Find message text from current_messages text_preview = "" for m in self.current_messages: if m.get("message_id") == p.get("message_id"): text_preview = m.get("text", "")[:60] if not sender_map.get(p.get("sender_id", "")): sender = m.get("sender", sender) break ts = p.get("pinned_at", "")[:16] if p.get("pinned_at") else "" item = QListWidgetItem(f"\U0001f4cc {sender}: {text_preview}\n Pinned: {ts}") item.setData(Qt.ItemDataRole.UserRole, p.get("message_id")) lw.addItem(item) layout.addWidget(lw) close_btn = QPushButton("Close") close_btn.setObjectName("secondaryBtn") close_btn.clicked.connect(dlg.accept) layout.addWidget(close_btn) def _on_item_clicked(item): target_id = item.data(Qt.ItemDataRole.UserRole) if target_id: for i, msg in enumerate(self.current_messages): if msg.get("message_id") == target_id: self._scroll_to_message(i) break dlg.accept() lw.itemDoubleClicked.connect(_on_item_clicked) dlg.exec() def _update_pin_banner(self): """Update the pinned message banner from current_messages.""" pinned = [m for m in self.current_messages if m.get("pinned_at")] if not pinned: self._pin_banner.setVisible(False) self._pin_banner_msg_id = None return # Show the most recently pinned message latest = pinned[-1] sender = latest.get("sender", "?") text = latest.get("text", "") if len(text) > 80: text = text[:80] + "..." text = text.replace("\n", " ") # HTML-escape user-controlled data to prevent injection sender_esc = sender.replace("&", "&").replace("<", "<").replace(">", ">") text_esc = text.replace("&", "&").replace("<", "<").replace(">", ">") count_str = f" ({len(pinned)} pinned)" if len(pinned) > 1 else "" self._pin_banner_label.setText(f"{sender_esc}: {text_esc}{count_str}") self._pin_banner_msg_id = latest.get("message_id") self._pin_banner.setVisible(True) def _on_pin_banner_clicked(self, event): """Scroll to the pinned message when banner is clicked.""" if self._pin_banner_msg_id: for i, msg in enumerate(self.current_messages): if msg.get("message_id") == self._pin_banner_msg_id: self._scroll_to_message(i) break def _show_pinned_messages(self): """Fetch and show pinned messages for current conversation.""" if self.current_conv_id: self.bridge.get_pinned_messages(self.current_conv_id) def _on_forward_result(self, ok, msg): if ok: self.status_bar.setText("Message forwarded") QTimer.singleShot(3000, self._clear_status_bar) else: self.status_bar.setText(f"Forward failed: {msg}") QTimer.singleShot(5000, self._clear_status_bar) def _confirm_dialog(self, title: str, text: str) -> bool: """Show a frameless confirmation dialog. Returns True if user confirmed.""" dlg = QDialog(self) dlg.setMinimumWidth(340) layout = _make_frameless(dlg, title) t = c() label = QLabel(text) label.setWordWrap(True) label.setStyleSheet(f"color: {t.text_primary}; font-size: 10pt;") layout.addWidget(label) btn_lay = QHBoxLayout() btn_lay.setSpacing(8) cancel_btn = QPushButton("Cancel") cancel_btn.setObjectName("secondaryBtn") confirm_btn = QPushButton("Delete") confirm_btn.setStyleSheet( f"QPushButton {{ background-color: {t.error}; color: {t.accent_text}; " f"border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; }}" f"QPushButton:hover {{ background-color: {t.warning}; }}" ) btn_lay.addStretch() btn_lay.addWidget(cancel_btn) btn_lay.addWidget(confirm_btn) layout.addLayout(btn_lay) cancel_btn.clicked.connect(dlg.reject) confirm_btn.clicked.connect(dlg.accept) return dlg.exec() == QDialog.DialogCode.Accepted def _show_forward_dialog(self, msg): """Show dialog to pick a conversation to forward a message to.""" dlg = QDialog(self) dlg.setMinimumSize(320, 360) t = c() layout = _make_frameless(dlg, "Forward message") label = QLabel("Select conversation:") label.setStyleSheet(f"color:{t.text_primary}; font-size:10pt;") layout.addWidget(label) conv_list = QListWidget() conv_list.setStyleSheet( f"QListWidget {{ background-color:{t.bg_secondary}; border:1px solid {t.border}; border-radius:6px; }}" f"QListWidget::item {{ padding:8px; color:{t.text_primary}; }}" f"QListWidget::item:selected {{ background-color:{t.bg_hover}; }}" ) for cv in self.conversations: if cv["conversation_id"] != self.current_conv_id: name = cv.get("name") if not name: others = [m.get("username") or m.get("email") or "?" for m in cv["members"] if m.get("email") != self.bridge.client.email] name = ", ".join(others) if others else "?" item = QListWidgetItem(name) item.setData(Qt.ItemDataRole.UserRole, cv) conv_list.addItem(item) layout.addWidget(conv_list) fwd_btn = QPushButton("Forward") fwd_btn.setObjectName("secondaryBtn") layout.addWidget(fwd_btn) def _do_forward(): sel = conv_list.currentItem() if not sel: return target_conv = sel.data(Qt.ItemDataRole.UserRole) if target_conv: fwd_msg = dict(msg) fwd_msg["conversation_id"] = self.current_conv_id self.bridge.forward_message( target_conv["conversation_id"], fwd_msg, target_conv["members"] ) dlg.accept() fwd_btn.clicked.connect(_do_forward) conv_list.itemDoubleClicked.connect(lambda _: _do_forward()) dlg.exec() # ------------------------------------------------------------------ # @Mentions autocomplete # ------------------------------------------------------------------ def _setup_mention_completer(self): """Set up the mention autocomplete popup for msg_input.""" self._mention_popup = QListWidget(self) self._mention_popup.setWindowFlags(Qt.WindowType.Popup) t = c() self._mention_popup.setStyleSheet( f"QListWidget {{ background:{t.bg_secondary}; color:{t.text_primary}; border:1px solid {t.border}; " f"border-radius:4px; font-size:10pt; }}" f"QListWidget::item {{ padding:4px 8px; }}" f"QListWidget::item:selected {{ background:{t.bg_hover}; }}" ) self._mention_popup.setMaximumHeight(150) self._mention_popup.itemClicked.connect(self._on_mention_selected) self._mention_popup.hide() self._mention_active = False def _check_mention_trigger(self): """Check if user is typing @mention and show autocomplete.""" if not hasattr(self, '_mention_popup'): self._setup_mention_completer() text = self.msg_input.toPlainText() cursor_pos = self.msg_input.textCursor().position() # Find @word at cursor position before = text[:cursor_pos] import re as _re match = _re.search(r'@(\w*)$', before) if not match: self._mention_popup.hide() self._mention_active = False return prefix = match.group(1).lower() self._mention_start = match.start() # Get members of current conversation members = [] for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: members = cv.get("members", []) break my_email = self.bridge.client.email if self.bridge.client else "" candidates = [] for m in members: uname = m.get("username") or m.get("email") or "" if m.get("email") == my_email: continue if prefix == "" or uname.lower().startswith(prefix): candidates.append(uname) if not candidates: self._mention_popup.hide() self._mention_active = False return self._mention_popup.clear() for cand in candidates[:6]: self._mention_popup.addItem(cand) # Position popup above the input cursor_rect = self.msg_input.cursorRect() global_pos = self.msg_input.mapToGlobal(cursor_rect.bottomLeft()) self._mention_popup.move(global_pos.x(), global_pos.y() - self._mention_popup.sizeHint().height() - 5) self._mention_popup.setFixedWidth(max(200, self.msg_input.width() // 2)) self._mention_popup.show() self._mention_active = True def _on_mention_selected(self, item): """Insert the selected @mention into msg_input.""" username = item.text() text = self.msg_input.toPlainText() cursor_pos = self.msg_input.textCursor().position() # Replace @prefix with @username new_text = text[:self._mention_start] + f"@{username} " + text[cursor_pos:] self.msg_input.setPlainText(new_text) cursor = self.msg_input.textCursor() cursor.setPosition(self._mention_start + len(username) + 2) self.msg_input.setTextCursor(cursor) self._mention_popup.hide() self._mention_active = False self.msg_input.setFocus() def _on_input_changed(self): text = self.msg_input.toPlainText() count = len(text) if count > MAX_INPUT_CHARS: cursor = self.msg_input.textCursor() self.msg_input.setPlainText(text[:MAX_INPUT_CHARS]) cursor.movePosition(cursor.MoveOperation.End) self.msg_input.setTextCursor(cursor) count = MAX_INPUT_CHARS color = c().error if count > MAX_INPUT_CHARS * 0.9 else c().text_muted self.char_counter.setStyleSheet(f"color: {color}; font-size: 8pt; padding: 0 4px;") self.char_counter.setText(f"{count} / {MAX_INPUT_CHARS}") # @Mention autocomplete check if self.current_conv_id: self._check_mention_trigger() def _cancel_reply(self): """Dismiss the reply preview.""" self.reply_to_id = None self.reply_label.setText("") self._reply_widget.setVisible(False) def _on_send(self): text = self.msg_input.toPlainText().strip() if not text or not self.current_conv_id: return if len(text) > MAX_INPUT_CHARS: QMessageBox.warning(self, "Message Too Long", f"Message too long (max {MAX_INPUT_CHARS} characters).") return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return self.msg_input.clear() self.bridge.send_message( self.current_conv_id, text, conv["members"], reply_to=self.reply_to_id, ) self.reply_to_id = None self._reply_widget.setVisible(False) def _on_message_sent(self, ok, msg): if not ok: QMessageBox.warning(self, "Error", msg) def _on_message_sent_payload(self, conv_id, payload): """Append the just-sent message locally without re-fetching from server.""" # Update last-message cache preview = payload.get("text", "") if payload.get("image") and not preview: preview = "Sent an image" elif payload.get("file") and not preview: preview = "Sent a file" self._last_message_cache[conv_id] = (preview[:60], payload.get("created_at", ""), "sent") self._update_conv_list_styles() if conv_id != self.current_conv_id: return # Avoid duplicate if notification arrived first (race) msg_id = payload.get("message_id", "") if msg_id: for m in self.current_messages: if m.get("message_id") == msg_id: return self.current_messages.append(payload) idx = len(self.current_messages) - 1 w = self._create_message_widget(payload, idx) self._msg_layout.addWidget(w) self._msg_widgets.append(w) if self._is_near_bottom: QTimer.singleShot(10, self._scroll_to_bottom) def _on_new_chat(self): dlg = QDialog(self) dlg.setMinimumWidth(400) t = c() lay = _make_frameless(dlg, "New Chat") lay.setSpacing(12) email_label = QLabel("Recipient email") email_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(email_label) email_input = QLineEdit() email_input.setPlaceholderText("user@example.com") email_input.setMinimumHeight(36) lay.addWidget(email_input) msg_label = QLabel("First message") msg_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(msg_label) msg_input = QLineEdit() msg_input.setPlaceholderText("Type a message...") msg_input.setMinimumHeight(36) lay.addWidget(msg_input) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Cancel") cancel_btn.setObjectName("secondaryBtn") cancel_btn.clicked.connect(dlg.reject) btn_row.addWidget(cancel_btn) send_btn = QPushButton("Send") send_btn.clicked.connect(dlg.accept) btn_row.addWidget(send_btn) lay.addLayout(btn_row) msg_input.returnPressed.connect(dlg.accept) email_input.returnPressed.connect(lambda: msg_input.setFocus()) if dlg.exec() != QDialog.DialogCode.Accepted: return email = email_input.text().strip() text = msg_input.text().strip() if not email or not text: return if len(text) > MAX_INPUT_CHARS: QMessageBox.warning(self, "Message Too Long", f"Message too long (max {MAX_INPUT_CHARS} characters).") return self.bridge.send_new_chat(email, text) def _on_new_group(self): dlg = QDialog(self) dlg.setMinimumWidth(400) t = c() lay = _make_frameless(dlg, "New Group") lay.setSpacing(12) name_label = QLabel("Group name") name_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(name_label) name_input = QLineEdit() name_input.setPlaceholderText("My Group") name_input.setMinimumHeight(36) lay.addWidget(name_input) members_label = QLabel("Member emails (comma-separated)") members_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(members_label) members_input = QLineEdit() members_input.setPlaceholderText("alice@example.com, bob@example.com") members_input.setMinimumHeight(36) lay.addWidget(members_input) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Cancel") cancel_btn.setObjectName("secondaryBtn") cancel_btn.clicked.connect(dlg.reject) btn_row.addWidget(cancel_btn) create_btn = QPushButton("Create") create_btn.clicked.connect(dlg.accept) btn_row.addWidget(create_btn) lay.addLayout(btn_row) members_input.returnPressed.connect(dlg.accept) name_input.returnPressed.connect(lambda: members_input.setFocus()) if dlg.exec() != QDialog.DialogCode.Accepted: return members = members_input.text().strip() if not members: return member_list = [m.strip() for m in members.split(",") if m.strip()] if member_list: self.bridge.create_group(member_list, name=name_input.text().strip() or None) def _on_add_member(self): if not self.current_conv_id: return dlg = QDialog(self) dlg.setMinimumWidth(360) t = c() lay = _make_frameless(dlg, "Add Member") lay.setSpacing(12) lbl = QLabel("Email to invite") lbl.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(lbl) email_input = QLineEdit() email_input.setPlaceholderText("user@example.com") email_input.setMinimumHeight(36) email_input.returnPressed.connect(dlg.accept) lay.addWidget(email_input) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Cancel") cancel_btn.setObjectName("secondaryBtn") cancel_btn.clicked.connect(dlg.reject) btn_row.addWidget(cancel_btn) add_btn = QPushButton("Add") add_btn.clicked.connect(dlg.accept) btn_row.addWidget(add_btn) lay.addLayout(btn_row) if dlg.exec() != QDialog.DialogCode.Accepted: return email = email_input.text().strip() if not email: return self.bridge.add_member(self.current_conv_id, email) def _on_add_member_result(self, ok, msg): if ok: QMessageBox.information(self, "Add Member", "Invitation sent.") else: QMessageBox.warning(self, "Add Member", msg) def _on_group_info(self): if not self.current_conv_id: return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" is_creator = conv.get("created_by") == my_user_id group_name = conv.get("name") or "Group" members = conv["members"] dlg = QDialog(self) dlg.setMinimumWidth(380) t = c() dlg_layout = _make_frameless(dlg, "Group Info") # Group avatar avatar_row = QHBoxLayout() avatar_label = QLabel() avatar_label.setFixedSize(64, 64) avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter) conv_id = conv["conversation_id"] if conv_id in self._group_avatar_cache: avatar_pix = self._make_circular_avatar(self._group_avatar_cache[conv_id], size=64) else: avatar_pix = self._make_default_avatar(group_name, size=64) avatar_label.setPixmap(avatar_pix) avatar_row.addWidget(avatar_label) group_name_esc = group_name.replace("&", "&").replace("<", "<").replace(">", ">") title = QLabel(f"{group_name_esc}") avatar_row.addWidget(title, stretch=1) if is_creator: change_avatar_btn = QPushButton("Change Avatar") change_avatar_btn.setObjectName("secondaryBtn") change_avatar_btn.clicked.connect(lambda: self._do_change_group_avatar(conv_id, dlg)) avatar_row.addWidget(change_avatar_btn) rename_btn = QPushButton("Rename") rename_btn.setObjectName("secondaryBtn") rename_btn.clicked.connect(lambda: self._do_rename_group(conv_id, group_name, dlg)) avatar_row.addWidget(rename_btn) dlg_layout.addLayout(avatar_row) count_label = QLabel(f"Members ({len(members)}):") count_label.setStyleSheet("margin-top: 8px;") dlg_layout.addWidget(count_label) for mem in members: uname = mem.get("username") or mem.get("email") or "?" email = mem.get("email", "") uid = mem.get("user_id") or mem.get("id") or "" is_mem_creator = uid == conv.get("created_by") row = QHBoxLayout() uname_esc = uname.replace("&", "&").replace("<", "<").replace(">", ">") email_esc = email.replace("&", "&").replace("<", "<").replace(">", ">") is_online = uid in self._online_users online_dot = "\U0001f7e2 " if is_online else "" verified_dot = "" my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" if uid and uid != my_uid and self.bridge.client.get_verification_status(uid) == "verified": verified_dot = " \u2705" name_text = f"{online_dot}{uname_esc}{verified_dot}" if email: name_text += f" {email_esc}" if is_mem_creator: name_text += f" creator" name_label = QLabel(name_text) name_label.setWordWrap(True) row.addWidget(name_label, stretch=1) info_btn = QPushButton("") info_btn.setFixedSize(28, 28) info_btn.setObjectName("secondaryBtn") info_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation)) info_btn.setToolTip(f"View profile of {uname}") info_btn.clicked.connect(lambda checked, u=uid, d=dlg: (d.accept(), self._show_user_profile(u))) row.addWidget(info_btn) # Remove button (only for creator, not on self) if is_creator and uid != my_user_id: remove_btn = QPushButton("") remove_btn.setFixedSize(28, 28) remove_btn.setObjectName("secondaryBtn") remove_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCloseButton)) remove_btn.setToolTip(f"Remove {uname}") remove_btn.clicked.connect(lambda checked, u=uid, n=uname, d=dlg: self._do_remove_member_action(u, n, d)) row.addWidget(remove_btn) dlg_layout.addLayout(row) dlg_layout.addSpacing(12) # Leave Group button leave_btn = QPushButton("Leave Group") leave_btn.setStyleSheet( f"QPushButton {{ background-color: {t.error}; color: {t.accent_text}; font-weight: bold; }}" f"QPushButton:hover {{ opacity: 0.8; }}" ) leave_btn.clicked.connect(lambda: self._do_leave_group_action(dlg)) dlg_layout.addWidget(leave_btn) close_btn = QPushButton("Close") close_btn.clicked.connect(dlg.accept) dlg_layout.addWidget(close_btn) dlg.exec() def _do_remove_member_action(self, user_id, username, dialog): if not self.current_conv_id: return confirm = QMessageBox.question( self, "Remove Member", f"Remove {username} from the group?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if confirm == QMessageBox.StandardButton.Yes: dialog.accept() self.bridge.remove_member(self.current_conv_id, user_id) def _do_change_group_avatar(self, conv_id, dialog): path, _ = QFileDialog.getOpenFileName( dialog, "Select Group Avatar", "", "Images (*.png *.jpg *.jpeg);;All Files (*)", ) if not path: return try: with open(path, "rb") as f: image_data = f.read() if len(image_data) > 2 * 1024 * 1024: QMessageBox.warning(dialog, "Too Large", "Avatar must be under 2 MB.") return dialog.accept() self.bridge.update_group_avatar(conv_id, image_data) except Exception as e: QMessageBox.warning(dialog, "Error", f"Failed to read image: {e}") def _do_rename_group(self, conv_id, current_name, dialog): from PyQt6.QtWidgets import QInputDialog new_name, ok = QInputDialog.getText( dialog, "Rename Group", "New group name:", text=current_name, ) if ok and new_name.strip(): new_name = new_name.strip() if new_name != current_name: dialog.accept() self.bridge.rename_conversation(conv_id, new_name) def _on_group_renamed(self, ok, msg): if not ok: QMessageBox.warning(self, "Rename Group", msg) def _do_leave_group_action(self, dialog): if not self.current_conv_id: return confirm = QMessageBox.question( self, "Leave Group", "Leave this group? You will no longer receive messages.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if confirm == QMessageBox.StandardButton.Yes: dialog.accept() self.bridge.leave_group(self.current_conv_id) def _on_group_left(self, ok, msg): if ok: self.current_conv_id = None self.msg_input.drop_enabled = False self.chat_header.setText("Select a conversation") self.chat_header_avatar.setVisible(False) self._clear_message_area() self.group_info_btn.setVisible(False) self.user_info_btn.setVisible(False) self.add_member_btn.setVisible(False) self.delete_conv_btn.setVisible(False) else: QMessageBox.warning(self, "Leave Group", msg) def _on_delete_conv_btn(self): if not self.current_conv_id: return confirm = QMessageBox.question( self, "Delete Conversation", "Delete this conversation? This cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if confirm == QMessageBox.StandardButton.Yes: self.bridge.delete_conversation(self.current_conv_id) def _on_conversation_deleted(self, ok, msg): if ok: self.current_conv_id = None self.msg_input.drop_enabled = False self.chat_header.setText("Select a conversation") self.chat_header_avatar.setVisible(False) self._clear_message_area() self.group_info_btn.setVisible(False) self.user_info_btn.setVisible(False) self.add_member_btn.setVisible(False) self.delete_conv_btn.setVisible(False) else: QMessageBox.warning(self, "Delete Conversation", msg) def _on_remove_member_result(self, ok, msg): if ok: QMessageBox.information(self, "Remove Member", "Member removed.") if self.current_conv_id: self.bridge.load_messages(self.current_conv_id) else: QMessageBox.warning(self, "Remove Member", msg) def _on_my_profile(self): my_user_id = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" if not my_user_id: return dlg = UserProfileDialog(self.bridge, my_user_id, editable=True, parent=self) dlg.exec() def _on_dm_user_info(self): """Show profile of the other user in a DM conversation.""" if not self.current_conv_id: return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return my_email = self.bridge.client.email for m in conv["members"]: if m.get("email") != my_email: uid = m.get("user_id") or m.get("id") if uid: self._show_user_profile(uid) return def _show_user_profile(self, user_id): dlg = UserProfileDialog(self.bridge, user_id, editable=False, parent=self) dlg.exec() def _on_open_settings(self): t = c() dlg = QDialog(self) dlg.setMinimumWidth(360) lay = _make_frameless(dlg, "Settings") lay.setSpacing(16) # -- Appearance section -- sec_appearance = QLabel("Appearance") sec_appearance.setStyleSheet( f"font-size: 10pt; font-weight: bold; color: {t.text_secondary}; " f"margin-top: 4px;" ) lay.addWidget(sec_appearance) theme_row = QHBoxLayout() theme_label = QLabel("Theme") theme_label.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};") theme_row.addWidget(theme_label) theme_row.addStretch() theme_btn = QPushButton( "\u2600 Light mode" if tm().is_dark else "\U0001f319 Dark mode" ) theme_btn.setObjectName("secondaryBtn") theme_btn.setFixedWidth(140) theme_row.addWidget(theme_btn) lay.addLayout(theme_row) # Separator sep1 = QFrame() sep1.setFrameShape(QFrame.Shape.HLine) sep1.setStyleSheet(f"background-color: {t.separator}; max-height: 1px;") lay.addWidget(sep1) # -- Security section -- sec_security = QLabel("Security") sec_security.setStyleSheet( f"font-size: 10pt; font-weight: bold; color: {t.text_secondary};" ) lay.addWidget(sec_security) # Rotate Keys rotate_row = QHBoxLayout() rotate_info = QVBoxLayout() rotate_title = QLabel("Rotate Keys") rotate_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};") rotate_info.addWidget(rotate_title) rotate_desc = QLabel("Generate new RSA keys. Revokes other devices.") rotate_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};") rotate_desc.setWordWrap(True) rotate_info.addWidget(rotate_desc) rotate_row.addLayout(rotate_info, stretch=1) rotate_btn = QPushButton("Rotate") rotate_btn.setObjectName("secondaryBtn") rotate_btn.setFixedWidth(100) rotate_btn.clicked.connect(lambda: (dlg.close(), self._on_rotate_keys())) rotate_row.addWidget(rotate_btn) lay.addLayout(rotate_row) # Change Username chun_row = QHBoxLayout() chun_info = QVBoxLayout() chun_title = QLabel("Change Username") chun_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};") chun_info.addWidget(chun_title) chun_desc = QLabel("Change your display name.") chun_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};") chun_desc.setWordWrap(True) chun_info.addWidget(chun_desc) chun_row.addLayout(chun_info, stretch=1) chun_btn = QPushButton("Change") chun_btn.setObjectName("secondaryBtn") chun_btn.setFixedWidth(100) chun_btn.clicked.connect(lambda: (dlg.close(), self._on_change_username())) chun_row.addWidget(chun_btn) lay.addLayout(chun_row) # Change Password chpw_row = QHBoxLayout() chpw_info = QVBoxLayout() chpw_title = QLabel("Change Password") chpw_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};") chpw_info.addWidget(chpw_title) chpw_desc = QLabel("Change password for local key encryption.") chpw_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};") chpw_desc.setWordWrap(True) chpw_info.addWidget(chpw_desc) chpw_row.addLayout(chpw_info, stretch=1) chpw_btn = QPushButton("Change") chpw_btn.setObjectName("secondaryBtn") chpw_btn.setFixedWidth(100) chpw_btn.clicked.connect(lambda: (dlg.close(), self._on_change_password())) chpw_row.addWidget(chpw_btn) lay.addLayout(chpw_row) # Separator sep2 = QFrame() sep2.setFrameShape(QFrame.Shape.HLine) sep2.setStyleSheet(f"background-color: {t.separator}; max-height: 1px;") lay.addWidget(sep2) # -- Devices section -- sec_devices = QLabel("Devices") sec_devices.setStyleSheet( f"font-size: 10pt; font-weight: bold; color: {t.text_secondary};" ) lay.addWidget(sec_devices) # Link Device (new) link_row = QHBoxLayout() link_info = QVBoxLayout() link_title = QLabel("Link New Device") link_title.setStyleSheet(f"font-size: 11pt; color: {t.text_primary};") link_info.addWidget(link_title) link_desc = QLabel("Authorize another device to access your account.") link_desc.setStyleSheet(f"font-size: 8pt; color: {t.text_muted};") link_desc.setWordWrap(True) link_info.addWidget(link_desc) link_row.addLayout(link_info, stretch=1) link_btn = QPushButton("Link") link_btn.setObjectName("secondaryBtn") link_btn.setFixedWidth(100) link_btn.clicked.connect(lambda: (dlg.close(), self._on_authorize_device())) link_row.addWidget(link_btn) lay.addLayout(link_row) # Wire up theme toggle now that all widgets exist _s_labels = [sec_appearance, sec_security, sec_devices] _t_labels = [theme_label, rotate_title, chpw_title, link_title] _d_labels = [rotate_desc, chpw_desc, link_desc] _seps = [sep1, sep2] def _toggle_and_update(): tm().toggle() theme_btn.setText( "\u2600 Light mode" if tm().is_dark else "\U0001f319 Dark mode" ) t2 = c() dlg._frameless_container.setStyleSheet( f"#_framelessContainer {{ background-color: {t2.bg_primary}; border-radius: 12px; }}" ) dlg._frameless_title_bar.setStyleSheet( f"background-color: {t2.bg_secondary}; " f"border-top-left-radius: 12px; border-top-right-radius: 12px;" ) dlg._frameless_title_label.setStyleSheet( f"font-weight: bold; font-size: 10pt; color: {t2.text_primary}; background: transparent;" ) for lbl in _s_labels: lbl.setStyleSheet(f"font-size: 10pt; font-weight: bold; color: {t2.text_secondary};") for lbl in _t_labels: lbl.setStyleSheet(f"font-size: 11pt; color: {t2.text_primary};") for lbl in _d_labels: lbl.setStyleSheet(f"font-size: 8pt; color: {t2.text_muted};") for s in _seps: s.setStyleSheet(f"background-color: {t2.separator}; max-height: 1px;") theme_btn.clicked.connect(_toggle_and_update) lay.addStretch() # Close button close_btn = QPushButton("Close") close_btn.clicked.connect(dlg.close) lay.addWidget(close_btn) dlg.exec() def _on_authorize_device(self): code, ok = QInputDialog.getText(self, "Authorize Device", "Pairing code:") if not ok or not code.strip(): return self.bridge.authorize_device(code.strip()) def _on_rotate_keys(self): confirm = QMessageBox.question( self, "Rotate Keys", "This will revoke other devices. Continue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if confirm != QMessageBox.StandardButton.Yes: return password, ok = QInputDialog.getText(self, "Rotate Keys", "Password:", QLineEdit.EchoMode.Password) if not ok or not password: return self.bridge.rotate_keys(self.bridge.client.username, password) def _on_change_password(self): t = c() dlg = QDialog(self) dlg.setMinimumWidth(360) lay = _make_frameless(dlg, "Change Password") old_label = QLabel("Current Password") old_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(old_label) old_input = QLineEdit() old_input.setEchoMode(QLineEdit.EchoMode.Password) old_input.setPlaceholderText("Enter current password") old_input.setStyleSheet( f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; " f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; " f"color: {t.text_primary}; }}" f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) lay.addWidget(old_input) new_label = QLabel("New Password") new_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(new_label) new_input = QLineEdit() new_input.setEchoMode(QLineEdit.EchoMode.Password) new_input.setPlaceholderText("Enter new password") new_input.setStyleSheet(old_input.styleSheet()) lay.addWidget(new_input) confirm_label = QLabel("Confirm New Password") confirm_label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(confirm_label) confirm_input = QLineEdit() confirm_input.setEchoMode(QLineEdit.EchoMode.Password) confirm_input.setPlaceholderText("Re-enter new password") confirm_input.setStyleSheet(old_input.styleSheet()) lay.addWidget(confirm_input) error_label = QLabel("") error_label.setStyleSheet(f"color: {t.error}; font-size: 9pt;") error_label.hide() lay.addWidget(error_label) btn_lay = QHBoxLayout() btn_lay.addStretch() change_btn = QPushButton("Change Password") change_btn.setStyleSheet( f"QPushButton {{ background-color: {t.accent}; color: {t.accent_text}; " f"border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; }}" f"QPushButton:hover {{ opacity: 0.9; }}" ) btn_lay.addWidget(change_btn) lay.addLayout(btn_lay) def _do_change(): old_pw = old_input.text() new_pw = new_input.text() conf_pw = confirm_input.text() if not old_pw: error_label.setText("Current password is required.") error_label.show() return if not new_pw: error_label.setText("New password cannot be empty.") error_label.show() return if new_pw != conf_pw: error_label.setText("New passwords do not match.") error_label.show() return dlg.accept() self.bridge.change_password(old_pw, new_pw) change_btn.clicked.connect(_do_change) confirm_input.returnPressed.connect(_do_change) dlg.exec() def _on_logout(self): confirm = QMessageBox.question( self, "Logout", "Log out and return to the login screen?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if confirm != QMessageBox.StandardButton.Yes: return self._is_logout = True self.bridge.logout() self.close() if self._on_logout_cb: self._on_logout_cb() def _on_notification(self, payload): sender = payload.get("sender", "???") conv_id = payload.get("conversation_id", "") # Update last-message cache for conversation list preview if conv_id: preview = payload.get("text", "") if payload.get("image") and not preview: preview = "Sent an image" elif payload.get("file") and not preview: preview = "Sent a file" if preview: self._last_message_cache[conv_id] = ( f"{sender}: {preview}"[:60], payload.get("created_at", ""), "", # incoming message — no receipt status for others' msgs ) # Resolve conversation name for notifications conv_name = sender is_notif_dm = False if conv_id: for cv in self.conversations: if cv["conversation_id"] == conv_id: is_notif_dm = len(cv["members"]) == 2 and not cv.get("name") if not is_notif_dm: conv_name = cv.get("name") or sender break # System tray toast when window is not visible or not focused if conv_id: if is_notif_dm: notif_title = sender notif_text = payload.get("text", "New message") else: notif_title = conv_name notif_text = f"{sender}: {payload.get('text', 'New message')}" if payload.get("image"): notif_text = notif_text or "Sent an image" elif payload.get("file"): notif_text = notif_text or "Sent a file" self._show_tray_notification(notif_title, notif_text) # Show notification in status bar (for non-current conversations) if conv_id and conv_id != self.current_conv_id: if is_notif_dm: self.status_bar.setText(f"New message from {sender}") else: self.status_bar.setText(f"New message from {sender} in {conv_name}") t = c() self.status_bar.setStyleSheet( f"background-color: {t.bg_tertiary}; border-radius: 0px; " f"padding: 0 8px; color: {t.success}; font-size: 8pt; font-weight: bold;" ) self._status_bar_conv_id = conv_id QTimer.singleShot(5000, self._clear_status_bar) # Confirm delivery for all incoming messages (always, regardless of current view) msg_id = payload.get("message_id", "") if conv_id and msg_id: self.bridge.schedule( self.bridge.client.confirm_delivery(conv_id, [msg_id]) ) # Increment unread count if not currently viewing this conversation # (or if privacy overlay is locked — user can't see messages) viewing = conv_id == self.current_conv_id and not self._privacy_locked if conv_id and not viewing: self._unread_counts[conv_id] = self._unread_counts.get(conv_id, 0) + 1 self._update_conv_list_styles() # Append directly to current conversation instead of re-fetching if conv_id == self.current_conv_id: # Avoid duplicate if local send already appended this message if msg_id: for m in self.current_messages: if m.get("message_id") == msg_id: return self.current_messages.append(payload) idx = len(self.current_messages) - 1 w = self._create_message_widget(payload, idx) self._msg_layout.addWidget(w) self._msg_widgets.append(w) if self._is_near_bottom: QTimer.singleShot(10, self._scroll_to_bottom) else: self.jump_btn.setText("\u2193 New") self.jump_btn.setVisible(True) self._position_jump_btn() # Mark as read (only if not locked) if msg_id and not self._privacy_locked: self.bridge.schedule( self.bridge.client.mark_read(conv_id, [msg_id]) ) def _on_messages_read(self, data): conv_id = data.get("conversation_id", "") user_id = data.get("user_id", "") message_ids = set(data.get("message_ids", [])) my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else "" # Persist to cache for ALL conversations (not just current) if conv_id == self.current_conv_id: for msg in self.current_messages: if message_ids and msg.get("message_id") not in message_ids: continue if not message_ids and msg.get("sender_id") != my_uid: continue read_by = msg.get("read_by", []) if not any(r.get("user_id") == user_id for r in read_by): read_by.append({"user_id": user_id}) msg["read_by"] = read_by self.bridge.client.update_message_in_cache( conv_id, msg.get("message_id"), {"read_by": read_by}) self._render_messages(scroll_to_bottom=self._is_near_bottom) else: # Non-current conversation: update cache only (no in-memory messages to update) cached = self.bridge.client.load_message_cache(conv_id) if cached: for msg_id_key, entry in cached.items(): if message_ids and msg_id_key not in message_ids: continue if not message_ids and entry.get("sender_id") != my_uid: continue read_by = entry.get("read_by", []) if not any(r.get("user_id") == user_id for r in read_by): read_by.append({"user_id": user_id}) self.bridge.client.update_message_in_cache( conv_id, msg_id_key, {"read_by": read_by}) # Update conv list receipt status to "read" if conv_id in self._last_message_cache: prev_text, prev_ts, prev_receipt = self._last_message_cache[conv_id] if prev_receipt in ("sent", "delivered"): self._last_message_cache[conv_id] = (prev_text, prev_ts, "read") self._update_conv_list_styles() def _on_message_delivered(self, data): conv_id = data.get("conversation_id", "") user_id = data.get("user_id", "") message_ids = set(data.get("message_ids", [])) if conv_id == self.current_conv_id: for msg in self.current_messages: if msg.get("message_id") in message_ids: delivered_to = msg.get("delivered_to", []) if not any(d.get("user_id") == user_id for d in delivered_to): delivered_to.append({"user_id": user_id}) msg["delivered_to"] = delivered_to self.bridge.client.update_message_in_cache( conv_id, msg.get("message_id"), {"delivered_to": delivered_to}) self._render_messages(scroll_to_bottom=self._is_near_bottom) else: # Non-current conversation: update cache only cached = self.bridge.client.load_message_cache(conv_id) if cached: for msg_id_key, entry in cached.items(): if msg_id_key in message_ids: delivered_to = entry.get("delivered_to", []) if not any(d.get("user_id") == user_id for d in delivered_to): delivered_to.append({"user_id": user_id}) self.bridge.client.update_message_in_cache( conv_id, msg_id_key, {"delivered_to": delivered_to}) # Update conv list receipt status to "delivered" if conv_id in self._last_message_cache: prev_text, prev_ts, prev_receipt = self._last_message_cache[conv_id] if prev_receipt == "sent": self._last_message_cache[conv_id] = (prev_text, prev_ts, "delivered") self._update_conv_list_styles() def _on_link_clicked(self, url_str): """Handle link clicks from message bubble labels.""" if url_str.startswith("image://"): file_id = url_str[len("image://"):] for msg in self.current_messages: image_info = msg.get("image") if image_info and image_info.get("file_id") == file_id: self._view_image(msg) return elif url_str.startswith("file://"): file_id = url_str[len("file://"):] for msg in self.current_messages: file_info = msg.get("file") if file_info and file_info.get("file_id") == file_id: self.bridge.download_file(file_id, file_info) return elif url_str.startswith("https://"): QDesktopServices.openUrl(QUrl(url_str)) elif url_str.startswith("http://"): reply = QMessageBox.warning( self, "Insecure link", f"This link uses unencrypted HTTP.\n\n{url_str}\n\nContinue anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: QDesktopServices.openUrl(QUrl(url_str)) # -- Drag & drop -------------------------------------------------------- _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"} def _msg_area_normal_style(self): return f"QScrollArea {{ background-color: {c().bg_primary}; border: none; }}" def eventFilter(self, obj, event): """Handle drag-and-drop on message scroll area + context menu on messages.""" from PyQt6.QtCore import QEvent # Context menu from any child of message container if event.type() == QEvent.Type.ContextMenu and obj is not self._msg_scroll_area: widget = obj idx = self._find_msg_index_at_widget(widget) if idx is not None: self._show_msg_context_menu(idx, event.globalPos()) return True if obj is self._msg_scroll_area: if event.type() == QEvent.Type.DragEnter: if not self.current_conv_id: event.ignore() return True if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()): event.acceptProposedAction() self._msg_scroll_area.setStyleSheet( f"QScrollArea {{ border: 2px dashed {c().accent}; }}" ) return True elif event.type() == QEvent.Type.DragMove: if event.mimeData().hasUrls(): event.acceptProposedAction() return True elif event.type() == QEvent.Type.DragLeave: self._msg_scroll_area.setStyleSheet(self._msg_area_normal_style()) return True elif event.type() == QEvent.Type.Drop: self._msg_scroll_area.setStyleSheet(self._msg_area_normal_style()) if event.mimeData().hasUrls(): for url in event.mimeData().urls(): if url.isLocalFile(): self._on_file_dropped(url.toLocalFile()) event.acceptProposedAction() return True return super().eventFilter(obj, event) def _on_file_dropped(self, path: str): """Send a dropped file as image or file attachment.""" if not self.current_conv_id: return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return import os ext = os.path.splitext(path)[1].lower() if ext in self._IMAGE_EXTENSIONS: self.bridge.send_image( self.current_conv_id, path, conv["members"], reply_to=self.reply_to_id, ) else: self.bridge.send_file( self.current_conv_id, path, conv["members"], reply_to=self.reply_to_id, ) self.reply_to_id = None self._reply_widget.setVisible(False) # -- Attach menu ------------------------------------------------------- def _on_attach_image(self): if not self.current_conv_id: return path, _ = QFileDialog.getOpenFileName( self, "Select Image", "", "Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All Files (*)", ) if not path: return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return self.bridge.send_image( self.current_conv_id, path, conv["members"], reply_to=self.reply_to_id, ) self.reply_to_id = None self._reply_widget.setVisible(False) @staticmethod def _human_file_size(size_bytes): if size_bytes >= 1024 * 1024: return f"{size_bytes / (1024 * 1024):.1f} MB" elif size_bytes >= 1024: return f"{size_bytes / 1024:.0f} KB" return f"{size_bytes} B" @staticmethod def _file_icon(filename: str) -> str: """Return an emoji icon based on file extension.""" ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" _icons = { "pdf": "\U0001f4d5", # red book "doc": "\U0001f4d8", # blue book "docx": "\U0001f4d8", "odt": "\U0001f4d8", "xls": "\U0001f4ca", # bar chart "xlsx": "\U0001f4ca", "ods": "\U0001f4ca", "csv": "\U0001f4ca", "ppt": "\U0001f4d9", # orange book "pptx": "\U0001f4d9", "odp": "\U0001f4d9", "zip": "\U0001f4e6", # package "rar": "\U0001f4e6", "7z": "\U0001f4e6", "tar": "\U0001f4e6", "gz": "\U0001f4e6", "mp3": "\U0001f3b5", # music note "wav": "\U0001f3b5", "flac": "\U0001f3b5", "ogg": "\U0001f3b5", "m4a": "\U0001f3b5", "mp4": "\U0001f3ac", # clapper board "mkv": "\U0001f3ac", "avi": "\U0001f3ac", "mov": "\U0001f3ac", "webm": "\U0001f3ac", "py": "\U0001f40d", # snake "js": "\U0001f4dc", # scroll "ts": "\U0001f4dc", "html": "\U0001f310", # globe "css": "\U0001f3a8", # palette "json": "\U0001f4cb", # clipboard "xml": "\U0001f4cb", "yaml": "\U0001f4cb", "yml": "\U0001f4cb", "txt": "\U0001f4c4", # page facing up "log": "\U0001f4c4", "md": "\U0001f4c4", } return _icons.get(ext, "\U0001f4ce") # default: paperclip def _on_attach_file(self): if not self.current_conv_id: return path, _ = QFileDialog.getOpenFileName( self, "Select File", "", "All Files (*)", ) if not path: return conv = None for cv in self.conversations: if cv["conversation_id"] == self.current_conv_id: conv = cv break if not conv: return self.bridge.send_file( self.current_conv_id, path, conv["members"], reply_to=self.reply_to_id, ) self.reply_to_id = None self._reply_widget.setVisible(False) def _on_file_sent(self, ok, msg): if not ok: QMessageBox.warning(self, "File Error", msg) def _on_file_downloaded(self, data, file_info): filename = _safe_filename(file_info.get("filename", "file"), "file") path, _ = QFileDialog.getSaveFileName(self, "Save File", filename) if path: try: with open(path, "wb") as f: f.write(data) QMessageBox.information(self, "Saved", f"File saved to {path}") except Exception as e: QMessageBox.warning(self, "Error", f"Failed to save: {e}") def _on_image_sent(self, ok, msg): if not ok: QMessageBox.warning(self, "Image Error", msg) def _view_image(self, msg): image_info = msg.get("image") if not image_info: return file_id = image_info.get("file_id", "") self._pending_image_download = {"file_id": file_id, "image_info": image_info} self.bridge.download_image(file_id, image_info) def _on_image_downloaded(self, file_id, data): if not self._pending_image_download or self._pending_image_download["file_id"] != file_id: return image_info = self._pending_image_download["image_info"] self._pending_image_download = None self._show_image_dialog(data, image_info) def _show_image_dialog(self, image_data, image_info): dlg = QDialog(self) dlg.setMinimumSize(400, 300) img_title = _safe_filename(image_info.get("filename", "Image"), "Image") layout = _make_frameless(dlg, img_title) qimg = _safe_load_image(image_data) if qimg is None: layout.addWidget(QLabel("Failed to load image.")) else: pixmap = QPixmap.fromImage(qimg) label = QLabel() # Scale down if larger than screen screen_size = self.screen().availableSize() max_w = int(screen_size.width() * 0.8) max_h = int(screen_size.height() * 0.8) if pixmap.width() > max_w or pixmap.height() > max_h: pixmap = pixmap.scaled(max_w, max_h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) label.setPixmap(pixmap) label.setAlignment(Qt.AlignmentFlag.AlignCenter) scroll = QScrollArea() scroll.setWidget(label) scroll.setWidgetResizable(True) layout.addWidget(scroll) btn_row = QHBoxLayout() save_btn = QPushButton("Save") save_btn.clicked.connect(lambda: self._save_image(image_data, image_info, dlg)) btn_row.addWidget(save_btn) close_btn = QPushButton("Close") close_btn.setObjectName("secondaryBtn") close_btn.clicked.connect(dlg.accept) btn_row.addWidget(close_btn) layout.addLayout(btn_row) if qimg is not None and not qimg.isNull(): dlg.resize(min(pixmap.width() + 40, max_w), min(pixmap.height() + 80, max_h)) dlg.exec() def _save_image(self, image_data, image_info, dialog): filename = _safe_filename(image_info.get("filename", "image.jpg"), "image.jpg") path, _ = QFileDialog.getSaveFileName(dialog, "Save Image", filename) if path: try: with open(path, "wb") as f: f.write(image_data) QMessageBox.information(dialog, "Saved", f"Image saved to {path}") except Exception as e: QMessageBox.warning(dialog, "Error", f"Failed to save: {e}") def _on_message_deleted(self, data): message_id = data.get("message_id", "") conv_id = data.get("conversation_id", "") if conv_id == self.current_conv_id: for msg in self.current_messages: if msg.get("message_id") == message_id: msg["deleted"] = True msg["text"] = "" msg["image"] = None break self._render_messages() def _on_delete_message_result(self, ok, msg): if not ok: QMessageBox.warning(self, "Delete Error", msg) return # No need to reload — _on_message_deleted() already updates in-place via notification def _on_authorize_result(self, ok, msg): if ok: QMessageBox.information(self, "Authorize Device", msg) else: QMessageBox.warning(self, "Authorize Device", msg) def _on_rotate_result(self, ok, msg): if ok: QMessageBox.information(self, "Rotate Keys", msg) else: QMessageBox.warning(self, "Rotate Keys", msg) def _on_password_changed(self, ok, msg): if ok: QMessageBox.information(self, "Change Password", msg) else: QMessageBox.warning(self, "Change Password", msg) def _on_change_username(self): t = c() current = "" if self.bridge and self.bridge.client: current = getattr(self.bridge.client, "username", "") or "" dlg = QDialog(self) dlg.setMinimumWidth(360) lay = _make_frameless(dlg, "Change Username") label = QLabel("New Username") label.setStyleSheet(f"color: {t.text_secondary}; font-size: 9pt;") lay.addWidget(label) name_input = QLineEdit() name_input.setText(current) name_input.setPlaceholderText("Enter new username") name_input.setMaxLength(100) name_input.setStyleSheet( f"QLineEdit {{ font-size: 11pt; background-color: {t.bg_secondary}; " f"border: 1px solid {t.border}; border-radius: 6px; padding: 8px; " f"color: {t.text_primary}; }}" f"QLineEdit:focus {{ border: 1px solid {t.border_focus}; }}" ) lay.addWidget(name_input) btn_row = QHBoxLayout() cancel_btn = QPushButton("Cancel") cancel_btn.setObjectName("secondaryBtn") cancel_btn.clicked.connect(dlg.reject) btn_row.addWidget(cancel_btn) save_btn = QPushButton("Save") save_btn.setObjectName("primaryBtn") save_btn.clicked.connect(dlg.accept) btn_row.addWidget(save_btn) lay.addLayout(btn_row) name_input.setFocus() if dlg.exec() == QDialog.DialogCode.Accepted: new_name = name_input.text().strip() if new_name and new_name != current: self.bridge.change_username(new_name) def _on_username_changed(self, ok, msg): if ok: QMessageBox.information(self, "Change Username", msg) self.bridge.schedule(self.bridge._do_load_conversations()) else: QMessageBox.warning(self, "Change Username", msg) def _on_reencrypt_status(self, msg): self.reencrypt_label.setText(msg) self.reencrypt_label.setVisible(True) if msg.lower().startswith("re-encryption complete"): QTimer.singleShot(4000, lambda: self.reencrypt_label.setVisible(False)) def closeEvent(self, event): if self._tray_icon: self._tray_icon.hide() if not self._is_logout: self.bridge.stop() self.bridge.wait(2000) event.accept() def main(): setup_logging() # Suppress Qt screen enumeration warnings on Windows (monitor sleep/wake) os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.screen=false") # Windows 10+ requires AppUserModelID for system tray notifications to work if sys.platform == "win32": try: import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("com.encrypted-chat.client") except Exception: pass app = QApplication(sys.argv) app.setStyleSheet(qss()) bridge = AsyncBridge() login_win = LoginWindow(bridge) main_win = [None] # mutable ref def on_connected(): login_win.reset() login_win.show() def on_conn_error(msg): QMessageBox.critical(None, "Connection Error", f"Cannot connect to server:\n{msg}") sys.exit(1) def on_register_result(ok, msg): if ok: # Show verification code page inline hint = "" if msg and len(msg) <= 6 and msg.isdigit(): hint = f"Code: {msg}" elif msg: hint = msg login_win.show_verification_page(hint) def do_confirm(code): async def _confirm(): okc, msgc = await bridge.client.confirm_registration( login_win.email_input.text().strip(), login_win.username_input.text().strip(), code.strip(), ) if okc: login_win.show_success(msgc) bridge.do_login(login_win.email_input.text().strip(), login_win.password_input.text()) else: login_win.show_error(msgc) bridge.schedule(_confirm()) login_win._confirm_callback = do_confirm else: login_win.show_error(msg) def on_pairing_code(code): login_win.show_success(f"Pairing code: {code}") def on_pairing_complete(ok, msg): if ok: login_win.show_success(msg) bridge.do_login(login_win._pair_email, login_win._pair_password) else: login_win.show_error(msg) def on_login_result(ok, msg): if ok: login_win.show_success(msg) login_win.hide() tm().set_email(bridge.client.email) app.setStyleSheet(qss()) main_win[0] = MainWindow(bridge, on_logout=lambda: (login_win.reset(), login_win.show())) main_win[0].show() else: login_win.show_error(msg) bridge.connected.connect(on_connected) bridge.connection_error.connect(on_conn_error) bridge.register_result.connect(on_register_result) bridge.login_result.connect(on_login_result) bridge.pairing_code.connect(on_pairing_code) bridge.pairing_complete.connect(on_pairing_complete) bridge.reconnected.connect(lambda: (login_win.reset(), login_win.show())) bridge.start() sys.exit(app.exec()) if __name__ == "__main__": main()