"""PyQt6 GUI client for encrypted chat."""
import asyncio
import base64
import json
import logging
import os
from pathlib import Path
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, QBuffer
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 crypto_utils import decode_pairing_qr
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
def _generate_qr_pixmap_bytes(data: bytes) -> QPixmap | None:
"""Generate a QR code QPixmap from raw bytes (base64-wrapped for scanner compatibility)."""
try:
import qrcode
from io import BytesIO
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 _decode_qr_image_file(path: str) -> bytes | None:
"""Decode base64-wrapped binary payload from a QR image file."""
try:
from PIL import Image
pil_img = Image.open(path)
except Exception:
return None
try:
from pyzbar.pyzbar import decode as pyzbar_decode
results = pyzbar_decode(pil_img)
if not results:
return None
qr_text = results[0].data
except ImportError:
return None
except Exception:
return None
try:
return base64.b64decode(qr_text)
except Exception:
return None
_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, str, bytes)
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)
typing_start_notification = pyqtSignal(dict)
typing_stop_notification = pyqtSignal(dict)
message_deleted_notification = pyqtSignal(dict)
image_sent = pyqtSignal(bool, str)
image_downloaded = pyqtSignal(str, bytes) # file_id, decrypted bytes
image_download_failed = pyqtSignal(str) # file_id
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
avatar_fetch_failed = pyqtSignal(str) # user_id — allows retry on next refresh
_avatar_changed_signal = pyqtSignal(str) # user_id — pushed when contact changes their avatar
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_fetch_failed = pyqtSignal(str) # conv_id — allows retry on next refresh
group_avatar_updated = pyqtSignal(bool, str) # ok, message
device_added_notification = pyqtSignal(dict)
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
message_queued = pyqtSignal(int) # number of messages currently in retry queue (0 = queue cleared)
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._messages_inflight: set[str] = set() # conv_ids currently being fetched
self._invitations_inflight = False
self._pending_send_queue: list[dict] = [] # messages queued for retry after reconnect
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_removed",
"conversation_renamed"):
self.conversation_updated.emit()
elif notif_type == "member_added":
self.conversation_updated.emit()
# Redistribute our sender key to the newly joined member
conv_id = data.get("conversation_id", "")
new_user_id = data.get("user_id", "")
if conv_id and new_user_id:
asyncio.ensure_future(
self.client.redistribute_sender_key_to_member(conv_id, new_user_id)
)
elif notif_type == "group_invitation":
self.invitation_received.emit(data)
elif notif_type == "device_added":
self.device_added_notification.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 == "typing_start":
self.typing_start_notification.emit(data)
elif notif_type == "typing_stop":
self.typing_stop_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 == "avatar_changed":
uid = data.get("user_id", "")
if uid:
self._avatar_changed_signal.emit(uid)
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()
asyncio.ensure_future(self._flush_send_queue())
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)
async def _flush_send_queue(self):
"""Retry all queued messages after reconnect."""
if not self._pending_send_queue:
return
queue = self._pending_send_queue[:]
self._pending_send_queue.clear()
self.message_queued.emit(0) # Signal queue cleared
for item in queue:
await self._do_send_message(
item["conv_id"], item["text"], item["members"], item.get("reply_to")
)
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 conv_id in self._messages_inflight:
return # Already fetching this conversation
self._messages_inflight.add(conv_id)
try:
if self._ready:
await self._ready.wait()
msgs = await self.client.get_messages(conv_id)
self.messages_loaded.emit(conv_id, msgs)
finally:
self._messages_inflight.discard(conv_id)
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 (ConnectionError, asyncio.TimeoutError) as e:
logger.warning("send_message network error, queuing for retry: %s", e)
self._pending_send_queue.append({
"conv_id": conv_id,
"text": text,
"members": members,
"reply_to": reply_to,
})
self.message_queued.emit(len(self._pending_send_queue))
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):
try:
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, self.client.pairing_fingerprint(), self.client.pairing_qr_data() or b"")
ok2, msg2 = await self.client.pairing_wait(code, username, password)
self.pairing_complete.emit(ok2, msg2)
except Exception as e:
logger.error("link_device exception: %s", e, exc_info=True)
self.pairing_complete.emit(False, str(e))
async def _do_authorize_device(self, code, fingerprint):
if self._ready:
await self._ready.wait()
ok, msg = await self.client.authorize_device(code, fingerprint)
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_typing_start(self, conv_id):
self.schedule(self.client.typing_start(conv_id))
def send_typing_stop(self, conv_id, force=False):
self.schedule(self.client.typing_stop(conv_id, force=force))
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, fingerprint):
self.schedule(self._do_authorize_device(code, fingerprint))
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)
else:
self.image_download_failed.emit(file_id)
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)
if ok:
# Cache own avatar locally so it appears immediately
my_uid = self.client.session.get("user_id", "") if self.client.session else ""
if my_uid:
from protocol import decode_binary, encode_binary
self.avatar_loaded.emit(my_uid, image_data)
self.profile_updated.emit(ok, msg)
def update_avatar(self, image_data):
self.schedule(self._do_update_avatar(image_data))
_MAX_AVATAR_CONCURRENT = 3
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
# Limit concurrent avatar requests to avoid pipeline congestion
if len(self._avatar_inflight) >= self._MAX_AVATAR_CONCURRENT:
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)
else:
self.avatar_fetch_failed.emit(user_id)
except Exception:
self.avatar_fetch_failed.emit(user_id)
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
if len(self._group_avatar_inflight) >= self._MAX_AVATAR_CONCURRENT:
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)
else:
self.group_avatar_fetch_failed.emit(conv_id)
except Exception:
self.group_avatar_fetch_failed.emit(conv_id)
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 show_pairing_qr(self, code: str, fingerprint: str, qr_data: bytes | None):
if not qr_data:
return
pixmap = _generate_qr_pixmap_bytes(qr_data)
if not pixmap:
return
dlg = QDialog(self)
dlg.setMinimumWidth(340)
t = c()
lay = _make_frameless(dlg, "Pairing QR")
info = QLabel(
"Scan this QR on the already logged-in device, or save it and load the image there."
)
info.setWordWrap(True)
info.setStyleSheet(f"color: {t.text_primary}; font-size: 9pt;")
lay.addWidget(info)
qr_label = QLabel()
qr_label.setPixmap(pixmap)
qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay.addWidget(qr_label)
details = QLabel(f"Code: {code}\nFingerprint:\n{fingerprint}")
details.setAlignment(Qt.AlignmentFlag.AlignCenter)
details.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
details.setStyleSheet(
f"font-family: monospace; font-size: 9pt; color: {t.text_primary}; "
f"background: {t.bg_secondary}; padding: 8px; border-radius: 6px;"
)
lay.addWidget(details)
save_btn = QPushButton("Save QR Code")
save_btn.setObjectName("secondaryBtn")
save_btn.clicked.connect(lambda: self._save_pairing_qr(pixmap))
lay.addWidget(save_btn)
close_btn = QPushButton("Close")
close_btn.setObjectName("secondaryBtn")
close_btn.clicked.connect(dlg.accept)
lay.addWidget(close_btn)
dlg.exec()
def _save_pairing_qr(self, pixmap: QPixmap):
path, _ = QFileDialog.getSaveFileName(
self, "Save Pairing QR Code", "pairing_qr.png",
"PNG Images (*.png);;All Files (*)",
)
if path:
pixmap.save(path, "PNG")
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._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._typing_users: dict[str, dict[str, str]] = {} # conv_id -> {user_id: username}
self._typing_timers: dict[tuple[str, str], QTimer] = {} # (conv_id, user_id) -> timer
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._preload_avatars_from_disk()
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 = 300_000 # 5 min 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()
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 — show overlay and require password."""
if not self._privacy_enabled or not self._lock_capable:
return
if self.isActiveWindow():
return # User came back before timeout fired
self._show_privacy_overlay()
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():
# Window got focus back
self._lock_timer.stop()
if not self._privacy_locked:
self._hide_privacy_overlay()
else:
# Locked — keep overlay, focus password input
self._lock_input.setFocus()
else:
# Window lost focus — only start lock timer, no immediate overlay
if self._privacy_enabled and self._lock_capable:
self._lock_timer.start(self._LOCK_TIMEOUT_MS)
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.typing_label = QLabel("")
self.typing_label.setStyleSheet(
f"color: {t.text_muted}; font-size: 9pt; padding: 0 10px; font-style: italic;"
)
self.typing_label.setVisible(False)
right_layout.addWidget(self.typing_label)
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.typing_start_notification.connect(self._on_typing_start)
self.bridge.typing_stop_notification.connect(self._on_typing_stop)
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.image_download_failed.connect(self._on_image_download_failed)
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.avatar_fetch_failed.connect(self._on_avatar_fetch_failed)
self.bridge._avatar_changed_signal.connect(self._on_avatar_changed_push)
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.device_added_notification.connect(self._on_device_added)
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_fetch_failed.connect(
lambda cid: self._group_avatar_requested.discard(cid))
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.bridge.message_queued.connect(self._on_message_queued)
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 only. Avatars are fetched on-demand."""
self.bridge.list_invitations()
def _on_online_users_loaded(self, user_ids):
self._online_users = set(user_ids)
self._rebuild_conv_list()
def _on_online_status_changed(self, user_id, is_online):
if is_online:
self._online_users.add(user_id)
else:
self._online_users.discard(user_id)
self._rebuild_conv_list()
def _avatar_disk_cache_dir(self) -> Path | None:
"""Return path to avatar disk cache directory."""
try:
from chat_core import get_key_dir
d = get_key_dir(self.bridge.client.email) / "avatar_cache"
d.mkdir(parents=True, exist_ok=True)
os.chmod(d, 0o700)
return d
except Exception:
return None
def _get_local_key(self) -> bytes | None:
"""Get local encryption key from ChatClient (derived from identity key)."""
try:
return self.bridge.client._local_key
except Exception:
return None
def _save_avatar_to_disk(self, key: str, data: bytes):
"""Save avatar bytes to disk cache (encrypted with local_key)."""
d = self._avatar_disk_cache_dir()
if not d:
return
try:
from chat_core import _encrypt_local
local_key = self._get_local_key()
if local_key:
enc = _encrypt_local(data, local_key)
p = d / f"{key}.bin"
p.write_bytes(enc)
os.chmod(p, 0o600)
else:
p = d / f"{key}.bin"
p.write_bytes(data)
os.chmod(p, 0o600)
except Exception:
pass
def _load_avatar_from_disk(self, key: str) -> bytes | None:
"""Load avatar bytes from disk cache (decrypts if encrypted)."""
d = self._avatar_disk_cache_dir()
if not d:
return None
p = d / f"{key}.bin"
if not p.exists():
return None
try:
raw = p.read_bytes()
local_key = self._get_local_key()
if local_key and len(raw) > 28:
from chat_core import _decrypt_local
try:
return _decrypt_local(raw, local_key)
except Exception:
# Transparent migration: try as plaintext, re-save encrypted
qimg = _safe_load_image(raw)
if qimg is not None:
self._save_avatar_to_disk(key, raw)
return raw
return None
return raw
except Exception:
return None
def _preload_avatars_from_disk(self):
"""Load cached avatars from disk into memory on startup."""
d = self._avatar_disk_cache_dir()
if not d:
return
for p in d.glob("*.bin"):
key = p.stem
try:
data = self._load_avatar_from_disk(key)
if data is None:
continue
qimg = _safe_load_image(data)
if qimg is None:
continue
pix = QPixmap.fromImage(qimg)
if key.startswith("g_"):
self._group_avatar_cache[key[2:]] = pix
else:
self._avatar_cache[key] = pix
except Exception:
pass
def _on_avatar_fetch_failed(self, user_id):
"""Allow retry by removing from requested set."""
self._avatar_requested.discard(user_id)
def _on_avatar_changed_push(self, user_id):
"""Server pushed avatar_changed — re-download this user's avatar."""
self._avatar_requested.discard(user_id)
self.bridge.get_avatar(user_id)
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._save_avatar_to_disk(user_id, data)
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._save_avatar_to_disk(f"g_{conv_id}", data)
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_device_added(self, data):
device_name = data.get("device_name") or "Unknown device"
device_id = (data.get("device_id") or "")[:8]
ip = data.get("ip") or "unknown"
t = c()
self.status_bar.setText(f"New device added: {device_name} ({device_id}) from {ip}")
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(10000, self._clear_status_bar)
QMessageBox.warning(
self,
"New Device Added",
f"A new device was added to this account:\n\n"
f"Name: {device_name}\n"
f"Device ID: {device_id}\n"
f"IP: {ip}\n\n"
f"If this was not you, rotate keys immediately.",
)
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
prev_conv_id = self.current_conv_id
if prev_conv_id:
self.bridge.send_typing_stop(prev_conv_id, force=True)
conv = self.conversations[row]
self.current_conv_id = conv["conversation_id"]
self._typing_label_refresh()
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()
# Show cached messages instantly, then sync with server in background
cached = self.bridge.client.get_cached_messages(self.current_conv_id)
if cached:
self._on_messages_loaded(self.current_conv_id, cached)
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 --
is_uploading = m.get("_uploading", False)
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:
scaled = thumb_pixmap.scaledToWidth(
min(200, thumb_pixmap.width()),
Qt.TransformationMode.SmoothTransformation,
)
if is_uploading:
# Semi-transparent overlay + spinner text on thumbnail
overlay_pm = QPixmap(scaled.size())
overlay_pm.fill(Qt.GlobalColor.transparent)
p = QPainter(overlay_pm)
p.drawPixmap(0, 0, scaled)
p.fillRect(overlay_pm.rect(), QColor(0, 0, 0, 100))
p.setPen(QColor(255, 255, 255))
p.setFont(QFont("sans-serif", 11))
p.drawText(overlay_pm.rect(), Qt.AlignmentFlag.AlignCenter,
"\u2B06 Uploading...")
p.end()
img_label = QLabel()
img_label.setPixmap(overlay_pm)
img_label.setStyleSheet("background: transparent;")
else:
img_label = QLabel()
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)
if is_uploading:
upload_label = QLabel(
f''
f'Encrypting and uploading...'
)
upload_label.setTextFormat(Qt.TextFormat.RichText)
upload_label.setStyleSheet("background: transparent;")
blay.addWidget(upload_label)
else:
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:
if text.strip():
self.bridge.send_typing_start(self.current_conv_id)
else:
self.bridge.send_typing_stop(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
conv_id = self.current_conv_id
reply_to = self.reply_to_id
self.msg_input.clear()
self.bridge.send_typing_stop(conv_id, force=True)
self.reply_to_id = None
self._reply_widget.setVisible(False)
# Optimistic display — show message immediately before server confirms
my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
optimistic_payload = {
"sender": self.bridge.client.username,
"text": text,
"reply_to": reply_to,
"message_id": "", # Filled when server responds
"created_at": "",
"sender_id": my_uid,
"conversation_id": conv_id,
"read_by": [],
"delivered_to": [],
"_optimistic": True, # Marker for optimistic message
}
if conv_id == self.current_conv_id:
self.current_messages.append(optimistic_payload)
idx = len(self.current_messages) - 1
w = self._create_message_widget(optimistic_payload, idx)
self._msg_layout.addWidget(w)
self._msg_widgets.append(w)
if self._is_near_bottom:
QTimer.singleShot(10, self._scroll_to_bottom)
self.bridge.send_message(conv_id, text, conv["members"], reply_to=reply_to)
def _on_message_sent(self, ok, msg):
if not ok:
self._remove_optimistic()
QMessageBox.warning(self, "Error", msg)
def _on_message_queued(self, count: int):
"""Show status bar feedback when messages are queued for retry after reconnect."""
t = c()
if count > 0:
label = "1 message" if count == 1 else f"{count} messages"
self.status_bar.setText(f"{label} queued — will send when reconnected")
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
else:
# Queue was flushed (messages retried after reconnect)
self._clear_status_bar()
def _remove_optimistic(self, opt_type: str = ""):
"""Remove the last optimistic message of given type from UI."""
for i in range(len(self.current_messages) - 1, -1, -1):
m = self.current_messages[i]
if m.get("_optimistic") and (not opt_type or m.get("_optimistic_type", "") == opt_type):
self.current_messages.pop(i)
if i < len(self._msg_widgets):
w = self._msg_widgets.pop(i)
self._msg_layout.removeWidget(w)
w.deleteLater()
break
def _show_optimistic_attachment(self, conv_id: str, is_image: bool, filename: str = "",
reply_to: str | None = None,
image_path: str | None = None):
"""Show an optimistic placeholder for image/file being uploaded."""
my_uid = self.bridge.client.session.get("user_id", "") if self.bridge.client.session else ""
optimistic_payload = {
"sender": self.bridge.client.username,
"text": "" if is_image else f"Sending {filename}...",
"reply_to": reply_to,
"message_id": "",
"created_at": "",
"sender_id": my_uid,
"conversation_id": conv_id,
"read_by": [],
"delivered_to": [],
"_optimistic": True,
"_optimistic_type": "image" if is_image else "file",
}
# For images, generate a local thumbnail for immediate preview
if is_image and image_path:
try:
raw = open(image_path, "rb").read()
qimg = QImage()
qimg.loadFromData(raw)
if not qimg.isNull():
thumb = qimg.scaled(
200, 200,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
buf = QBuffer()
buf.open(QBuffer.OpenModeFlag.WriteOnly)
thumb.save(buf, "JPEG", 60)
from protocol import encode_binary
thumb_b64 = encode_binary(bytes(buf.data()))
display_name = filename or os.path.basename(image_path)
optimistic_payload["image"] = {
"file_id": "",
"thumbnail": thumb_b64,
"filename": display_name,
"size": os.path.getsize(image_path),
}
optimistic_payload["text"] = ""
optimistic_payload["_uploading"] = True
else:
logger.warning("QImage failed to load: %s", image_path)
optimistic_payload["text"] = "Sending image..."
except Exception as exc:
logger.warning("Optimistic thumbnail failed: %s", exc)
optimistic_payload["text"] = "Sending image..."
if conv_id == self.current_conv_id:
self.current_messages.append(optimistic_payload)
idx = len(self.current_messages) - 1
w = self._create_message_widget(optimistic_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_message_sent_payload(self, conv_id, payload):
"""Update optimistic message with server-confirmed data, or append if no optimistic."""
# 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
msg_id = payload.get("message_id", "")
# Avoid duplicate if notification arrived first (race)
if msg_id:
for m in self.current_messages:
if m.get("message_id") == msg_id:
return
# Find and update the optimistic message
# Match by: text content (text msgs), or _optimistic_type (image/file)
is_image_payload = bool(payload.get("image"))
is_file_payload = bool(payload.get("file"))
for i, m in enumerate(self.current_messages):
if not m.get("_optimistic"):
continue
opt_type = m.get("_optimistic_type", "")
matched = False
if opt_type == "image" and is_image_payload:
matched = True
elif opt_type == "file" and is_file_payload:
matched = True
elif not opt_type and m.get("text") == payload.get("text"):
matched = True
if matched:
# Upgrade optimistic → confirmed
self.current_messages[i] = payload
# Rebuild the widget for this message
if i < len(self._msg_widgets):
old_w = self._msg_widgets[i]
new_w = self._create_message_widget(payload, i)
self._msg_layout.replaceWidget(old_w, new_w)
old_w.deleteLater()
self._msg_widgets[i] = new_w
return
# No optimistic found — append normally (e.g. image/file sends)
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):
use_qr = QMessageBox.question(
self,
"Authorize Device",
"Load a pairing QR image?\n\n"
"Choose Yes to select an image file with the QR from the new device.\n"
"Choose No to enter the pairing code and fingerprint manually.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Yes,
)
code = ""
fingerprint = ""
if use_qr == QMessageBox.StandardButton.Cancel:
return
if use_qr == QMessageBox.StandardButton.Yes:
path, _ = QFileDialog.getOpenFileName(
self,
"Select Pairing QR Image",
"",
"Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)",
)
if not path:
return
qr_data = _decode_qr_image_file(path)
if qr_data is None:
QMessageBox.warning(
self,
"Authorize Device",
"Could not decode pairing QR code. Install 'pyzbar' or use manual entry.",
)
return
try:
code, fingerprint = decode_pairing_qr(qr_data)
except ValueError as e:
QMessageBox.warning(self, "Authorize Device", f"Invalid pairing QR code: {e}")
return
else:
code, ok = QInputDialog.getText(self, "Authorize Device", "Pairing code:")
if not ok or not code.strip():
return
fingerprint, ok = QInputDialog.getText(
self,
"Authorize Device",
"Fingerprint shown on the new device\n(spaces optional):",
)
if not ok or not fingerprint.strip():
return
self.bridge.authorize_device(code.strip(), fingerprint.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", "")
sender_id = payload.get("sender_id", "")
if conv_id and sender_id:
self._expire_typing_user(conv_id, sender_id)
timer = self._typing_timers.get((conv_id, sender_id))
if timer:
timer.stop()
# 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 _display_name_for_user(self, conv_id: str, user_id: str, fallback: str = "") -> str:
for conv in self.conversations:
if conv.get("conversation_id") != conv_id:
continue
for member in conv.get("members", []):
member_id = member.get("user_id") or member.get("id")
if member_id == user_id:
return member.get("username") or member.get("email") or fallback or "Someone"
return fallback or "Someone"
def _typing_label_refresh(self):
if not self.current_conv_id:
self.typing_label.setVisible(False)
self.typing_label.setText("")
return
users = list(self._typing_users.get(self.current_conv_id, {}).values())
if not users:
self.typing_label.setVisible(False)
self.typing_label.setText("")
return
if len(users) == 1:
text = f"{users[0]} is typing..."
elif len(users) == 2:
text = f"{users[0]} and {users[1]} are typing..."
else:
text = f"{users[0]} and {len(users) - 1} others are typing..."
self.typing_label.setText(text)
self.typing_label.setVisible(True)
def _on_typing_start(self, data):
conv_id = data.get("conversation_id", "")
user_id = data.get("user_id", "")
if not conv_id or not user_id:
return
if self.bridge.client.session and user_id == self.bridge.client.session.get("user_id"):
return
username = data.get("username") or self._display_name_for_user(conv_id, user_id, "Someone")
conv_users = self._typing_users.setdefault(conv_id, {})
conv_users[user_id] = username
key = (conv_id, user_id)
timer = self._typing_timers.get(key)
if timer is None:
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(lambda c=conv_id, u=user_id: self._expire_typing_user(c, u))
self._typing_timers[key] = timer
timer.start(3000)
if conv_id == self.current_conv_id:
self._typing_label_refresh()
def _expire_typing_user(self, conv_id: str, user_id: str):
users = self._typing_users.get(conv_id)
if users and user_id in users:
users.pop(user_id, None)
if not users:
self._typing_users.pop(conv_id, None)
if conv_id == self.current_conv_id:
self._typing_label_refresh()
def _on_typing_stop(self, data):
conv_id = data.get("conversation_id", "")
user_id = data.get("user_id", "")
if not conv_id or not user_id:
return
self._expire_typing_user(conv_id, user_id)
key = (conv_id, user_id)
timer = self._typing_timers.get(key)
if timer:
timer.stop()
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()
reply_to = self.reply_to_id
is_image = ext in self._IMAGE_EXTENSIONS
filename = os.path.basename(path)
self._show_optimistic_attachment(self.current_conv_id, is_image=is_image,
filename=filename, reply_to=reply_to,
image_path=path if is_image else None)
if is_image:
self.bridge.send_image(
self.current_conv_id, path, conv["members"],
reply_to=reply_to,
)
else:
self.bridge.send_file(
self.current_conv_id, path, conv["members"],
reply_to=reply_to,
)
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
reply_to = self.reply_to_id
self._show_optimistic_attachment(self.current_conv_id, is_image=True,
reply_to=reply_to, image_path=path)
self.bridge.send_image(
self.current_conv_id, path, conv["members"],
reply_to=reply_to,
)
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
import os
reply_to = self.reply_to_id
filename = os.path.basename(path)
self._show_optimistic_attachment(self.current_conv_id, is_image=False,
filename=filename, reply_to=reply_to)
self.bridge.send_file(
self.current_conv_id, path, conv["members"],
reply_to=reply_to,
)
self.reply_to_id = None
self._reply_widget.setVisible(False)
def _on_file_sent(self, ok, msg):
if not ok:
self._remove_optimistic("file")
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:
self._remove_optimistic("image")
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 _on_image_download_failed(self, file_id):
if self._pending_image_download and self._pending_image_download["file_id"] == file_id:
self._pending_image_download = None
self.statusBar().showMessage("Image download failed.", 5000)
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.status_label.setText(f"Connected to {bridge.client.server_endpoint()}")
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._reg_email_input.text().strip(),
login_win.username_input.text().strip(),
code.strip(),
)
if okc:
login_win.show_success(msgc)
bridge.do_login(
login_win._reg_email_input.text().strip(),
login_win._reg_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, fingerprint, qr_data):
login_win._set_enabled(True)
login_win.show_success(
"Pairing code: "
f"{code}\n"
f"Pairing fingerprint:\n{fingerprint}\n"
f"Server: {bridge.client.server_endpoint()}\n"
"Approve it on an already logged-in device.\n"
"Never share this pairing code."
)
login_win.show_pairing_qr(code, fingerprint, qr_data)
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()