Files
Kecalek_python/zaloha/gui_client.py
2026-03-11 16:54:14 +01:00

3336 lines
134 KiB
Python

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