rrc-gui/rrc_gui/gui.py

1938 lines
70 KiB
Python

"""Main GUI frame for RRC client."""
from __future__ import annotations
import json
import logging
import os
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
import cbor2
import RNS
import wx
import wx.richtext
from .client import Client, ClientConfig, parse_hash
from .config import load_config, save_config
from .constants import (
B_JOINED_USERS,
B_WELCOME_GREETING,
B_WELCOME_HUB,
K_BODY,
K_ID,
K_NICK,
K_ROOM,
K_SRC,
)
from .dialogs import (
ConfigurationDialog,
ConnectionDialog,
DiscoveredHubsDialog,
RestartDialog,
)
from .state import StateManager
from .theme import get_theme_colors
from .ui_constants import (
BUTTON_WIDTH,
CONNECTION_TIMEOUT,
DEFAULT_BORDER,
INPUT_HISTORY_SIZE,
MAX_MESSAGE_LENGTH,
MAX_MESSAGES_PER_ROOM,
PENDING_MESSAGE_TIMEOUT,
RATE_LIMIT_MESSAGES_PER_MINUTE,
RATE_LIMIT_WARNING_THRESHOLD,
ROOM_LIST_WIDTH,
USER_LIST_WIDTH,
)
from .utils import load_or_create_identity, normalize_room_name, sanitize_display_name
logger = logging.getLogger(__name__)
STALE_HUB_THRESHOLD_SECONDS = 3600
MAX_ANNOUNCE_DATA_SIZE = 10240
MAX_TIMESTAMP_SKEW_SECONDS = 300
_load_config = load_config
_save_config = save_config
_get_theme_colors = get_theme_colors
_load_or_create_identity = load_or_create_identity
_normalize_room_name = normalize_room_name
class HubAnnounceHandler:
"""Handler for RRC hub announcements on the Reticulum network."""
def __init__(self, main_frame):
"""Initialize the announce handler.
Args:
main_frame: Reference to the MainFrame instance
"""
self.main_frame = main_frame
self.aspect_filter = "rrc.hub"
def received_announce(
self,
destination_hash: bytes,
announced_identity: RNS.Identity, # noqa: ARG002
app_data: bytes,
) -> None:
"""Handle received announces from the network.
Args:
destination_hash: Hash of the announcing destination
announced_identity: Identity that made the announcement (unused)
app_data: Application data from the announcement
"""
try:
hash_hex = destination_hash.hex()
hub_name = None
if app_data:
if len(app_data) > MAX_ANNOUNCE_DATA_SIZE:
logger.warning(
f"Ignoring announce with oversized app_data: {len(app_data)} bytes"
)
return
try:
decoded = cbor2.loads(app_data)
if isinstance(decoded, dict):
if decoded.get("proto") == "rrc" and "hub" in decoded:
hub_name = (
decoded["hub"]
if isinstance(decoded["hub"], str)
else None
)
else:
hub_name = (
decoded.get("name")
if isinstance(decoded.get("name"), str)
else (
decoded.get("hub")
if isinstance(decoded.get("hub"), str)
else None
)
)
elif isinstance(decoded, str):
hub_name = decoded if len(decoded) <= 200 else None
except Exception:
try:
hub_name = app_data.decode("utf-8")
except Exception:
pass
if not hub_name:
hub_name = f"Hub {hash_hex[:8]}"
sanitized_hub_name = sanitize_display_name(hub_name, max_length=200)
if not sanitized_hub_name:
sanitized_hub_name = f"Hub {hash_hex[:8]}"
logger.info(
f"Discovered RRC hub: {sanitized_hub_name} ({hash_hex[:16]}...)"
)
self.main_frame.discovered_hubs[hash_hex] = {
"hash": hash_hex,
"name": sanitized_hub_name,
"last_seen": time.time(),
}
wx.CallAfter(self.main_frame._save_discovered_hubs)
except Exception as e:
logger.exception(f"Error processing announcement: {e}")
class MainFrame(wx.Frame):
"""Main chat window."""
def __init__(self):
super().__init__(None, title="RRC Client", size=(900, 600))
self.state_manager = StateManager()
theme_colors = get_theme_colors()
self.COLOR_OWN_MESSAGE = theme_colors["own_message"]
self.COLOR_NOTICE = theme_colors["notice"]
self.COLOR_ERROR = theme_colors["error"]
self.COLOR_SYSTEM = theme_colors["system"]
self.client: Client | None = None
self.active_room: str | None = None
self.nickname_map: dict[str, str] = {}
self.own_identity_hash: str | None = None
self.current_configdir: str | None = None
self.is_connecting: bool = False
self.pending_messages: dict[bytes, tuple[str, str, float, int | None]] = {}
self.room_messages: dict[
str, list[tuple[str, wx.Colour | None, bool, bool]]
] = {}
self.HUB_ROOM = "[Hub]"
self.room_messages[self.HUB_ROOM] = []
self.room_users: dict[str, set[str]] = {}
self.unread_counts: dict[str, int] = {}
self.message_send_times: list[float] = []
self.input_history: list[str] = []
self.input_history_index: int = -1
self.input_buffer: str = ""
self.last_ping_time: float | None = None
self.latency_ms: int | None = None
self.room_operation_times: dict[str, list[float]] = {}
self.room_op_rate_limit = 10
self.room_op_rate_window = 5.0
self.discovered_hubs: dict[str, dict] = {}
self.announce_handler: HubAnnounceHandler | None = None
self.hub_cache_path = Path.home() / ".rrc-gui" / "discovered_hubs.json"
self._initialize_reticulum()
window_state = self.state_manager.get_window_state()
if window_state.get("position"):
self.SetPosition(tuple(window_state["position"]))
if window_state.get("size"):
self.SetSize(tuple(window_state["size"]))
if window_state.get("maximized"):
self.Maximize()
self._create_menu_bar()
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.HORIZONTAL)
left_box = wx.BoxSizer(wx.VERTICAL)
room_label = wx.StaticText(panel, label="Rooms:")
left_box.Add(room_label, flag=wx.ALL, border=DEFAULT_BORDER)
self.room_list = wx.ListBox(panel, size=(ROOM_LIST_WIDTH, -1))
self.room_list.Bind(wx.EVT_LISTBOX, self.on_room_select)
self.room_list.Append(self.HUB_ROOM)
self.room_list.SetSelection(0)
self.active_room = self.HUB_ROOM
left_box.Add(
self.room_list, proportion=1, flag=wx.EXPAND | wx.ALL, border=DEFAULT_BORDER
)
room_btn_box = wx.BoxSizer(wx.HORIZONTAL)
self.join_btn = wx.Button(panel, label="Join", size=(BUTTON_WIDTH, -1))
self.part_btn = wx.Button(panel, label="Part", size=(BUTTON_WIDTH, -1))
self.join_btn.Bind(wx.EVT_BUTTON, self.on_join_room)
self.part_btn.Bind(wx.EVT_BUTTON, self.on_part_room)
room_btn_box.Add(self.join_btn, flag=wx.RIGHT, border=DEFAULT_BORDER)
room_btn_box.Add(self.part_btn)
left_box.Add(room_btn_box, flag=wx.ALL | wx.ALIGN_CENTER, border=DEFAULT_BORDER)
main_sizer.Add(left_box, flag=wx.EXPAND)
right_box = wx.BoxSizer(wx.VERTICAL)
self.active_room_label = wx.StaticText(panel, label="Active room: [Hub]")
right_box.Add(self.active_room_label, flag=wx.ALL, border=DEFAULT_BORDER)
self.message_display = wx.richtext.RichTextCtrl(
panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_WORDWRAP
)
right_box.Add(
self.message_display,
proportion=1,
flag=wx.EXPAND | wx.ALL,
border=DEFAULT_BORDER,
)
input_box = wx.BoxSizer(wx.HORIZONTAL)
self.message_input = wx.TextCtrl(panel, style=wx.TE_PROCESS_ENTER)
self.message_input.Bind(wx.EVT_TEXT_ENTER, self.on_send_message)
self.message_input.Bind(wx.EVT_KEY_DOWN, self.on_input_key_down)
input_box.Add(self.message_input, proportion=1, flag=wx.EXPAND)
self.send_btn = wx.Button(panel, label="Send")
self.send_btn.Bind(wx.EVT_BUTTON, self.on_send_message)
input_box.Add(self.send_btn, flag=wx.LEFT, border=DEFAULT_BORDER)
right_box.Add(input_box, flag=wx.EXPAND | wx.ALL, border=DEFAULT_BORDER)
main_sizer.Add(right_box, proportion=1, flag=wx.EXPAND)
self.users_outer_box = wx.BoxSizer(wx.VERTICAL)
self.users_panel = wx.Panel(panel)
users_box = wx.BoxSizer(wx.VERTICAL)
users_label = wx.StaticText(self.users_panel, label="Users:")
users_box.Add(users_label, flag=wx.ALL, border=DEFAULT_BORDER)
self.users_list = wx.ListBox(self.users_panel, size=(USER_LIST_WIDTH, -1))
users_box.Add(
self.users_list,
proportion=1,
flag=wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM,
border=DEFAULT_BORDER,
)
self.users_panel.SetSizer(users_box)
self.users_outer_box.Add(self.users_panel, proportion=1, flag=wx.EXPAND)
self.users_panel.Hide()
self.users_panel_in_sizer = False
panel.SetSizer(main_sizer)
self.CreateStatusBar()
self.SetStatusText("Not connected")
self._set_controls_enabled(False)
self.Bind(wx.EVT_CLOSE, self.on_close)
self.Bind(wx.EVT_CHAR_HOOK, self.on_key_press)
self.pending_check_timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, self._check_pending_timeouts, self.pending_check_timer)
self.pending_check_timer.Start(5000)
self.status_update_timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, self._update_status_display, self.status_update_timer)
self.status_update_timer.Start(1000)
def _initialize_reticulum(self):
"""Initialize Reticulum at startup."""
try:
config = _load_config()
configdir = config.get("configdir") or None
if RNS.Reticulum.get_instance() is None:
RNS.Reticulum(configdir=configdir)
self.current_configdir = configdir
self._load_discovered_hubs()
self.announce_handler = HubAnnounceHandler(self)
RNS.Transport.register_announce_handler(self.announce_handler)
logger.info("Hub discovery announce handler registered")
self._cleanup_stale_hubs()
except Exception as e:
wx.MessageBox(
f"Failed to initialize Reticulum: {e}\n\n"
f"You will not be able to connect to hubs until you restart.",
"Reticulum Warning",
wx.OK | wx.ICON_WARNING,
)
def _load_discovered_hubs(self):
"""Load discovered hubs from cache file."""
try:
if self.hub_cache_path.exists():
file_size = self.hub_cache_path.stat().st_size
if file_size > 1024 * 1024:
logger.warning("Hub cache file too large, resetting")
self.discovered_hubs = {}
return
with open(self.hub_cache_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
logger.warning("Hub cache has invalid format, resetting")
self.discovered_hubs = {}
return
validated_hubs = {}
for hash_hex, hub in data.items():
if not isinstance(hub, dict):
continue
if not all(key in hub for key in ["hash", "name", "last_seen"]):
continue
if not isinstance(hub.get("last_seen"), (int, float)):
continue
current_time = time.time()
last_seen = hub["last_seen"]
if (
last_seen < 0
or last_seen > current_time + MAX_TIMESTAMP_SKEW_SECONDS
):
continue
validated_hubs[hash_hex] = hub
self.discovered_hubs = validated_hubs
logger.info(
f"Loaded {len(self.discovered_hubs)} discovered hub(s) from cache"
)
except json.JSONDecodeError:
logger.error("Hub cache file is corrupted")
self.discovered_hubs = {}
except Exception as e:
logger.error(f"Failed to load discovered hubs: {e}")
self.discovered_hubs = {}
def _save_discovered_hubs(self):
"""Save discovered hubs to cache file."""
try:
self.hub_cache_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.hub_cache_path, "w", encoding="utf-8") as f:
json.dump(self.discovered_hubs, f, indent=2)
logger.debug(
f"Saved {len(self.discovered_hubs)} discovered hub(s) to cache"
)
except Exception as e:
logger.error(f"Failed to save discovered hubs: {e}")
def _cleanup_stale_hubs(self):
"""Remove hubs that haven't been seen in over 1 hour."""
current_time = time.time()
stale_hubs = [
hash_hex
for hash_hex, hub in self.discovered_hubs.items()
if current_time - hub.get("last_seen", 0) > STALE_HUB_THRESHOLD_SECONDS
]
for hash_hex in stale_hubs:
del self.discovered_hubs[hash_hex]
logger.info(f"Removed stale hub: {hash_hex[:16]}...")
if stale_hubs:
self._save_discovered_hubs()
def _create_menu_bar(self):
menu_bar = wx.MenuBar()
file_menu = wx.Menu()
self.connect_menu_item = file_menu.Append(
wx.ID_ANY, "&Connect\tCtrl-N", "Connect to hub"
)
self.disconnect_menu_item = file_menu.Append(
wx.ID_ANY, "&Disconnect\tCtrl-D", "Disconnect from hub"
)
file_menu.AppendSeparator()
discovered_hubs_item = file_menu.Append(
wx.ID_ANY, "Discovered &Hubs...", "View and connect to discovered hubs"
)
file_menu.AppendSeparator()
config_item = file_menu.Append(
wx.ID_PREFERENCES, "&Configuration...", "Configure application settings"
)
file_menu.AppendSeparator()
quit_item = file_menu.Append(wx.ID_EXIT, "&Quit\tCtrl-Q", "Quit application")
self.Bind(wx.EVT_MENU, self.on_connect_menu, self.connect_menu_item)
self.Bind(wx.EVT_MENU, self.on_disconnect_menu, self.disconnect_menu_item)
self.Bind(wx.EVT_MENU, self.on_discovered_hubs, discovered_hubs_item)
self.Bind(wx.EVT_MENU, self.on_configuration, config_item)
self.Bind(wx.EVT_MENU, self.on_quit, quit_item)
self.disconnect_menu_item.Enable(False)
menu_bar.Append(file_menu, "&File")
self.SetMenuBar(menu_bar)
def _on_connection_success(self):
"""Handle successful connection."""
self.is_connecting = False
self._update_status_display()
self._set_controls_enabled(True)
self.connect_menu_item.Enable(False)
self.disconnect_menu_item.Enable(True)
def _on_connection_failed(self, error_msg: str):
"""Handle connection failure."""
self.is_connecting = False
self.client = None
self._update_status_display()
self.connect_menu_item.Enable(True)
self.disconnect_menu_item.Enable(False)
wx.MessageBox(error_msg, "Connection Error", wx.OK | wx.ICON_ERROR)
def _update_status_display(self, event=None):
"""Update status bar with connection state and pending message count."""
if self.is_connecting:
icon = "🟡"
status = "Connecting..."
elif self.client:
icon = "🟢"
status = "Connected"
else:
icon = "🔴"
status = "Not connected"
pending_count = len(self.pending_messages)
if pending_count > 0:
status += f" | Sending: {pending_count}"
if self.latency_ms is not None and self.client:
status += f" | {self.latency_ms}ms"
self.SetStatusText(f"{icon} {status}")
def on_input_key_down(self, event):
"""Handle input history navigation with up/down arrows."""
keycode = event.GetKeyCode()
if keycode == wx.WXK_UP:
if self.input_history:
if self.input_history_index == -1:
self.input_buffer = self.message_input.GetValue()
self.input_history_index = len(self.input_history) - 1
elif self.input_history_index > 0:
self.input_history_index -= 1
if 0 <= self.input_history_index < len(self.input_history):
self.message_input.SetValue(
self.input_history[self.input_history_index]
)
self.message_input.SetInsertionPointEnd()
return
elif keycode == wx.WXK_DOWN:
if self.input_history_index != -1:
if self.input_history_index < len(self.input_history) - 1:
self.input_history_index += 1
self.message_input.SetValue(
self.input_history[self.input_history_index]
)
self.message_input.SetInsertionPointEnd()
else:
self.input_history_index = -1
self.message_input.SetValue(self.input_buffer)
self.message_input.SetInsertionPointEnd()
return
event.Skip()
def on_key_press(self, event):
"""Handle keyboard shortcuts."""
keycode = event.GetKeyCode()
if event.AltDown() and ord("1") <= keycode <= ord("9"):
room_index = keycode - ord("1")
if room_index < self.room_list.GetCount():
self.room_list.SetSelection(room_index)
room = self.room_list.GetString(room_index)
self._set_active_room(room)
return
if event.AltDown():
if keycode == wx.WXK_UP:
current = self.room_list.GetSelection()
if current > 0:
self.room_list.SetSelection(current - 1)
room = self.room_list.GetString(current - 1)
self._set_active_room(room)
return
elif keycode == wx.WXK_DOWN:
current = self.room_list.GetSelection()
if current < self.room_list.GetCount() - 1:
self.room_list.SetSelection(current + 1)
room = self.room_list.GetString(current + 1)
self._set_active_room(room)
return
event.Skip()
def _check_pending_timeouts(self, event):
"""Check for pending messages that have timed out and mark them as failed."""
if not self.pending_messages:
return
current_time = time.time()
timed_out = []
for mid, (room, text, sent_time, index) in list(self.pending_messages.items()):
if current_time - sent_time <= PENDING_MESSAGE_TIMEOUT:
continue
timed_out.append(mid)
if room not in self.room_messages:
continue
messages = self.room_messages[room]
message_index: int | None = None
if isinstance(index, int) and 0 <= index < len(messages):
msg_text, msg_color, _msg_bold, msg_italic = messages[index]
if msg_italic and msg_color == self.COLOR_SYSTEM and text in msg_text:
message_index = index
if message_index is None:
for i in range(len(messages) - 1, -1, -1):
msg_text, msg_color, _msg_bold, msg_italic = messages[i]
if (
msg_italic
and msg_color == self.COLOR_SYSTEM
and text in msg_text
):
message_index = i
break
if message_index is None:
continue
timestamp = datetime.now().strftime("%H:%M:%S")
user = (
self._format_user(bytes.fromhex(self.own_identity_hash))
if self.own_identity_hash
else "you"
)
messages[message_index] = (
f"[{timestamp}] [{room}] {user}: {text} [FAILED - not delivered]\n",
self.COLOR_ERROR,
False,
True,
)
if room == self.active_room:
self._reload_room_messages()
for mid in timed_out:
self.pending_messages.pop(mid, None)
def _update_theme_colors(self):
"""Update color constants based on current theme."""
theme_colors = _get_theme_colors()
self.COLOR_OWN_MESSAGE = theme_colors["own_message"]
self.COLOR_NOTICE = theme_colors["notice"]
self.COLOR_ERROR = theme_colors["error"]
self.COLOR_SYSTEM = theme_colors["system"]
def _set_controls_enabled(self, enabled: bool):
"""Enable/disable controls based on connection state."""
self.room_list.Enable(enabled)
self.join_btn.Enable(enabled)
self.part_btn.Enable(enabled)
self.message_input.Enable(enabled)
self.send_btn.Enable(enabled)
def _format_user(self, src: bytes | bytearray | str) -> str:
"""Format a user identity for display, using nickname if available."""
if not isinstance(src, (bytes, bytearray)):
return str(src)
src_hex_full = src.hex()
src_hex_short = src_hex_full[:12]
if self.own_identity_hash and src_hex_full == self.own_identity_hash:
own_nick = self.nickname_map.get(src_hex_full, "")
if own_nick:
return f"{own_nick} (you)"
return f"{src_hex_short}… (you)"
nick: str | None = self.nickname_map.get(src_hex_full)
if nick:
return f"{nick} <{src_hex_short}…>"
return f"{src_hex_short}"
def _append_styled_message(
self,
text: str,
color: wx.Colour | None = None,
bold: bool = False,
italic: bool = False,
room: str | None = None,
) -> int:
"""Append text to message display with styling and store in room history."""
target_room = room or self.active_room or self.HUB_ROOM
if target_room not in self.room_messages:
self.room_messages[target_room] = []
self.room_messages[target_room].append((text, color, bold, italic))
appended_index = len(self.room_messages[target_room]) - 1
if target_room != self.active_room and target_room != self.HUB_ROOM:
self.unread_counts[target_room] = self.unread_counts.get(target_room, 0) + 1
wx.CallAfter(self._update_room_list_display)
if len(self.room_messages[target_room]) > MAX_MESSAGES_PER_ROOM:
dropped = len(self.room_messages[target_room]) - MAX_MESSAGES_PER_ROOM
self.room_messages[target_room] = self.room_messages[target_room][
-MAX_MESSAGES_PER_ROOM:
]
appended_index = max(0, appended_index - dropped)
if self.pending_messages and dropped > 0:
for mid, (
pending_room,
pending_text,
pending_sent,
pending_index,
) in list(self.pending_messages.items()):
if pending_room != target_room:
continue
if pending_index is None:
continue
new_index = pending_index - dropped
self.pending_messages[mid] = (
pending_room,
pending_text,
pending_sent,
new_index if new_index >= 0 else None,
)
if target_room != self.active_room:
return appended_index
if not self.IsShown() or self.message_display.GetSize().GetWidth() <= 0:
return appended_index
self.message_display.MoveEnd()
attr = wx.richtext.RichTextAttr()
if color:
attr.SetTextColour(color)
if bold:
attr.SetFontWeight(wx.FONTWEIGHT_BOLD)
if italic:
attr.SetFontStyle(wx.FONTSTYLE_ITALIC)
if color:
self.message_display.BeginTextColour(color)
if bold:
self.message_display.BeginBold()
if italic:
self.message_display.BeginItalic()
self.message_display.WriteText(text)
if italic:
self.message_display.EndItalic()
if bold:
self.message_display.EndBold()
if color:
self.message_display.EndTextColour()
self.message_display.MoveEnd()
self.message_display.ShowPosition(self.message_display.GetLastPosition())
return appended_index
def on_discovered_hubs(self, event):
"""Show discovered hubs dialog."""
if not self.discovered_hubs:
wx.MessageBox(
"No hubs have been discovered yet.\n\n"
"Hubs will appear here automatically as they announce themselves on the network.",
"No Hubs Found",
wx.OK | wx.ICON_INFORMATION,
)
return
dlg = DiscoveredHubsDialog(self, self.discovered_hubs)
result = dlg.ShowModal()
if result == wx.ID_OK:
selected_hash = dlg.get_selected_hub_hash()
if selected_hash:
self._connect_to_hub_hash(selected_hash)
dlg.Destroy()
def _connect_to_hub_hash(self, hub_hash: str):
"""Open connection dialog with pre-filled hub hash."""
config = _load_config()
config["hub_hash"] = hub_hash
_save_config(config)
dlg = ConnectionDialog(self)
if dlg.ShowModal() == wx.ID_OK:
values = dlg.get_values()
dlg.Destroy()
_save_config(values)
self.is_connecting = True
self._update_status_display()
self.connect_menu_item.Enable(False)
if RNS.Reticulum.get_instance() is None:
wx.MessageBox(
"Reticulum is not initialized.\n\n"
"Please restart the application.",
"Reticulum Error",
wx.OK | wx.ICON_ERROR,
)
self.is_connecting = False
self.connect_menu_item.Enable(True)
self.SetStatusText("Not connected")
return
configdir = values.get("configdir") or None
if configdir != self.current_configdir:
wx.MessageBox(
f"Reticulum is already initialized with a different config directory.\n"
f"Current: {self.current_configdir or '(default)'}\n"
f"Requested: {configdir or '(default)'}\n\n"
f"Please restart the application to use a different config directory.",
"Config Directory Mismatch",
wx.OK | wx.ICON_WARNING,
)
self.is_connecting = False
self.connect_menu_item.Enable(True)
return
thread = threading.Thread(
target=self._connect_thread, args=(values,), daemon=True
)
thread.start()
else:
dlg.Destroy()
def on_connect_menu(self, event):
"""Show connection dialog and connect to hub."""
if self.client or self.is_connecting:
return
dlg = ConnectionDialog(self)
result = dlg.ShowModal()
if result == wx.ID_OK:
if not dlg.Validate():
dlg.Destroy()
return
values = dlg.get_values()
dlg.Destroy()
_save_config(values)
self.is_connecting = True
self._update_status_display()
self.connect_menu_item.Enable(False)
if RNS.Reticulum.get_instance() is None:
wx.MessageBox(
"Reticulum is not initialized.\n\n"
"Please restart the application.",
"Reticulum Error",
wx.OK | wx.ICON_ERROR,
)
self.is_connecting = False
self.connect_menu_item.Enable(True)
self.SetStatusText("Not connected")
return
configdir = values.get("configdir") or None
if configdir != self.current_configdir:
wx.MessageBox(
f"Reticulum is already initialized with a different config directory.\n"
f"Current: {self.current_configdir or '(default)'}\n"
f"Requested: {configdir or '(default)'}\n\n"
f"Please restart the application to use a different config directory.",
"Config Directory Mismatch",
wx.OK | wx.ICON_WARNING,
)
self.is_connecting = False
self.connect_menu_item.Enable(True)
return
thread = threading.Thread(
target=self._connect_thread, args=(values,), daemon=True
)
thread.start()
else:
dlg.Destroy()
def _connect_thread(self, values: dict):
"""Connect to the hub (runs in background thread)."""
try:
print(f"[DEBUG] _connect_thread started with values: {values.keys()}")
wx.CallAfter(self.SetStatusText, "Connecting...")
if RNS.Reticulum.get_instance() is None:
print("[DEBUG] ERROR: Reticulum not initialized")
wx.CallAfter(
self._on_connection_failed,
"Reticulum not initialized. Please try again.",
)
return
print(f"[DEBUG] Loading identity from: {values['identity_path']}")
identity = _load_or_create_identity(values["identity_path"])
print(f"[DEBUG] Identity loaded: {identity.hash.hex()[:16]}...")
own_identity_hash = identity.hash.hex()
nickname = values.get("nickname", "")
if nickname:
print(f"[DEBUG] Set nickname: {nickname}")
print(f"[DEBUG] Creating client with dest_name={values['dest_name']}")
config = ClientConfig(dest_name=values["dest_name"])
client = Client(identity, config, nickname=nickname if nickname else None)
client.on_message = lambda env: wx.CallAfter(self._on_message, env)
client.on_notice = lambda env: wx.CallAfter(self._on_notice, env)
client.on_error = lambda env: wx.CallAfter(self._on_error, env)
client.on_welcome = lambda env: wx.CallAfter(self._on_welcome, env)
client.on_joined = lambda room, env: wx.CallAfter(
self._on_joined, room, env
)
client.on_parted = lambda room, env: wx.CallAfter(
self._on_parted, room, env
)
client.on_close = lambda: wx.CallAfter(self._on_close)
client.on_resource_warning = lambda msg: wx.CallAfter(
self._on_resource_warning, msg
)
client.on_pong = lambda env: wx.CallAfter(self._on_pong, env)
print(f"[DEBUG] Parsing hub hash: {values['hub_hash']}")
hub_hash = parse_hash(values["hub_hash"])
print(f"[DEBUG] Parsed hub hash: {hub_hash.hex()}")
print(f"[DEBUG] Calling client.connect() with timeout={CONNECTION_TIMEOUT}")
client.connect(
hub_hash, wait_for_welcome=True, timeout_s=CONNECTION_TIMEOUT
)
print("[DEBUG] client.connect() returned successfully")
auto_join_room = values.get("auto_join_room", "")
def _commit_connection() -> None:
self.client = client
self.own_identity_hash = own_identity_hash
if nickname:
self.nickname_map[own_identity_hash] = nickname
self._on_connection_success()
if auto_join_room:
room = _normalize_room_name(auto_join_room)
if room:
try:
client.join(room)
except Exception:
pass
wx.CallAfter(_commit_connection)
except OSError as e:
wx.CallAfter(self._on_connection_failed, f"Network error: {e}")
except ValueError as e:
wx.CallAfter(
self._on_connection_failed, f"Invalid connection parameter: {e}"
)
except Exception as e:
wx.CallAfter(self._on_connection_failed, f"Failed to connect: {e}")
def on_disconnect_menu(self, event):
"""Disconnect from hub."""
if self.client:
try:
self.client.close()
except Exception:
pass
self.client = None
self.is_connecting = False
self.pending_messages.clear()
self.nickname_map.clear()
self.own_identity_hash = None
self.room_list.Clear()
self.room_list.Append(self.HUB_ROOM)
self.room_list.SetSelection(0)
hub_msgs = self.room_messages.get(self.HUB_ROOM, [])
self.room_messages.clear()
self.room_messages[self.HUB_ROOM] = hub_msgs
self.room_users.clear()
self._set_active_room(self.HUB_ROOM)
self._set_controls_enabled(False)
self._update_status_display()
self.connect_menu_item.Enable(True)
self.disconnect_menu_item.Enable(False)
def on_configuration(self, event):
"""Show configuration dialog."""
dlg = ConfigurationDialog(self)
if dlg.ShowModal() == wx.ID_OK:
theme_colors = _get_theme_colors()
self.COLOR_OWN_MESSAGE = theme_colors["own_message"]
self.COLOR_NOTICE = theme_colors["notice"]
self.COLOR_ERROR = theme_colors["error"]
self.COLOR_SYSTEM = theme_colors["system"]
if self.active_room:
self._reload_room_messages()
if dlg.requires_restart():
restart_dlg = RestartDialog(self)
result = restart_dlg.ShowModal()
restart_dlg.Destroy()
if result == wx.ID_YES:
self._restart_application()
dlg.Destroy()
def _restart_application(self):
"""Restart the application."""
if hasattr(self, "pending_check_timer"):
if self.pending_check_timer and self.pending_check_timer.IsRunning():
self.pending_check_timer.Stop()
if hasattr(self, "status_update_timer"):
if self.status_update_timer and self.status_update_timer.IsRunning():
self.status_update_timer.Stop()
pos = self.GetPosition()
size = self.GetSize()
is_maximized = self.IsMaximized()
self.state_manager.save_window_state(
size=(size.width, size.height),
position=(pos.x, pos.y),
maximized=is_maximized,
)
if self.active_room and self.input_history:
self.state_manager.save_input_history(self.active_room, self.input_history)
python = sys.executable
os.execl(python, python, *sys.argv)
def on_quit(self, event):
"""Quit the application."""
self.Close()
def on_close(self, event):
"""Handle window close."""
if hasattr(self, "pending_check_timer"):
if self.pending_check_timer and self.pending_check_timer.IsRunning():
self.pending_check_timer.Stop()
if hasattr(self, "status_update_timer"):
if self.status_update_timer and self.status_update_timer.IsRunning():
self.status_update_timer.Stop()
pos = self.GetPosition()
size = self.GetSize()
is_maximized = self.IsMaximized()
self.state_manager.save_window_state(
size=(size.width, size.height),
position=(pos.x, pos.y),
maximized=is_maximized,
)
if self.active_room and self.input_history:
self.state_manager.save_input_history(self.active_room, self.input_history)
if self.client:
self.client.close()
self.Destroy()
def on_room_select(self, event):
"""Handle room selection from list."""
sel = self.room_list.GetSelection()
if sel != wx.NOT_FOUND:
room = self.room_list.GetString(sel)
self._set_active_room(room)
def _set_active_room(self, room: str):
"""Set the active room for sending messages and update display."""
if self.active_room and self.input_history:
config = _load_config()
if config.get("save_input_history", True):
self.state_manager.save_input_history(
self.active_room, self.input_history
)
self.active_room = room
self.active_room_label.SetLabel(f"Active room: {room}")
config = _load_config()
if config.get("save_input_history", True):
self.input_history = self.state_manager.get_input_history(room)
else:
self.input_history = []
self.input_history_index = -1
self.input_buffer = ""
if room in self.unread_counts:
self.unread_counts[room] = 0
self._update_room_list_display()
idx = self.room_list.FindString(room)
if idx != wx.NOT_FOUND:
self.room_list.SetSelection(idx)
panel = self.users_panel.GetParent()
main_sizer = panel.GetSizer()
if room == self.HUB_ROOM:
if self.users_panel_in_sizer:
main_sizer.Detach(self.users_outer_box)
self.users_panel.Hide()
self.users_panel_in_sizer = False
else:
if not self.users_panel_in_sizer:
main_sizer.Add(self.users_outer_box, flag=wx.EXPAND)
self.users_panel.Show()
self.users_panel_in_sizer = True
panel.Layout()
self._reload_room_messages()
def _reload_room_messages(self):
"""Reload the message display with current room's history."""
if not self.IsShown() or self.message_display.GetSize().GetWidth() <= 0:
return
self.message_display.Clear()
room = self.active_room or self.HUB_ROOM
messages = self.room_messages.get(room, [])
for text, color, bold, italic in messages:
self.message_display.MoveEnd()
if color:
self.message_display.BeginTextColour(color)
if bold:
self.message_display.BeginBold()
if italic:
self.message_display.BeginItalic()
self.message_display.WriteText(text)
if italic:
self.message_display.EndItalic()
if bold:
self.message_display.EndBold()
if color:
self.message_display.EndTextColour()
self.message_display.MoveEnd()
self.message_display.ShowPosition(self.message_display.GetLastPosition())
self._update_user_list()
def _update_room_list_display(self):
"""Update room list with unread message indicators."""
current_sel = self.room_list.GetSelection()
current_room = (
self.room_list.GetString(current_sel)
if current_sel != wx.NOT_FOUND
else None
)
rooms = [self.room_list.GetString(i) for i in range(self.room_list.GetCount())]
self.room_list.Clear()
for room in rooms:
clean_room = room.split(" (")[0] if " (" in room else room
unread = self.unread_counts.get(clean_room, 0)
if unread > 0 and clean_room != self.active_room:
display = f"{clean_room} ({unread})"
else:
display = clean_room
self.room_list.Append(display)
if current_room:
for i in range(self.room_list.GetCount()):
if self.room_list.GetString(i).startswith(current_room.split(" (")[0]):
self.room_list.SetSelection(i)
break
def _update_user_list(self):
"""Update the user list for the active room."""
self.users_list.Clear()
room = self.active_room
if not room or room == self.HUB_ROOM:
return
users = self.room_users.get(room, set())
if not users:
return
user_entries = []
for user_hash in users:
nick = self.nickname_map.get(user_hash)
if nick:
display = f"{nick} <{user_hash[:12]}…>"
else:
display = f"{user_hash[:12]}"
if user_hash == self.own_identity_hash:
display += " (you)"
user_entries.append((display, user_hash))
user_entries.sort(key=lambda x: x[0].lower())
for display, _ in user_entries:
self.users_list.Append(display)
def on_join_room(self, event):
"""Join a new room."""
if not self.client:
return
dlg = wx.TextEntryDialog(self, "Enter room name:", "Join Room")
if dlg.ShowModal() == wx.ID_OK:
raw_room = dlg.GetValue()
if " " in raw_room:
wx.MessageBox(
"Room names cannot contain spaces.\n\n"
"Use hyphens (-) or underscores (_) instead.\n"
"Example: 'test-2' or 'test_2'",
"Invalid Room Name",
wx.OK | wx.ICON_WARNING,
)
dlg.Destroy()
return
room = _normalize_room_name(raw_room)
if room:
if self.room_list.FindString(room) != wx.NOT_FOUND:
wx.MessageBox(
f"Already in room '{room}'.",
"Already Joined",
wx.OK | wx.ICON_INFORMATION,
)
else:
self.client.join(room)
dlg.Destroy()
def on_part_room(self, event):
"""Leave the active room."""
if not self.client or not self.active_room:
return
if self.active_room == self.HUB_ROOM:
return
room_to_part = self.active_room
result = wx.MessageBox(
f"Leave room '{room_to_part}'?\n\nMessage history will be preserved.",
"Confirm Part",
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION,
)
if result == wx.YES:
self.client.part(room_to_part)
def on_send_message(self, event):
"""Send a message to the active room."""
if not self.client or not self.active_room:
return
text = self.message_input.GetValue().strip()
if not text:
return
if text.startswith("/"):
self._handle_command(text)
self.message_input.Clear()
return
if self.active_room == self.HUB_ROOM:
wx.MessageBox(
"Cannot send messages to [Hub]. Join a room first or use /join <room>.",
"Hub Messages",
wx.OK | wx.ICON_INFORMATION,
)
return
current_time = time.time()
self.message_send_times = [
t for t in self.message_send_times if current_time - t < 60
]
if len(self.message_send_times) >= RATE_LIMIT_MESSAGES_PER_MINUTE:
wx.MessageBox(
f"Rate limit: maximum {RATE_LIMIT_MESSAGES_PER_MINUTE} messages per minute.\n\nPlease wait a moment before sending.",
"Sending Too Fast",
wx.OK | wx.ICON_WARNING,
)
return
elif len(self.message_send_times) >= int(
RATE_LIMIT_MESSAGES_PER_MINUTE * RATE_LIMIT_WARNING_THRESHOLD
):
self.SetStatusText(
f"WARNING: Approaching rate limit ({len(self.message_send_times)}/{RATE_LIMIT_MESSAGES_PER_MINUTE} msgs/min)"
)
if len(text) > MAX_MESSAGE_LENGTH:
wx.MessageBox(
f"Message too long ({len(text)} characters).\n\nMaximum length is {MAX_MESSAGE_LENGTH} characters.",
"Message Too Long",
wx.OK | wx.ICON_WARNING,
)
return
try:
mid = self.client.msg(self.active_room, text)
except Exception as e:
if "MessageTooLargeError" not in str(type(e).__name__):
wx.MessageBox(
f"Failed to send message: {e}",
"Send Error",
wx.OK | wx.ICON_ERROR,
)
return
if self.own_identity_hash:
timestamp = datetime.now().strftime("%H:%M:%S")
send_time = time.time()
user = self._format_user(bytes.fromhex(self.own_identity_hash))
placeholder_index = self._append_styled_message(
f"[{timestamp}] [{self.active_room}] {user}: {text}\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
self.pending_messages[mid] = (
self.active_room,
text,
send_time,
placeholder_index,
)
if text not in self.input_history or self.input_history[-1] != text:
self.input_history.append(text)
config = _load_config()
max_history = config.get("input_history_size", INPUT_HISTORY_SIZE)
if len(self.input_history) > max_history:
self.input_history.pop(0)
self.input_history_index = -1
self.input_buffer = ""
self.message_send_times.append(time.time())
self.message_input.Clear()
def _check_room_operation_rate_limit(self, operation_key: str) -> bool:
"""Check if room operation is within rate limit.
Args:
operation_key: Unique key for this operation (e.g., 'join:room_name')
Returns:
True if operation is allowed, False if rate limited
"""
now = time.time()
if operation_key not in self.room_operation_times:
self.room_operation_times[operation_key] = []
self.room_operation_times[operation_key] = [
t
for t in self.room_operation_times[operation_key]
if now - t < self.room_op_rate_window
]
if len(self.room_operation_times[operation_key]) >= self.room_op_rate_limit:
return False
self.room_operation_times[operation_key].append(now)
return True
def _handle_command(self, text: str):
"""Handle slash commands.
Args:
text: Command text starting with /
"""
parts = text.split(maxsplit=1)
cmd = parts[0].lower()
timestamp = datetime.now().strftime("%H:%M:%S")
if cmd == "/join":
if len(parts) < 2:
self._append_styled_message(
f"[{timestamp}] Usage: /join <room>\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
return
room_name = parts[1].strip()
if " " in room_name:
wx.MessageBox(
"Room names cannot contain spaces.\n\n"
"Use hyphens (-) or underscores (_) instead.\n"
"Example: 'test-2' or 'test_2'",
"Invalid Room Name",
wx.OK | wx.ICON_WARNING,
)
return
room = _normalize_room_name(room_name)
if room:
if self.room_list.FindString(room) != wx.NOT_FOUND:
self._append_styled_message(
f"[{timestamp}] Already in room '{room}'\n",
color=self.COLOR_NOTICE,
room=self.active_room,
)
else:
if not self._check_room_operation_rate_limit(f"join:{room}"):
self._append_styled_message(
f"[{timestamp}] Too many join requests. Please wait a moment.\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
return
if not self.client:
return
try:
self.client.join(room)
except Exception as e:
self._append_styled_message(
f"[{timestamp}] Failed to join room: {e}\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
elif cmd == "/part":
if len(parts) > 1:
part_room: str | None = _normalize_room_name(parts[1].strip())
else:
part_room = (
self.active_room if self.active_room != self.HUB_ROOM else None
)
if not part_room:
self._append_styled_message(
f"[{timestamp}] Usage: /part [room] - specify a room or use from a room window\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
return
if self.room_list.FindString(part_room) == wx.NOT_FOUND:
self._append_styled_message(
f"[{timestamp}] Not in room '{part_room}'\n",
color=self.COLOR_NOTICE,
room=self.active_room,
)
return
if not self._check_room_operation_rate_limit(f"part:{part_room}"):
self._append_styled_message(
f"[{timestamp}] Too many part requests. Please wait a moment.\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
return
if not self.client:
return
try:
self.client.part(part_room)
except Exception as e:
self._append_styled_message(
f"[{timestamp}] Failed to part room: {e}\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
elif cmd == "/nick":
if not self.client:
return
if len(parts) < 2:
current_nick = (
self.client.nickname if self.client.nickname else "(not set)"
)
self._append_styled_message(
f"[{timestamp}] Current nickname: {current_nick}\n"
f"[{timestamp}] Usage: /nick <nickname> to change it\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
return
new_nick = parts[1].strip()
if len(new_nick) > 32:
self._append_styled_message(
f"[{timestamp}] Nickname too long (max 32 characters)\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
return
if not new_nick:
self._append_styled_message(
f"[{timestamp}] Nickname cannot be empty\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
return
old_nick = self.client.nickname
self.client.nickname = new_nick
if self.own_identity_hash:
self.nickname_map[self.own_identity_hash] = new_nick
for room in self.room_users.keys():
if self.active_room == room:
wx.CallAfter(self._update_user_list)
config = _load_config()
config["nickname"] = new_nick
_save_config(config)
if old_nick:
self._append_styled_message(
f"[{timestamp}] Nickname changed from '{old_nick}' to '{new_nick}'\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
else:
self._append_styled_message(
f"[{timestamp}] Nickname set to '{new_nick}'\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
elif cmd == "/ping":
if not self.client:
return
try:
self.last_ping_time = time.time()
self.client.ping()
self._append_styled_message(
f"[{timestamp}] PING sent to hub\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
except Exception as e:
self.last_ping_time = None
self._append_styled_message(
f"[{timestamp}] Failed to send PING: {e}\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
elif cmd in ("/help", "/?"):
help_text = (
f"[{timestamp}] Available commands:\n"
" /join <room> - Join a room\n"
" /part [room] - Leave current room or specified room\n"
" /nick <name> - Change your nickname\n"
" /ping - Send a PING to the hub\n"
" /help or /? - Show this help message\n"
)
self._append_styled_message(
help_text,
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
else:
if not self.client or not self.active_room:
return
try:
self.client.msg(self.active_room, text)
self._append_styled_message(
f"[{timestamp}] > {text}\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
except Exception as e:
self._append_styled_message(
f"[{timestamp}] Failed to send command: {e}\n",
color=self.COLOR_ERROR,
room=self.active_room,
)
def _on_message(self, env: dict):
"""Handle incoming message."""
room = env.get(K_ROOM, "?")
src = env.get(K_SRC, b"")
body = env.get(K_BODY, "")
nick = env.get(K_NICK)
if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick:
src_hex = src.hex()
self.nickname_map[src_hex] = nick
if room == self.active_room and room in self.room_users:
self.room_users[room].add(src_hex)
self._update_user_list()
user = self._format_user(src)
timestamp = datetime.now().strftime("%H:%M:%S")
is_own = (
isinstance(src, (bytes, bytearray))
and self.own_identity_hash
and src.hex() == self.own_identity_hash
)
mid = env.get(K_ID)
target_room = room if room and room != "?" else self.HUB_ROOM
if is_own:
pending = None
if isinstance(mid, (bytes, bytearray)):
pending = self.pending_messages.pop(bytes(mid), None)
if pending and target_room in self.room_messages:
pending_room, pending_text, _pending_sent, pending_index = pending
messages = self.room_messages[target_room]
message_index: int | None = None
if isinstance(pending_index, int) and 0 <= pending_index < len(
messages
):
msg_text, msg_color, _msg_bold, msg_italic = messages[pending_index]
if (
msg_italic
and msg_color == self.COLOR_SYSTEM
and pending_text in msg_text
):
message_index = pending_index
if message_index is None:
for i in range(len(messages) - 1, -1, -1):
msg_text, msg_color, _msg_bold, msg_italic = messages[i]
if (
msg_italic
and msg_color == self.COLOR_SYSTEM
and pending_text in msg_text
):
message_index = i
break
if message_index is not None:
messages[message_index] = (
f"[{timestamp}] [{room}] {user}: {body}\n",
self.COLOR_OWN_MESSAGE,
False,
False,
)
if target_room == self.active_room:
self._reload_room_messages()
return
self._append_styled_message(
f"[{timestamp}] [{room}] {user}: {body}\n",
color=self.COLOR_OWN_MESSAGE,
room=target_room,
)
else:
self._append_styled_message(
f"[{timestamp}] [{room}] {user}: {body}\n", room=target_room
)
def _on_notice(self, env: dict):
"""Handle incoming notice."""
room = env.get(K_ROOM, "?")
src = env.get(K_SRC, b"")
body = env.get(K_BODY, "")
nick = env.get(K_NICK)
if isinstance(src, (bytes, bytearray)) and isinstance(nick, str) and nick:
src_hex = src.hex()
self.nickname_map[src_hex] = nick
if room == self.active_room and room in self.room_users:
self.room_users[room].add(src_hex)
self._update_user_list()
user = self._format_user(src)
timestamp = datetime.now().strftime("%H:%M:%S")
target_room = room if room and room != "?" else self.HUB_ROOM
self._append_styled_message(
f"[{timestamp}] [{room}] NOTICE {user}: {body}\n",
color=self.COLOR_NOTICE,
room=target_room,
)
def _on_error(self, env: dict):
"""Handle incoming error."""
room = env.get(K_ROOM, "?")
body = env.get(K_BODY, "")
timestamp = datetime.now().strftime("%H:%M:%S")
if body == "HELLO already sent":
print("[DEBUG] Ignoring expected HELLO retry error")
return
target_room = room if room and room != "?" else self.HUB_ROOM
self._append_styled_message(
f"[{timestamp}] ERROR [{room}]: {body}\n",
color=self.COLOR_ERROR,
bold=True,
room=target_room,
)
def _on_resource_warning(self, message: str):
"""Handle resource warning (message too large)."""
wx.MessageBox(
message,
"Message Too Large",
wx.OK | wx.ICON_WARNING,
)
def _on_pong(self, env: dict):
"""Handle PONG response from hub."""
if self.last_ping_time:
latency = int((time.time() - self.last_ping_time) * 1000)
self.latency_ms = latency
self.last_ping_time = None
self._update_status_display()
timestamp = datetime.now().strftime("%H:%M:%S")
self._append_styled_message(
f"[{timestamp}] PONG received - latency: {latency}ms\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.active_room,
)
def _on_welcome(self, env: dict):
"""Handle WELCOME message."""
timestamp = datetime.now().strftime("%H:%M:%S")
hub_name = None
greeting = None
body = env.get(K_BODY)
if isinstance(body, dict):
hub = body.get(B_WELCOME_HUB)
if isinstance(hub, str) and hub.strip():
hub_name = hub.strip()
g = body.get(B_WELCOME_GREETING)
if isinstance(g, str) and g.strip():
greeting = g
hub_txt = f" ({hub_name})" if hub_name else ""
self._append_styled_message(
f"[{timestamp}] *** WELCOME - Connected to hub{hub_txt} ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.HUB_ROOM,
)
if greeting:
self._append_styled_message(
f"[{timestamp}] *** {greeting}\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.HUB_ROOM,
)
def _on_joined(self, room: str, env: dict):
"""Handle JOINED confirmation.
Per rrcd spec:
- When YOU join: body contains list of all existing members
- When SOMEONE ELSE joins: body contains their hash (single-element list)
"""
timestamp = datetime.now().strftime("%H:%M:%S")
body = env.get(K_BODY)
logger.debug(f"JOINED room={room}, body type={type(body)}, body={body}")
user_list = None
if isinstance(body, dict):
user_list = body.get(B_JOINED_USERS)
logger.debug(f"Body is dict, user_list={user_list}")
elif isinstance(body, list):
user_list = body
logger.debug(f"Body is list directly, user_list={user_list}")
if not isinstance(user_list, list):
user_list = []
already_in_room = room in self.room_users
if not already_in_room:
if self.room_list.FindString(room) == wx.NOT_FOUND:
self.room_list.Append(room)
if room not in self.room_messages:
self.room_messages[room] = []
members = set()
for member_hash in user_list:
if isinstance(member_hash, (bytes, bytearray)):
members.add(member_hash.hex())
logger.debug(f"Added user: {member_hash.hex()[:16]}...")
self.room_users[room] = members
if self.own_identity_hash:
self.room_users[room].add(self.own_identity_hash)
member_count = len(self.room_users.get(room, set()))
self._append_styled_message(
f"[{timestamp}] *** JOINED {room} ({member_count} user{'s' if member_count != 1 else ''}) ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=room,
)
if self.active_room == self.HUB_ROOM:
self._set_active_room(room)
elif self.active_room == room:
self._update_user_list()
else:
if len(user_list) > 0:
user_hash = user_list[0]
if isinstance(user_hash, (bytes, bytearray)):
user_hex = user_hash.hex()
user_formatted = self._format_user(user_hash)
if room not in self.room_users:
self.room_users[room] = set()
self.room_users[room].add(user_hex)
self._append_styled_message(
f"[{timestamp}] *** {user_formatted} joined {room} ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=room,
)
if self.active_room == room:
self._update_user_list()
def _on_parted(self, room: str, env: dict):
"""Handle PARTED confirmation.
Per rrcd spec EX1:
All users (including the departing user) receive a PARTED message
containing only the departing user's identity hash (single-element list).
"""
try:
timestamp = datetime.now().strftime("%H:%M:%S")
body = env.get(K_BODY)
logger.debug(f"PARTED room={room}, body type={type(body)}, body={body}")
logger.debug(f"Own identity hash: {self.own_identity_hash}")
user_list = None
if isinstance(body, dict):
user_list = body.get(B_JOINED_USERS)
logger.debug(f"Body is dict, user_list={user_list}")
elif isinstance(body, list):
user_list = body
logger.debug(f"Body is list directly, user_list={user_list}")
if not isinstance(user_list, list):
user_list = []
logger.warning(f"PARTED body is not a list: {body}")
if len(user_list) == 1:
user_hash = user_list[0]
if isinstance(user_hash, (bytes, bytearray)):
user_hex = user_hash.hex()
logger.debug(f"Parting user hash: {user_hex}, is_us: {user_hex == self.own_identity_hash}")
if user_hex == self.own_identity_hash:
logger.info(f"We parted from room: {room} (new spec)")
idx = self.room_list.FindString(room)
logger.debug(f"Room list index for '{room}': {idx}")
if idx != wx.NOT_FOUND:
self.room_list.Delete(idx)
logger.debug(f"Deleted room from list at index {idx}")
else:
logger.warning(f"Room '{room}' not found in room_list!")
self._append_styled_message(
f"[{timestamp}] *** PARTED {room} ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=room,
)
if self.active_room == room:
self._set_active_room(self.HUB_ROOM)
if room in self.room_users:
del self.room_users[room]
if room in self.room_messages:
del self.room_messages[room]
else:
logger.debug(f"User {user_hex[:16]}... parted from room: {room} (new spec)")
user_formatted = self._format_user(user_hash)
if room in self.room_users:
self.room_users[room].discard(user_hex)
self._append_styled_message(
f"[{timestamp}] *** {user_formatted} left {room} ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=room,
)
if self.active_room == room:
self._update_user_list()
else:
user_hashes_in_body = set()
for user_hash in user_list:
if isinstance(user_hash, (bytes, bytearray)):
user_hashes_in_body.add(user_hash.hex())
we_are_in_body = self.own_identity_hash in user_hashes_in_body
if not we_are_in_body:
logger.info(f"We parted from room: {room} (old spec, {len(user_list)} remaining)")
idx = self.room_list.FindString(room)
if idx != wx.NOT_FOUND:
self.room_list.Delete(idx)
self._append_styled_message(
f"[{timestamp}] *** PARTED {room} ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=room,
)
if self.active_room == room:
self._set_active_room(self.HUB_ROOM)
if room in self.room_users:
del self.room_users[room]
if room in self.room_messages:
del self.room_messages[room]
else:
logger.warning(f"PARTED with multiple users ({len(user_list)}) but we're in the list - unexpected")
except Exception as e:
logger.exception(f"Exception in _on_parted handler for room {room}: {e}")
def _on_close(self):
"""Handle connection close (called from callback thread)."""
wx.CallAfter(self._handle_disconnect)
def _handle_disconnect(self):
"""Handle disconnect in main thread."""
self.client = None
self.is_connecting = False
timestamp = datetime.now().strftime("%H:%M:%S")
self._append_styled_message(
f"[{timestamp}] *** DISCONNECTED ***\n",
color=self.COLOR_SYSTEM,
italic=True,
room=self.HUB_ROOM,
)
while self.room_list.GetCount() > 1:
self.room_list.Delete(1)
for room in list(self.room_users.keys()):
del self.room_users[room]
self.pending_messages.clear()
self._set_active_room(self.HUB_ROOM)
self._set_controls_enabled(False)
self.connect_menu_item.Enable(True)
self.disconnect_menu_item.Enable(False)
self._update_status_display()