From 557af55ee82f75497a35f3813924eb3a41c06e9f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 16:56:23 -0700 Subject: [PATCH 01/27] extract backend message lifecycle service --- app/event_handlers.py | 42 +--- app/packet_processor.py | 280 +++--------------------- app/routers/messages.py | 106 +++------- app/services/__init__.py | 1 + app/services/messages.py | 447 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+), 365 deletions(-) create mode 100644 app/services/__init__.py create mode 100644 app/services/messages.py diff --git a/app/event_handlers.py b/app/event_handlers.py index a2dd087..0e1db4b 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from meshcore import EventType -from app.models import CONTACT_TYPE_REPEATER, Contact, Message, MessagePath +from app.models import CONTACT_TYPE_REPEATER, Contact from app.packet_processor import process_raw_packet from app.repository import ( AmbiguousPublicKeyPrefixError, @@ -12,6 +12,7 @@ from app.repository import ( ContactRepository, MessageRepository, ) +from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast from app.websocket import broadcast_event if TYPE_CHECKING: @@ -108,21 +109,21 @@ async def on_contact_message(event: "Event") -> None: sender_name = contact.name if contact else None path = payload.get("path") path_len = payload.get("path_len") - msg_id = await MessageRepository.create( - msg_type="PRIV", - text=payload.get("text", ""), + message = await create_fallback_direct_message( conversation_key=sender_pubkey, + text=payload.get("text", ""), sender_timestamp=sender_timestamp, received_at=received_at, path=path, path_len=path_len, txt_type=txt_type, signature=payload.get("signature"), - sender_key=sender_pubkey, sender_name=sender_name, + sender_key=sender_pubkey, + broadcast_fn=broadcast_event, ) - if msg_id is None: + if message is None: # Already handled by packet processor (or exact duplicate) - nothing more to do logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12]) return @@ -131,31 +132,6 @@ async def on_contact_message(event: "Event") -> None: # (likely because private key export is not available) logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12]) - # Build paths array for broadcast - paths = ( - [MessagePath(path=path or "", received_at=received_at, path_len=path_len)] - if path is not None - else None - ) - - # Broadcast the new message - broadcast_event( - "message", - Message( - id=msg_id, - type="PRIV", - conversation_key=sender_pubkey, - text=payload.get("text", ""), - sender_timestamp=sender_timestamp, - received_at=received_at, - paths=paths, - txt_type=txt_type, - signature=payload.get("signature"), - sender_key=sender_pubkey, - sender_name=sender_name, - ).model_dump(), - ) - # Update contact last_contacted (contact was already fetched above) if contact: await ContactRepository.update_last_contacted(sender_pubkey, received_at) @@ -307,12 +283,10 @@ async def on_ack(event: "Event") -> None: if ack_code in _pending_acks: message_id, _, _ = _pending_acks.pop(ack_code) logger.info("ACK received for message %d", message_id) - - ack_count = await MessageRepository.increment_ack_count(message_id) # DM ACKs don't carry path data, so paths is intentionally omitted. # The frontend's mergePendingAck handles the missing field correctly, # preserving any previously known paths. - broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count}) + await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event) else: logger.debug("ACK code %s does not match any pending messages", ack_code) diff --git a/app/packet_processor.py b/app/packet_processor.py index d9e5e38..d676290 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -30,8 +30,6 @@ from app.decoder import ( from app.keystore import get_private_key, get_public_key, has_private_key from app.models import ( CONTACT_TYPE_REPEATER, - Message, - MessagePath, RawPacketBroadcast, RawPacketDecryptedInfo, ) @@ -43,6 +41,12 @@ from app.repository import ( MessageRepository, RawPacketRepository, ) +from app.services.messages import ( + create_dm_message_from_decrypted as _create_dm_message_from_decrypted, +) +from app.services.messages import ( + create_message_from_decrypted as _create_message_from_decrypted, +) from app.websocket import broadcast_error, broadcast_event logger = logging.getLogger(__name__) @@ -50,77 +54,6 @@ logger = logging.getLogger(__name__) _raw_observation_counter = count(1) -async def _handle_duplicate_message( - packet_id: int, - msg_type: str, - conversation_key: str, - text: str, - sender_timestamp: int, - path: str | None, - received: int, - path_len: int | None = None, -) -> None: - """Handle a duplicate message by updating paths/acks on the existing record. - - Called when MessageRepository.create returns None (INSERT OR IGNORE hit a duplicate). - Looks up the existing message, adds the new path, increments ack count for outgoing - messages, and broadcasts the update to clients. - """ - existing_msg = await MessageRepository.get_by_content( - msg_type=msg_type, - conversation_key=conversation_key, - text=text, - sender_timestamp=sender_timestamp, - ) - if not existing_msg: - label = "message" if msg_type == "CHAN" else "DM" - logger.warning( - "Duplicate %s for %s but couldn't find existing", - label, - conversation_key[:12], - ) - return - - logger.debug( - "Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path", - msg_type, - conversation_key[:12], - existing_msg.id, - existing_msg.outgoing, - ) - - # Add path if provided - if path is not None: - paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len) - else: - # Get current paths for broadcast - paths = existing_msg.paths or [] - - # Increment ack count for outgoing messages (echo confirmation) - if existing_msg.outgoing: - ack_count = await MessageRepository.increment_ack_count(existing_msg.id) - else: - ack_count = existing_msg.acked - - # Only broadcast when something actually changed: - # - outgoing: ack count was incremented - # - path provided: a new path entry was appended - # The path=None case happens for direct-delivery DMs (0-hop, no routing bytes). - # A non-outgoing duplicate with no new path changes nothing in the DB, so skip. - if existing_msg.outgoing or path is not None: - broadcast_event( - "message_acked", - { - "message_id": existing_msg.id, - "ack_count": ack_count, - "paths": [p.model_dump() for p in paths] if paths else [], - }, - ) - - # Mark this packet as decrypted - await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id) - - async def create_message_from_decrypted( packet_id: int, channel_key: str, @@ -133,95 +66,21 @@ async def create_message_from_decrypted( channel_name: str | None = None, realtime: bool = True, ) -> int | None: - """Create a message record from decrypted channel packet content. - - This is the shared logic for storing decrypted channel messages, - used by both real-time packet processing and historical decryption. - - Args: - packet_id: ID of the raw packet being processed - channel_key: Hex string channel key - channel_name: Channel name (e.g. "#general"), for bot context - sender: Sender name (will be prefixed to message) or None - message_text: The decrypted message content - timestamp: Sender timestamp from the packet - received_at: When the packet was received (defaults to now) - path: Hex-encoded routing path - realtime: If False, skip fanout dispatch (used for historical decryption) - - Returns the message ID if created, None if duplicate. - """ - received = received_at or int(time.time()) - - # Format the message text with sender prefix if present - text = f"{sender}: {message_text}" if sender else message_text - - # Normalize channel key to uppercase for consistency - channel_key_normalized = channel_key.upper() - - # Resolve sender_key: look up contact by exact name match - resolved_sender_key: str | None = None - if sender: - candidates = await ContactRepository.get_by_name(sender) - if len(candidates) == 1: - resolved_sender_key = candidates[0].public_key - - # Try to create message - INSERT OR IGNORE handles duplicates atomically - msg_id = await MessageRepository.create( - msg_type="CHAN", - text=text, - conversation_key=channel_key_normalized, - sender_timestamp=timestamp, - received_at=received, + """Store a decrypted channel message via the shared message service.""" + return await _create_message_from_decrypted( + packet_id=packet_id, + channel_key=channel_key, + sender=sender, + message_text=message_text, + timestamp=timestamp, + received_at=received_at, path=path, path_len=path_len, - sender_name=sender, - sender_key=resolved_sender_key, - ) - - if msg_id is None: - # Duplicate message detected - this happens when: - # 1. Our own outgoing message echoes back (flood routing) - # 2. Same message arrives via multiple paths before first is committed - # In either case, add the path to the existing message. - await _handle_duplicate_message( - packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received, path_len - ) - return None - - logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8]) - - # Mark the raw packet as decrypted - await RawPacketRepository.mark_decrypted(packet_id, msg_id) - - # Build paths array for broadcast - # Use "is not None" to include empty string (direct/0-hop messages) - paths = ( - [MessagePath(path=path or "", received_at=received, path_len=path_len)] - if path is not None - else None - ) - - # Broadcast new message to connected clients (and fanout modules when realtime) - broadcast_event( - "message", - Message( - id=msg_id, - type="CHAN", - conversation_key=channel_key_normalized, - text=text, - sender_timestamp=timestamp, - received_at=received, - paths=paths, - sender_name=sender, - sender_key=resolved_sender_key, - channel_name=channel_name, - ).model_dump(), + channel_name=channel_name, realtime=realtime, + broadcast_fn=broadcast_event, ) - return msg_id - async def create_dm_message_from_decrypted( packet_id: int, @@ -234,111 +93,20 @@ async def create_dm_message_from_decrypted( outgoing: bool = False, realtime: bool = True, ) -> int | None: - """Create a message record from decrypted direct message packet content. - - This is the shared logic for storing decrypted direct messages, - used by both real-time packet processing and historical decryption. - - Args: - packet_id: ID of the raw packet being processed - decrypted: DecryptedDirectMessage from decoder - their_public_key: The contact's full 64-char public key (conversation_key) - our_public_key: Our public key (to determine direction), or None - received_at: When the packet was received (defaults to now) - path: Hex-encoded routing path - outgoing: Whether this is an outgoing message (we sent it) - realtime: If False, skip fanout dispatch (used for historical decryption) - - Returns the message ID if created, None if duplicate. - """ - # Check if sender is a repeater - repeaters only send CLI responses, not chat messages. - # CLI responses are handled by the command endpoint, not stored in chat history. - contact = await ContactRepository.get_by_key(their_public_key) - if contact and contact.type == CONTACT_TYPE_REPEATER: - logger.debug( - "Skipping message from repeater %s (CLI responses not stored): %s", - their_public_key[:12], - (decrypted.message or "")[:50], - ) - return None - - received = received_at or int(time.time()) - - # conversation_key is always the other party's public key - conversation_key = their_public_key.lower() - - # Resolve sender name for incoming messages (used for name-based blocking) - sender_name = contact.name if contact and not outgoing else None - - # Try to create message - INSERT OR IGNORE handles duplicates atomically - msg_id = await MessageRepository.create( - msg_type="PRIV", - text=decrypted.message, - conversation_key=conversation_key, - sender_timestamp=decrypted.timestamp, - received_at=received, + """Store a decrypted direct message via the shared message service.""" + return await _create_dm_message_from_decrypted( + packet_id=packet_id, + decrypted=decrypted, + their_public_key=their_public_key, + our_public_key=our_public_key, + received_at=received_at, path=path, path_len=path_len, outgoing=outgoing, - sender_key=conversation_key if not outgoing else None, - sender_name=sender_name, - ) - - if msg_id is None: - # Duplicate message detected - await _handle_duplicate_message( - packet_id, - "PRIV", - conversation_key, - decrypted.message, - decrypted.timestamp, - path, - received, - path_len, - ) - return None - - logger.info( - "Stored direct message %d for contact %s (outgoing=%s)", - msg_id, - conversation_key[:12], - outgoing, - ) - - # Mark the raw packet as decrypted - await RawPacketRepository.mark_decrypted(packet_id, msg_id) - - # Build paths array for broadcast - paths = ( - [MessagePath(path=path or "", received_at=received, path_len=path_len)] - if path is not None - else None - ) - - # Broadcast new message to connected clients (and fanout modules when realtime) - sender_name = contact.name if contact and not outgoing else None - broadcast_event( - "message", - Message( - id=msg_id, - type="PRIV", - conversation_key=conversation_key, - text=decrypted.message, - sender_timestamp=decrypted.timestamp, - received_at=received, - paths=paths, - outgoing=outgoing, - sender_name=sender_name, - sender_key=conversation_key if not outgoing else None, - ).model_dump(), realtime=realtime, + broadcast_fn=broadcast_event, ) - # Update contact's last_contacted timestamp (for sorting) - await ContactRepository.update_last_contacted(conversation_key, received) - - return msg_id - async def run_historical_dm_decryption( private_key_bytes: bytes, diff --git a/app/routers/messages.py b/app/routers/messages.py index b15c960..b66d612 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -16,6 +16,11 @@ from app.models import ( from app.radio import radio_manager from app.region_scope import normalize_region_scope from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository +from app.services.messages import ( + build_message_model, + create_outgoing_channel_message, + create_outgoing_direct_message, +) from app.websocket import broadcast_error, broadcast_event logger = logging.getLogger(__name__) @@ -239,15 +244,15 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message: raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") # Store outgoing message - message_id = await MessageRepository.create( - msg_type="PRIV", - text=request.text, + message = await create_outgoing_direct_message( conversation_key=db_contact.public_key.lower(), + text=request.text, sender_timestamp=now, received_at=now, - outgoing=True, + broadcast_fn=broadcast_event, + message_repository=MessageRepository, ) - if message_id is None: + if message is None: raise HTTPException( status_code=500, detail="Failed to store outgoing message - unexpected duplicate", @@ -261,23 +266,8 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message: suggested_timeout: int = result.payload.get("suggested_timeout", 10000) # default 10s if expected_ack: ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack - track_pending_ack(ack_code, message_id, suggested_timeout) - logger.debug("Tracking ACK %s for message %d", ack_code, message_id) - - message = Message( - id=message_id, - type="PRIV", - conversation_key=db_contact.public_key.lower(), - text=request.text, - sender_timestamp=now, - received_at=now, - outgoing=True, - acked=0, - ) - - # Broadcast so all connected clients (not just sender) see the outgoing message immediately. - # Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch. - broadcast_event("message", message.model_dump()) + track_pending_ack(ack_code, message.id, suggested_timeout) + logger.debug("Tracking ACK %s for message %d", ack_code, message.id) return message @@ -351,57 +341,39 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message: # Store outgoing immediately after send to avoid a race where # our own echo lands before persistence. - message_id = await MessageRepository.create( - msg_type="CHAN", - text=text_with_sender, + outgoing_message = await create_outgoing_channel_message( conversation_key=channel_key_upper, + text=text_with_sender, sender_timestamp=now, received_at=now, - outgoing=True, sender_name=radio_name or None, sender_key=our_public_key, + channel_name=db_channel.name, + broadcast_fn=broadcast_event, + message_repository=MessageRepository, ) - if message_id is None: + if outgoing_message is None: raise HTTPException( status_code=500, detail="Failed to store outgoing message - unexpected duplicate", ) - - # Broadcast immediately so all connected clients see the message promptly. - # This ensures the message exists in frontend state when echo-driven - # `message_acked` events arrive. - broadcast_event( - "message", - Message( - id=message_id, - type="CHAN", - conversation_key=channel_key_upper, - text=text_with_sender, - sender_timestamp=now, - received_at=now, - outgoing=True, - acked=0, - sender_name=radio_name or None, - sender_key=our_public_key, - channel_name=db_channel.name, - ).model_dump(), - ) + message_id = outgoing_message.id if message_id is None or now is None: raise HTTPException(status_code=500, detail="Failed to store outgoing message") acked_count, paths = await MessageRepository.get_ack_and_paths(message_id) - message = Message( - id=message_id, - type="CHAN", + message = build_message_model( + message_id=message_id, + msg_type="CHAN", conversation_key=channel_key_upper, text=text_with_sender, sender_timestamp=now, received_at=now, + paths=paths, outgoing=True, acked=acked_count, - paths=paths, sender_name=radio_name or None, sender_key=our_public_key, channel_name=db_channel.name, @@ -492,17 +464,18 @@ async def resend_channel_message( # For new-timestamp resend, create a new message row and broadcast it if new_timestamp: - new_msg_id = await MessageRepository.create( - msg_type="CHAN", - text=msg.text, + new_message = await create_outgoing_channel_message( conversation_key=msg.conversation_key, + text=msg.text, sender_timestamp=now, received_at=now, - outgoing=True, sender_name=radio_name or None, sender_key=resend_public_key, + channel_name=db_channel.name, + broadcast_fn=broadcast_event, + message_repository=MessageRepository, ) - if new_msg_id is None: + if new_message is None: # Timestamp-second collision (same text+channel within the same second). # The radio already transmitted, so log and return the original ID rather # than surfacing a 500 for a message that was successfully sent over the air. @@ -512,30 +485,13 @@ async def resend_channel_message( ) return {"status": "ok", "message_id": message_id} - broadcast_event( - "message", - Message( - id=new_msg_id, - type="CHAN", - conversation_key=msg.conversation_key, - text=msg.text, - sender_timestamp=now, - received_at=now, - outgoing=True, - acked=0, - sender_name=radio_name or None, - sender_key=resend_public_key, - channel_name=db_channel.name, - ).model_dump(), - ) - logger.info( "Resent channel message %d as new message %d to %s", message_id, - new_msg_id, + new_message.id, db_channel.name, ) - return {"status": "ok", "message_id": new_msg_id} + return {"status": "ok", "message_id": new_message.id} logger.info("Resent channel message %d to %s", message_id, db_channel.name) return {"status": "ok", "message_id": message_id} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..c43f9f8 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Backend service-layer helpers.""" diff --git a/app/services/messages.py b/app/services/messages.py new file mode 100644 index 0000000..f96ee11 --- /dev/null +++ b/app/services/messages.py @@ -0,0 +1,447 @@ +import logging +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from app.models import CONTACT_TYPE_REPEATER, Message, MessagePath +from app.repository import ContactRepository, MessageRepository, RawPacketRepository + +if TYPE_CHECKING: + from app.decoder import DecryptedDirectMessage + +logger = logging.getLogger(__name__) + +BroadcastFn = Callable[..., Any] + + +def build_message_paths( + path: str | None, + received_at: int, + path_len: int | None = None, +) -> list[MessagePath] | None: + """Build the single-path list used by message payloads.""" + return ( + [MessagePath(path=path or "", received_at=received_at, path_len=path_len)] + if path is not None + else None + ) + + +def build_message_model( + *, + message_id: int, + msg_type: str, + conversation_key: str, + text: str, + sender_timestamp: int | None, + received_at: int, + paths: list[MessagePath] | None = None, + txt_type: int = 0, + signature: str | None = None, + sender_key: str | None = None, + outgoing: bool = False, + acked: int = 0, + sender_name: str | None = None, + channel_name: str | None = None, +) -> Message: + """Build a Message model with the canonical backend payload shape.""" + return Message( + id=message_id, + type=msg_type, + conversation_key=conversation_key, + text=text, + sender_timestamp=sender_timestamp, + received_at=received_at, + paths=paths, + txt_type=txt_type, + signature=signature, + sender_key=sender_key, + outgoing=outgoing, + acked=acked, + sender_name=sender_name, + channel_name=channel_name, + ) + + +def broadcast_message( + *, + message: Message, + broadcast_fn: BroadcastFn, + realtime: bool | None = None, +) -> None: + """Broadcast a message payload, preserving the caller's broadcast signature.""" + payload = message.model_dump() + if realtime is None: + broadcast_fn("message", payload) + else: + broadcast_fn("message", payload, realtime=realtime) + + +def broadcast_message_acked( + *, + message_id: int, + ack_count: int, + paths: list[MessagePath] | None, + broadcast_fn: BroadcastFn, +) -> None: + """Broadcast a message_acked payload.""" + broadcast_fn( + "message_acked", + { + "message_id": message_id, + "ack_count": ack_count, + "paths": [path.model_dump() for path in paths] if paths else [], + }, + ) + + +async def increment_ack_and_broadcast( + *, + message_id: int, + broadcast_fn: BroadcastFn, +) -> int: + """Increment a message's ACK count and broadcast the update.""" + ack_count = await MessageRepository.increment_ack_count(message_id) + broadcast_fn("message_acked", {"message_id": message_id, "ack_count": ack_count}) + return ack_count + + +async def handle_duplicate_message( + *, + packet_id: int, + msg_type: str, + conversation_key: str, + text: str, + sender_timestamp: int, + path: str | None, + received_at: int, + path_len: int | None = None, + broadcast_fn: BroadcastFn, +) -> None: + """Handle a duplicate message by updating paths/acks on the existing record.""" + existing_msg = await MessageRepository.get_by_content( + msg_type=msg_type, + conversation_key=conversation_key, + text=text, + sender_timestamp=sender_timestamp, + ) + if not existing_msg: + label = "message" if msg_type == "CHAN" else "DM" + logger.warning( + "Duplicate %s for %s but couldn't find existing", + label, + conversation_key[:12], + ) + return + + logger.debug( + "Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path", + msg_type, + conversation_key[:12], + existing_msg.id, + existing_msg.outgoing, + ) + + if path is not None: + paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len) + else: + paths = existing_msg.paths or [] + + if existing_msg.outgoing: + ack_count = await MessageRepository.increment_ack_count(existing_msg.id) + else: + ack_count = existing_msg.acked + + if existing_msg.outgoing or path is not None: + broadcast_message_acked( + message_id=existing_msg.id, + ack_count=ack_count, + paths=paths, + broadcast_fn=broadcast_fn, + ) + + await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id) + + +async def create_message_from_decrypted( + *, + packet_id: int, + channel_key: str, + sender: str | None, + message_text: str, + timestamp: int, + received_at: int | None = None, + path: str | None = None, + path_len: int | None = None, + channel_name: str | None = None, + realtime: bool = True, + broadcast_fn: BroadcastFn, +) -> int | None: + """Store and broadcast a decrypted channel message.""" + received = received_at or int(time.time()) + text = f"{sender}: {message_text}" if sender else message_text + channel_key_normalized = channel_key.upper() + + resolved_sender_key: str | None = None + if sender: + candidates = await ContactRepository.get_by_name(sender) + if len(candidates) == 1: + resolved_sender_key = candidates[0].public_key + + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=text, + conversation_key=channel_key_normalized, + sender_timestamp=timestamp, + received_at=received, + path=path, + path_len=path_len, + sender_name=sender, + sender_key=resolved_sender_key, + ) + + if msg_id is None: + await handle_duplicate_message( + packet_id=packet_id, + msg_type="CHAN", + conversation_key=channel_key_normalized, + text=text, + sender_timestamp=timestamp, + path=path, + received_at=received, + path_len=path_len, + broadcast_fn=broadcast_fn, + ) + return None + + logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8]) + await RawPacketRepository.mark_decrypted(packet_id, msg_id) + + broadcast_message( + message=build_message_model( + message_id=msg_id, + msg_type="CHAN", + conversation_key=channel_key_normalized, + text=text, + sender_timestamp=timestamp, + received_at=received, + paths=build_message_paths(path, received, path_len), + sender_name=sender, + sender_key=resolved_sender_key, + channel_name=channel_name, + ), + broadcast_fn=broadcast_fn, + realtime=realtime, + ) + + return msg_id + + +async def create_dm_message_from_decrypted( + *, + packet_id: int, + decrypted: "DecryptedDirectMessage", + their_public_key: str, + our_public_key: str | None, + received_at: int | None = None, + path: str | None = None, + path_len: int | None = None, + outgoing: bool = False, + realtime: bool = True, + broadcast_fn: BroadcastFn, +) -> int | None: + """Store and broadcast a decrypted direct message.""" + contact = await ContactRepository.get_by_key(their_public_key) + if contact and contact.type == CONTACT_TYPE_REPEATER: + logger.debug( + "Skipping message from repeater %s (CLI responses not stored): %s", + their_public_key[:12], + (decrypted.message or "")[:50], + ) + return None + + received = received_at or int(time.time()) + conversation_key = their_public_key.lower() + sender_name = contact.name if contact and not outgoing else None + + msg_id = await MessageRepository.create( + msg_type="PRIV", + text=decrypted.message, + conversation_key=conversation_key, + sender_timestamp=decrypted.timestamp, + received_at=received, + path=path, + path_len=path_len, + outgoing=outgoing, + sender_key=conversation_key if not outgoing else None, + sender_name=sender_name, + ) + + if msg_id is None: + await handle_duplicate_message( + packet_id=packet_id, + msg_type="PRIV", + conversation_key=conversation_key, + text=decrypted.message, + sender_timestamp=decrypted.timestamp, + path=path, + received_at=received, + path_len=path_len, + broadcast_fn=broadcast_fn, + ) + return None + + logger.info( + "Stored direct message %d for contact %s (outgoing=%s)", + msg_id, + conversation_key[:12], + outgoing, + ) + await RawPacketRepository.mark_decrypted(packet_id, msg_id) + + broadcast_message( + message=build_message_model( + message_id=msg_id, + msg_type="PRIV", + conversation_key=conversation_key, + text=decrypted.message, + sender_timestamp=decrypted.timestamp, + received_at=received, + paths=build_message_paths(path, received, path_len), + outgoing=outgoing, + sender_name=sender_name, + sender_key=conversation_key if not outgoing else None, + ), + broadcast_fn=broadcast_fn, + realtime=realtime, + ) + + await ContactRepository.update_last_contacted(conversation_key, received) + return msg_id + + +async def create_fallback_direct_message( + *, + conversation_key: str, + text: str, + sender_timestamp: int, + received_at: int, + path: str | None, + path_len: int | None, + txt_type: int, + signature: str | None, + sender_name: str | None, + sender_key: str | None, + broadcast_fn: BroadcastFn, + message_repository=MessageRepository, +) -> Message | None: + """Store and broadcast a CONTACT_MSG_RECV fallback direct message.""" + msg_id = await message_repository.create( + msg_type="PRIV", + text=text, + conversation_key=conversation_key, + sender_timestamp=sender_timestamp, + received_at=received_at, + path=path, + path_len=path_len, + txt_type=txt_type, + signature=signature, + sender_key=sender_key, + sender_name=sender_name, + ) + if msg_id is None: + return None + + message = build_message_model( + message_id=msg_id, + msg_type="PRIV", + conversation_key=conversation_key, + text=text, + sender_timestamp=sender_timestamp, + received_at=received_at, + paths=build_message_paths(path, received_at, path_len), + txt_type=txt_type, + signature=signature, + sender_key=sender_key, + sender_name=sender_name, + ) + broadcast_message(message=message, broadcast_fn=broadcast_fn) + return message + + +async def create_outgoing_direct_message( + *, + conversation_key: str, + text: str, + sender_timestamp: int, + received_at: int, + broadcast_fn: BroadcastFn, + message_repository=MessageRepository, +) -> Message | None: + """Store and broadcast an outgoing direct message.""" + msg_id = await message_repository.create( + msg_type="PRIV", + text=text, + conversation_key=conversation_key, + sender_timestamp=sender_timestamp, + received_at=received_at, + outgoing=True, + ) + if msg_id is None: + return None + + message = build_message_model( + message_id=msg_id, + msg_type="PRIV", + conversation_key=conversation_key, + text=text, + sender_timestamp=sender_timestamp, + received_at=received_at, + outgoing=True, + acked=0, + ) + broadcast_message(message=message, broadcast_fn=broadcast_fn) + return message + + +async def create_outgoing_channel_message( + *, + conversation_key: str, + text: str, + sender_timestamp: int, + received_at: int, + sender_name: str | None, + sender_key: str | None, + channel_name: str | None, + broadcast_fn: BroadcastFn, + message_repository=MessageRepository, +) -> Message | None: + """Store and broadcast an outgoing channel message.""" + msg_id = await message_repository.create( + msg_type="CHAN", + text=text, + conversation_key=conversation_key, + sender_timestamp=sender_timestamp, + received_at=received_at, + outgoing=True, + sender_name=sender_name, + sender_key=sender_key, + ) + if msg_id is None: + return None + + message = build_message_model( + message_id=msg_id, + msg_type="CHAN", + conversation_key=conversation_key, + text=text, + sender_timestamp=sender_timestamp, + received_at=received_at, + outgoing=True, + acked=0, + sender_name=sender_name, + sender_key=sender_key, + channel_name=channel_name, + ) + broadcast_message(message=message, broadcast_fn=broadcast_fn) + return message From b1e3e71b68e776a987916170b30c15af2eb28446 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 17:03:07 -0700 Subject: [PATCH 02/27] extract dm ack tracker service --- app/event_handlers.py | 31 +++++++----------------- app/services/dm_ack_tracker.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 app/services/dm_ack_tracker.py diff --git a/app/event_handlers.py b/app/event_handlers.py index 0e1db4b..526e8fb 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -12,6 +12,7 @@ from app.repository import ( ContactRepository, MessageRepository, ) +from app.services import dm_ack_tracker from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast from app.websocket import broadcast_event @@ -23,33 +24,17 @@ logger = logging.getLogger(__name__) # Track active subscriptions so we can unsubscribe before re-registering # This prevents handler duplication after reconnects _active_subscriptions: list["Subscription"] = [] - - -# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms) -_pending_acks: dict[str, tuple[int, float, int]] = {} +_pending_acks = dm_ack_tracker._pending_acks def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None: - """Track a pending ACK for a direct message.""" - _pending_acks[expected_ack] = (message_id, time.time(), timeout_ms) - logger.debug( - "Tracking pending ACK %s for message %d (timeout %dms)", - expected_ack, - message_id, - timeout_ms, - ) + """Compatibility wrapper for pending DM ACK tracking.""" + dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms) def cleanup_expired_acks() -> None: - """Remove expired pending ACKs.""" - now = time.time() - expired = [] - for code, (_msg_id, created_at, timeout_ms) in _pending_acks.items(): - if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer - expired.append(code) - for code in expired: - del _pending_acks[code] - logger.debug("Expired pending ACK %s", code) + """Compatibility wrapper for expiring stale DM ACK entries.""" + dm_ack_tracker.cleanup_expired_acks() async def on_contact_message(event: "Event") -> None: @@ -280,8 +265,8 @@ async def on_ack(event: "Event") -> None: cleanup_expired_acks() - if ack_code in _pending_acks: - message_id, _, _ = _pending_acks.pop(ack_code) + message_id = dm_ack_tracker.pop_pending_ack(ack_code) + if message_id is not None: logger.info("ACK received for message %d", message_id) # DM ACKs don't carry path data, so paths is intentionally omitted. # The frontend's mergePendingAck handles the missing field correctly, diff --git a/app/services/dm_ack_tracker.py b/app/services/dm_ack_tracker.py new file mode 100644 index 0000000..b882073 --- /dev/null +++ b/app/services/dm_ack_tracker.py @@ -0,0 +1,43 @@ +"""Shared pending ACK tracking for outgoing direct messages.""" + +import logging +import time + +logger = logging.getLogger(__name__) + +PendingAck = tuple[int, float, int] + +_pending_acks: dict[str, PendingAck] = {} + + +def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None: + """Track an expected ACK code for an outgoing direct message.""" + _pending_acks[expected_ack] = (message_id, time.time(), timeout_ms) + logger.debug( + "Tracking pending ACK %s for message %d (timeout %dms)", + expected_ack, + message_id, + timeout_ms, + ) + + +def cleanup_expired_acks() -> None: + """Remove stale pending ACK entries.""" + now = time.time() + expired_codes = [ + code + for code, (_message_id, created_at, timeout_ms) in _pending_acks.items() + if now - created_at > (timeout_ms / 1000) * 2 + ] + for code in expired_codes: + del _pending_acks[code] + logger.debug("Expired pending ACK %s", code) + + +def pop_pending_ack(ack_code: str) -> int | None: + """Claim the tracked message ID for an ACK code if present.""" + pending = _pending_acks.pop(ack_code, None) + if pending is None: + return None + message_id, _, _ = pending + return message_id From 088dcb39d6830bbc45f5a2cabf376fc2fea68f4f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 17:32:43 -0700 Subject: [PATCH 03/27] extract contact reconciliation service --- app/event_handlers.py | 26 ++--- app/packet_processor.py | 34 ++---- app/radio_sync.py | 25 +--- app/routers/contacts.py | 35 ++---- app/services/contact_reconciliation.py | 115 +++++++++++++++++++ tests/test_contact_reconciliation_service.py | 72 ++++++++++++ 6 files changed, 222 insertions(+), 85 deletions(-) create mode 100644 app/services/contact_reconciliation.py create mode 100644 tests/test_contact_reconciliation_service.py diff --git a/app/event_handlers.py b/app/event_handlers.py index 526e8fb..173986b 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -8,11 +8,13 @@ from app.models import CONTACT_TYPE_REPEATER, Contact from app.packet_processor import process_raw_packet from app.repository import ( AmbiguousPublicKeyPrefixError, - ContactNameHistoryRepository, ContactRepository, - MessageRepository, ) from app.services import dm_ack_tracker +from app.services.contact_reconciliation import ( + claim_prefix_messages_for_contact, + record_contact_name_and_reconcile, +) from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast from app.websocket import broadcast_event @@ -76,7 +78,7 @@ async def on_contact_message(event: "Event") -> None: sender_pubkey = contact.public_key.lower() # Promote any prefix-stored messages to this full key - await MessageRepository.claim_prefix_messages(sender_pubkey) + await claim_prefix_messages_for_contact(public_key=sender_pubkey, log=logger) # Skip messages from repeaters - they only send CLI responses, not chat messages. # CLI responses are handled by the command endpoint and txt_type filter above. @@ -232,19 +234,13 @@ async def on_new_contact(event: "Event") -> None: } await ContactRepository.upsert(contact_data) - # Record name history if contact has a name adv_name = payload.get("adv_name") - if adv_name: - await ContactNameHistoryRepository.record_name( - public_key.lower(), adv_name, int(time.time()) - ) - backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name) - if backfilled > 0: - logger.info( - "Backfilled sender_key on %d channel message(s) for %s", - backfilled, - adv_name, - ) + await record_contact_name_and_reconcile( + public_key=public_key, + contact_name=adv_name, + timestamp=int(time.time()), + log=logger, + ) # Read back from DB so the broadcast includes all fields (last_contacted, # last_read_at, etc.) matching the REST Contact shape exactly. diff --git a/app/packet_processor.py b/app/packet_processor.py index d676290..d8e09b6 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -36,11 +36,10 @@ from app.models import ( from app.repository import ( ChannelRepository, ContactAdvertPathRepository, - ContactNameHistoryRepository, ContactRepository, - MessageRepository, RawPacketRepository, ) +from app.services.contact_reconciliation import record_contact_name_and_reconcile from app.services.messages import ( create_dm_message_from_decrypted as _create_dm_message_from_decrypted, ) @@ -490,14 +489,6 @@ async def _process_advertisement( hop_count=new_path_len, ) - # Record name history - if advert.name: - await ContactNameHistoryRepository.record_name( - public_key=advert.public_key.lower(), - name=advert.name, - timestamp=timestamp, - ) - contact_data = { "public_key": advert.public_key.lower(), "name": advert.name, @@ -513,23 +504,12 @@ async def _process_advertisement( } await ContactRepository.upsert(contact_data) - claimed = await MessageRepository.claim_prefix_messages(advert.public_key.lower()) - if claimed > 0: - logger.info( - "Claimed %d prefix DM message(s) for contact %s", - claimed, - advert.public_key[:12], - ) - if advert.name: - backfilled = await MessageRepository.backfill_channel_sender_key( - advert.public_key, advert.name - ) - if backfilled > 0: - logger.info( - "Backfilled sender_key on %d channel message(s) for %s", - backfilled, - advert.name, - ) + await record_contact_name_and_reconcile( + public_key=advert.public_key, + contact_name=advert.name, + timestamp=timestamp, + log=logger, + ) # Read back from DB so the broadcast includes all fields (last_contacted, # last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly. diff --git a/app/radio_sync.py b/app/radio_sync.py index 0430be8..b7b4a47 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -24,8 +24,8 @@ from app.repository import ( AppSettingsRepository, ChannelRepository, ContactRepository, - MessageRepository, ) +from app.services.contact_reconciliation import reconcile_contact_messages logger = logging.getLogger(__name__) @@ -156,24 +156,11 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict: await ContactRepository.upsert( Contact.from_radio_dict(public_key, contact_data, on_radio=False) ) - claimed = await MessageRepository.claim_prefix_messages(public_key.lower()) - if claimed > 0: - logger.info( - "Claimed %d prefix DM message(s) for contact %s", - claimed, - public_key[:12], - ) - adv_name = contact_data.get("adv_name") - if adv_name: - backfilled = await MessageRepository.backfill_channel_sender_key( - public_key, adv_name - ) - if backfilled > 0: - logger.info( - "Backfilled sender_key on %d channel message(s) for %s", - backfilled, - adv_name, - ) + await reconcile_contact_messages( + public_key=public_key, + contact_name=contact_data.get("adv_name"), + log=logger, + ) synced += 1 # Remove from radio diff --git a/app/routers/contacts.py b/app/routers/contacts.py index f145312..ad1de33 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -26,6 +26,7 @@ from app.repository import ( ContactRepository, MessageRepository, ) +from app.services.contact_reconciliation import reconcile_contact_messages logger = logging.getLogger(__name__) @@ -181,18 +182,11 @@ async def create_contact( await ContactRepository.upsert(contact_data) logger.info("Created contact %s", lower_key[:12]) - # Promote any prefix-stored messages to this full key - claimed = await MessageRepository.claim_prefix_messages(lower_key) - if claimed > 0: - logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12]) - - # Backfill sender_key on channel messages that match this contact's name - if request.name: - backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name) - if backfilled > 0: - logger.info( - "Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name - ) + await reconcile_contact_messages( + public_key=lower_key, + contact_name=request.name, + log=logger, + ) # Trigger historical decryption if requested if request.try_historical: @@ -318,18 +312,11 @@ async def sync_contacts_from_radio() -> dict: Contact.from_radio_dict(lower_key, contact_data, on_radio=True) ) synced_keys.append(lower_key) - claimed = await MessageRepository.claim_prefix_messages(lower_key) - if claimed > 0: - logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12]) - adv_name = contact_data.get("adv_name") - if adv_name: - backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name) - if backfilled > 0: - logger.info( - "Backfilled sender_key on %d channel message(s) for %s", - backfilled, - adv_name, - ) + await reconcile_contact_messages( + public_key=lower_key, + contact_name=contact_data.get("adv_name"), + log=logger, + ) count += 1 # Clear on_radio for contacts not found on the radio diff --git a/app/services/contact_reconciliation.py b/app/services/contact_reconciliation.py new file mode 100644 index 0000000..7b71dc4 --- /dev/null +++ b/app/services/contact_reconciliation.py @@ -0,0 +1,115 @@ +"""Shared contact/message reconciliation helpers.""" + +import logging + +from app.repository import ContactNameHistoryRepository, MessageRepository + +logger = logging.getLogger(__name__) + + +async def claim_prefix_messages_for_contact( + *, + public_key: str, + message_repository=MessageRepository, + log: logging.Logger | None = None, +) -> int: + """Promote prefix-key DMs to a resolved full public key.""" + normalized_key = public_key.lower() + claimed = await message_repository.claim_prefix_messages(normalized_key) + if claimed > 0: + (log or logger).info( + "Claimed %d prefix DM message(s) for contact %s", + claimed, + normalized_key[:12], + ) + return claimed + + +async def backfill_channel_sender_for_contact( + *, + public_key: str, + contact_name: str | None, + message_repository=MessageRepository, + log: logging.Logger | None = None, +) -> int: + """Backfill channel sender attribution once a contact name is known.""" + if not contact_name: + return 0 + + normalized_key = public_key.lower() + backfilled = await message_repository.backfill_channel_sender_key( + normalized_key, + contact_name, + ) + if backfilled > 0: + (log or logger).info( + "Backfilled sender_key on %d channel message(s) for %s", + backfilled, + contact_name, + ) + return backfilled + + +async def reconcile_contact_messages( + *, + public_key: str, + contact_name: str | None, + message_repository=MessageRepository, + log: logging.Logger | None = None, +) -> tuple[int, int]: + """Apply message reconciliation once a contact's identity is resolved.""" + claimed = await claim_prefix_messages_for_contact( + public_key=public_key, + message_repository=message_repository, + log=log, + ) + backfilled = await backfill_channel_sender_for_contact( + public_key=public_key, + contact_name=contact_name, + message_repository=message_repository, + log=log, + ) + return claimed, backfilled + + +async def record_contact_name( + *, + public_key: str, + contact_name: str | None, + timestamp: int, + contact_name_history_repository=ContactNameHistoryRepository, +) -> bool: + """Record contact name history when a non-empty name is available.""" + if not contact_name: + return False + + await contact_name_history_repository.record_name( + public_key.lower(), + contact_name, + timestamp, + ) + return True + + +async def record_contact_name_and_reconcile( + *, + public_key: str, + contact_name: str | None, + timestamp: int, + message_repository=MessageRepository, + contact_name_history_repository=ContactNameHistoryRepository, + log: logging.Logger | None = None, +) -> tuple[int, int]: + """Record name history, then reconcile message identity for the contact.""" + await record_contact_name( + public_key=public_key, + contact_name=contact_name, + timestamp=timestamp, + contact_name_history_repository=contact_name_history_repository, + ) + return await reconcile_contact_messages( + public_key=public_key, + contact_name=contact_name, + message_repository=message_repository, + log=log, + ) diff --git a/tests/test_contact_reconciliation_service.py b/tests/test_contact_reconciliation_service.py new file mode 100644 index 0000000..ccf3c00 --- /dev/null +++ b/tests/test_contact_reconciliation_service.py @@ -0,0 +1,72 @@ +"""Tests for shared contact/message reconciliation helpers.""" + +import pytest + +from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository +from app.services.contact_reconciliation import ( + claim_prefix_messages_for_contact, + record_contact_name_and_reconcile, +) + + +@pytest.mark.asyncio +async def test_claim_prefix_messages_for_contact_promotes_prefix_dm(test_db): + public_key = "aa" * 32 + await ContactRepository.upsert({"public_key": public_key, "name": "Alice", "type": 1}) + + await MessageRepository.create( + msg_type="PRIV", + text="hello", + conversation_key=public_key[:12], + sender_timestamp=1000, + received_at=1000, + ) + + claimed = await claim_prefix_messages_for_contact(public_key=public_key) + + assert claimed == 1 + messages = await MessageRepository.get_all(conversation_key=public_key) + assert len(messages) == 1 + assert messages[0].conversation_key == public_key + + +@pytest.mark.asyncio +async def test_record_contact_name_and_reconcile_records_history_and_backfills(test_db): + public_key = "bb" * 32 + channel_key = "CC" * 16 + await ContactRepository.upsert({"public_key": public_key, "name": "Alice", "type": 1}) + + await MessageRepository.create( + msg_type="PRIV", + text="dm", + conversation_key=public_key[:12], + sender_timestamp=1000, + received_at=1000, + ) + await MessageRepository.create( + msg_type="CHAN", + text="Alice: hello", + conversation_key=channel_key, + sender_timestamp=1001, + received_at=1001, + sender_name="Alice", + ) + + claimed, backfilled = await record_contact_name_and_reconcile( + public_key=public_key, + contact_name="Alice", + timestamp=1234, + ) + + assert claimed == 1 + assert backfilled == 1 + + history = await ContactNameHistoryRepository.get_history(public_key) + assert len(history) == 1 + assert history[0].name == "Alice" + assert history[0].first_seen == 1234 + assert history[0].last_seen == 1234 + + messages = await MessageRepository.get_all(msg_type="CHAN", conversation_key=channel_key) + assert len(messages) == 1 + assert messages[0].sender_key == public_key From 2d781cad562330f870f20f0aeeccde2ff49b5328 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 17:47:31 -0700 Subject: [PATCH 04/27] add typed websocket event contracts --- app/events.py | 97 ++++++++++++++++++++++++++++++ app/websocket.py | 7 ++- frontend/src/test/wsEvents.test.ts | 41 +++++++++++++ frontend/src/useWebSocket.ts | 12 ++-- frontend/src/wsEvents.ts | 78 ++++++++++++++++++++++++ tests/test_websocket.py | 22 +++++++ 6 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 app/events.py create mode 100644 frontend/src/test/wsEvents.test.ts create mode 100644 frontend/src/wsEvents.ts diff --git a/app/events.py b/app/events.py new file mode 100644 index 0000000..59fb163 --- /dev/null +++ b/app/events.py @@ -0,0 +1,97 @@ +"""Typed WebSocket event contracts and serialization helpers.""" + +import json +from typing import Any, Literal + +from pydantic import TypeAdapter +from typing_extensions import NotRequired, TypedDict + +from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast +from app.routers.health import HealthResponse + +WsEventType = Literal[ + "health", + "message", + "contact", + "channel", + "contact_deleted", + "channel_deleted", + "raw_packet", + "message_acked", + "error", + "success", +] + + +class ContactDeletedPayload(TypedDict): + public_key: str + + +class ChannelDeletedPayload(TypedDict): + key: str + + +class MessageAckedPayload(TypedDict): + message_id: int + ack_count: int + paths: NotRequired[list[MessagePath]] + + +class ToastPayload(TypedDict): + message: str + details: NotRequired[str] + + +WsEventPayload = ( + HealthResponse + | Message + | Contact + | Channel + | ContactDeletedPayload + | ChannelDeletedPayload + | RawPacketBroadcast + | MessageAckedPayload + | ToastPayload +) + +_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = { + "health": TypeAdapter(HealthResponse), + "message": TypeAdapter(Message), + "contact": TypeAdapter(Contact), + "channel": TypeAdapter(Channel), + "contact_deleted": TypeAdapter(ContactDeletedPayload), + "channel_deleted": TypeAdapter(ChannelDeletedPayload), + "raw_packet": TypeAdapter(RawPacketBroadcast), + "message_acked": TypeAdapter(MessageAckedPayload), + "error": TypeAdapter(ToastPayload), + "success": TypeAdapter(ToastPayload), +} + + +def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any: + """Validate known WebSocket payloads; pass unknown events through unchanged.""" + adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type] + if adapter is None: + return data + return adapter.validate_python(data) + + +def dump_ws_event(event_type: str, data: Any) -> str: + """Serialize a WebSocket event envelope with validation for known event types.""" + adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type] + if adapter is None: + return json.dumps({"type": event_type, "data": data}) + + validated = adapter.validate_python(data) + payload = adapter.dump_python(validated, mode="json") + return json.dumps({"type": event_type, "data": payload}) + + +def dump_ws_event_payload(event_type: str, data: Any) -> Any: + """Return the JSON-serializable payload for a WebSocket event.""" + adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type] + if adapter is None: + return data + + validated = adapter.validate_python(data) + return adapter.dump_python(validated, mode="json") diff --git a/app/websocket.py b/app/websocket.py index 3ceb705..27ebdb0 100644 --- a/app/websocket.py +++ b/app/websocket.py @@ -1,12 +1,13 @@ """WebSocket manager for real-time updates.""" import asyncio -import json import logging from typing import Any from fastapi import WebSocket +from app.events import dump_ws_event + logger = logging.getLogger(__name__) # Timeout for individual WebSocket send operations (seconds) @@ -45,7 +46,7 @@ class WebSocketManager: if not self.active_connections: return - message = json.dumps({"type": event_type, "data": data}) + message = dump_ws_event(event_type, data) # Copy connection list under lock to avoid holding lock during I/O async with self._lock: @@ -81,7 +82,7 @@ class WebSocketManager: async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None: """Send an event to a specific client.""" - message = json.dumps({"type": event_type, "data": data}) + message = dump_ws_event(event_type, data) try: await websocket.send_text(message) except Exception as e: diff --git a/frontend/src/test/wsEvents.test.ts b/frontend/src/test/wsEvents.test.ts new file mode 100644 index 0000000..4ff03be --- /dev/null +++ b/frontend/src/test/wsEvents.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { parseWsEvent } from '../wsEvents'; + +describe('wsEvents', () => { + it('parses contact_deleted events', () => { + const event = parseWsEvent( + JSON.stringify({ type: 'contact_deleted', data: { public_key: 'aa' } }) + ); + + expect(event).toEqual({ + type: 'contact_deleted', + data: { public_key: 'aa' }, + }); + }); + + it('parses channel_deleted events', () => { + const event = parseWsEvent(JSON.stringify({ type: 'channel_deleted', data: { key: 'bb' } })); + + expect(event).toEqual({ + type: 'channel_deleted', + data: { key: 'bb' }, + }); + }); + + it('returns unknown events with rawType preserved', () => { + const event = parseWsEvent(JSON.stringify({ type: 'mystery', data: { ok: true } })); + + expect(event).toEqual({ + type: 'unknown', + rawType: 'mystery', + data: { ok: true }, + }); + }); + + it('rejects invalid envelopes', () => { + expect(() => parseWsEvent(JSON.stringify({ data: {} }))).toThrow( + 'Invalid WebSocket event envelope' + ); + }); +}); diff --git a/frontend/src/useWebSocket.ts b/frontend/src/useWebSocket.ts index 19a740b..70548b2 100644 --- a/frontend/src/useWebSocket.ts +++ b/frontend/src/useWebSocket.ts @@ -1,10 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; import type { Channel, HealthStatus, Contact, Message, MessagePath, RawPacket } from './types'; - -interface WebSocketMessage { - type: string; - data: unknown; -} +import { parseWsEvent } from './wsEvents'; interface ErrorEvent { message: string; @@ -92,7 +88,7 @@ export function useWebSocket(options: UseWebSocketOptions) { ws.onmessage = (event) => { try { - const msg: WebSocketMessage = JSON.parse(event.data); + const msg = parseWsEvent(event.data); // Access handlers through ref to always use current versions const handlers = optionsRef.current; @@ -136,8 +132,8 @@ export function useWebSocket(options: UseWebSocketOptions) { case 'pong': // Heartbeat response, ignore break; - default: - console.warn('Unknown WebSocket message type:', msg.type); + case 'unknown': + console.warn('Unknown WebSocket message type:', msg.rawType); } } catch (e) { console.error('Failed to parse WebSocket message:', e); diff --git a/frontend/src/wsEvents.ts b/frontend/src/wsEvents.ts new file mode 100644 index 0000000..bd0719d --- /dev/null +++ b/frontend/src/wsEvents.ts @@ -0,0 +1,78 @@ +import type { Channel, Contact, HealthStatus, Message, MessagePath, RawPacket } from './types'; + +export interface MessageAckedPayload { + message_id: number; + ack_count: number; + paths?: MessagePath[]; +} + +export interface ContactDeletedPayload { + public_key: string; +} + +export interface ChannelDeletedPayload { + key: string; +} + +export interface ToastPayload { + message: string; + details?: string; +} + +export type KnownWsEvent = + | { type: 'health'; data: HealthStatus } + | { type: 'message'; data: Message } + | { type: 'contact'; data: Contact } + | { type: 'channel'; data: Channel } + | { type: 'contact_deleted'; data: ContactDeletedPayload } + | { type: 'channel_deleted'; data: ChannelDeletedPayload } + | { type: 'raw_packet'; data: RawPacket } + | { type: 'message_acked'; data: MessageAckedPayload } + | { type: 'error'; data: ToastPayload } + | { type: 'success'; data: ToastPayload } + | { type: 'pong'; data?: null }; + +export interface UnknownWsEvent { + type: 'unknown'; + rawType: string; + data: unknown; +} + +export type ParsedWsEvent = KnownWsEvent | UnknownWsEvent; + +interface RawWsEnvelope { + type?: unknown; + data?: unknown; +} + +export function parseWsEvent(raw: string): ParsedWsEvent { + const parsed: RawWsEnvelope = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || typeof parsed.type !== 'string') { + throw new Error('Invalid WebSocket event envelope'); + } + + switch (parsed.type) { + case 'health': + case 'message': + case 'contact': + case 'channel': + case 'contact_deleted': + case 'channel_deleted': + case 'raw_packet': + case 'message_acked': + case 'error': + case 'success': + return { + type: parsed.type, + data: parsed.data, + } as KnownWsEvent; + case 'pong': + return { type: 'pong', data: parsed.data as null | undefined }; + default: + return { + type: 'unknown', + rawType: parsed.type, + data: parsed.data, + }; + } +} diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 7bd361f..49930ed 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,9 +1,11 @@ """Tests for WebSocket manager functionality.""" import asyncio +import json from unittest.mock import AsyncMock, patch import pytest +from pydantic import ValidationError from app.websocket import SEND_TIMEOUT_SECONDS, WebSocketManager @@ -245,3 +247,23 @@ class TestBroadcastEventFanout: mock_ws.broadcast.assert_called_once() mock_fm.broadcast_raw.assert_called_once_with({"data": "ff00"}) + + +class TestTypedEventSerialization: + """Tests for typed websocket event serialization.""" + + def test_dump_ws_event_preserves_optional_message_acked_shape(self): + from app.events import dump_ws_event + + serialized = dump_ws_event("message_acked", {"message_id": 7, "ack_count": 2}) + + assert json.loads(serialized) == { + "type": "message_acked", + "data": {"message_id": 7, "ack_count": 2}, + } + + def test_dump_ws_event_validates_supported_payloads(self): + from app.events import dump_ws_event + + with pytest.raises(ValidationError): + dump_ws_event("message_acked", {"ack_count": 2}) From 0d671f361dd22db16c255e61bc8a6e95da0e4341 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 17:54:44 -0700 Subject: [PATCH 05/27] extract message send service --- app/routers/messages.py | 335 ++++----------------------------- app/services/message_send.py | 352 +++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 303 deletions(-) create mode 100644 app/services/message_send.py diff --git a/app/routers/messages.py b/app/routers/messages.py index b66d612..588d064 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -1,9 +1,7 @@ import logging import time -from typing import Any from fastapi import APIRouter, HTTPException, Query -from meshcore import EventType from app.dependencies import require_connected from app.event_handlers import track_pending_ack @@ -14,12 +12,11 @@ from app.models import ( SendDirectMessageRequest, ) from app.radio import radio_manager -from app.region_scope import normalize_region_scope from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository -from app.services.messages import ( - build_message_model, - create_outgoing_channel_message, - create_outgoing_direct_message, +from app.services.message_send import ( + resend_channel_message_record, + send_channel_message_to_channel, + send_direct_message_to_contact, ) from app.websocket import broadcast_error, broadcast_event @@ -27,105 +24,6 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/messages", tags=["messages"]) -async def _send_channel_message_with_effective_scope( - *, - mc, - channel, - key_bytes: bytes, - text: str, - timestamp_bytes: bytes, - action_label: str, -) -> Any: - """Send a channel message, temporarily overriding flood scope when configured.""" - override_scope = normalize_region_scope(channel.flood_scope_override) - baseline_scope = "" - - if override_scope: - settings = await AppSettingsRepository.get() - baseline_scope = normalize_region_scope(settings.flood_scope) - - if override_scope and override_scope != baseline_scope: - logger.info( - "Temporarily applying channel flood_scope override for %s: %r", - channel.name, - override_scope, - ) - override_result = await mc.commands.set_flood_scope(override_scope) - if override_result is not None and override_result.type == EventType.ERROR: - logger.warning( - "Failed to apply channel flood_scope override for %s: %s", - channel.name, - override_result.payload, - ) - raise HTTPException( - status_code=500, - detail=( - f"Failed to apply regional override {override_scope!r} before {action_label}: " - f"{override_result.payload}" - ), - ) - - try: - set_result = await mc.commands.set_channel( - channel_idx=TEMP_RADIO_SLOT, - channel_name=channel.name, - channel_secret=key_bytes, - ) - if set_result.type == EventType.ERROR: - logger.warning( - "Failed to set channel on radio slot %d before %s: %s", - TEMP_RADIO_SLOT, - action_label, - set_result.payload, - ) - raise HTTPException( - status_code=500, - detail=f"Failed to configure channel on radio before {action_label}", - ) - - return await mc.commands.send_chan_msg( - chan=TEMP_RADIO_SLOT, - msg=text, - timestamp=timestamp_bytes, - ) - finally: - if override_scope and override_scope != baseline_scope: - try: - restore_result = await mc.commands.set_flood_scope( - baseline_scope if baseline_scope else "" - ) - if restore_result is not None and restore_result.type == EventType.ERROR: - logger.error( - "Failed to restore baseline flood_scope after sending to %s: %s", - channel.name, - restore_result.payload, - ) - broadcast_error( - "Regional override restore failed", - ( - f"Sent to {channel.name}, but restoring flood scope failed. " - "The radio may still be region-scoped. Consider rebooting the radio." - ), - ) - else: - logger.debug( - "Restored baseline flood_scope after channel send: %r", - baseline_scope or "(disabled)", - ) - except Exception: - logger.exception( - "Failed to restore baseline flood_scope after sending to %s", - channel.name, - ) - broadcast_error( - "Regional override restore failed", - ( - f"Sent to {channel.name}, but restoring flood scope failed. " - "The radio may still be region-scoped. Consider rebooting the radio." - ), - ) - - @router.get("/around/{message_id}", response_model=MessagesAroundResponse) async def get_messages_around( message_id: int, @@ -211,65 +109,16 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message: status_code=404, detail=f"Contact not found in database: {request.destination}" ) - # Always add/update the contact on radio before sending. - # The library cache (get_contact_by_key_prefix) can be stale after radio reboot, - # so we can't rely on it to know if the firmware has the contact. - # add_contact is idempotent - updates if exists, adds if not. - contact_data = db_contact.to_radio_dict() - async with radio_manager.radio_operation("send_direct_message") as mc: - logger.debug("Ensuring contact %s is on radio before sending", db_contact.public_key[:12]) - add_result = await mc.commands.add_contact(contact_data) - if add_result.type == EventType.ERROR: - logger.warning("Failed to add contact to radio: %s", add_result.payload) - # Continue anyway - might still work if contact exists - - # Get the contact from the library cache (may have updated info like path) - contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12]) - if not contact: - contact = contact_data - - logger.info("Sending direct message to %s", db_contact.public_key[:12]) - - # Capture timestamp BEFORE sending so we can pass the same value to both the radio - # and the database. This ensures consistency for deduplication. - now = int(time.time()) - - result = await mc.commands.send_msg( - dst=contact, - msg=request.text, - timestamp=now, - ) - - if result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") - - # Store outgoing message - message = await create_outgoing_direct_message( - conversation_key=db_contact.public_key.lower(), + return await send_direct_message_to_contact( + contact=db_contact, text=request.text, - sender_timestamp=now, - received_at=now, + radio_manager=radio_manager, broadcast_fn=broadcast_event, + track_pending_ack_fn=track_pending_ack, + now_fn=time.time, message_repository=MessageRepository, + contact_repository=ContactRepository, ) - if message is None: - raise HTTPException( - status_code=500, - detail="Failed to store outgoing message - unexpected duplicate", - ) - - # Update last_contacted for the contact - await ContactRepository.update_last_contacted(db_contact.public_key.lower(), now) - - # Track the expected ACK for this message - expected_ack = result.payload.get("expected_ack") - suggested_timeout: int = result.payload.get("suggested_timeout", 10000) # default 10s - if expected_ack: - ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack - track_pending_ack(ack_code, message.id, suggested_timeout) - logger.debug("Tracking ACK %s for message %d", ack_code, message.id) - - return message # Temporary radio slot used for sending channel messages @@ -307,80 +156,19 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message: TEMP_RADIO_SLOT, expected_hash, ) - channel_key_upper = request.channel_key.upper() - message_id: int | None = None - now: int | None = None - radio_name: str = "" - text_with_sender: str = request.text - - our_public_key: str | None = None - - async with radio_manager.radio_operation("send_channel_message") as mc: - radio_name = mc.self_info.get("name", "") if mc.self_info else "" - our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None - text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text - logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50]) - - # Capture timestamp BEFORE sending so we can pass the same value to both the radio - # and the database. This ensures the echo's timestamp matches our stored message - # for proper deduplication. - now = int(time.time()) - timestamp_bytes = now.to_bytes(4, "little") - - result = await _send_channel_message_with_effective_scope( - mc=mc, - channel=db_channel, - key_bytes=key_bytes, - text=request.text, - timestamp_bytes=timestamp_bytes, - action_label="sending message", - ) - - if result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") - - # Store outgoing immediately after send to avoid a race where - # our own echo lands before persistence. - outgoing_message = await create_outgoing_channel_message( - conversation_key=channel_key_upper, - text=text_with_sender, - sender_timestamp=now, - received_at=now, - sender_name=radio_name or None, - sender_key=our_public_key, - channel_name=db_channel.name, - broadcast_fn=broadcast_event, - message_repository=MessageRepository, - ) - if outgoing_message is None: - raise HTTPException( - status_code=500, - detail="Failed to store outgoing message - unexpected duplicate", - ) - message_id = outgoing_message.id - - if message_id is None or now is None: - raise HTTPException(status_code=500, detail="Failed to store outgoing message") - - acked_count, paths = await MessageRepository.get_ack_and_paths(message_id) - - message = build_message_model( - message_id=message_id, - msg_type="CHAN", - conversation_key=channel_key_upper, - text=text_with_sender, - sender_timestamp=now, - received_at=now, - paths=paths, - outgoing=True, - acked=acked_count, - sender_name=radio_name or None, - sender_key=our_public_key, - channel_name=db_channel.name, + return await send_channel_message_to_channel( + channel=db_channel, + channel_key_upper=request.channel_key.upper(), + key_bytes=key_bytes, + text=request.text, + radio_manager=radio_manager, + broadcast_fn=broadcast_event, + error_broadcast_fn=broadcast_error, + now_fn=time.time, + temp_radio_slot=TEMP_RADIO_SLOT, + message_repository=MessageRepository, ) - return message - RESEND_WINDOW_SECONDS = 30 @@ -425,73 +213,14 @@ async def resend_channel_message( if not db_channel: raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found") - # Choose timestamp: original for byte-perfect, fresh for new-timestamp - if new_timestamp: - now = int(time.time()) - timestamp_bytes = now.to_bytes(4, "little") - else: - timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little") - - try: - key_bytes = bytes.fromhex(msg.conversation_key) - except ValueError: - raise HTTPException( - status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}" - ) from None - - resend_public_key: str | None = None - - async with radio_manager.radio_operation("resend_channel_message") as mc: - # Strip sender prefix: DB stores "RadioName: message" but radio needs "message" - radio_name = mc.self_info.get("name", "") if mc.self_info else "" - resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None - text_to_send = msg.text - if radio_name and text_to_send.startswith(f"{radio_name}: "): - text_to_send = text_to_send[len(f"{radio_name}: ") :] - - result = await _send_channel_message_with_effective_scope( - mc=mc, - channel=db_channel, - key_bytes=key_bytes, - text=text_to_send, - timestamp_bytes=timestamp_bytes, - action_label="resending message", - ) - if result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to resend message: {result.payload}" - ) - - # For new-timestamp resend, create a new message row and broadcast it - if new_timestamp: - new_message = await create_outgoing_channel_message( - conversation_key=msg.conversation_key, - text=msg.text, - sender_timestamp=now, - received_at=now, - sender_name=radio_name or None, - sender_key=resend_public_key, - channel_name=db_channel.name, - broadcast_fn=broadcast_event, - message_repository=MessageRepository, - ) - if new_message is None: - # Timestamp-second collision (same text+channel within the same second). - # The radio already transmitted, so log and return the original ID rather - # than surfacing a 500 for a message that was successfully sent over the air. - logger.warning( - "Duplicate timestamp collision resending message %d — radio sent but DB row not created", - message_id, - ) - return {"status": "ok", "message_id": message_id} - - logger.info( - "Resent channel message %d as new message %d to %s", - message_id, - new_message.id, - db_channel.name, - ) - return {"status": "ok", "message_id": new_message.id} - - logger.info("Resent channel message %d to %s", message_id, db_channel.name) - return {"status": "ok", "message_id": message_id} + return await resend_channel_message_record( + message=msg, + channel=db_channel, + new_timestamp=new_timestamp, + radio_manager=radio_manager, + broadcast_fn=broadcast_event, + error_broadcast_fn=broadcast_error, + now_fn=time.time, + temp_radio_slot=TEMP_RADIO_SLOT, + message_repository=MessageRepository, + ) diff --git a/app/services/message_send.py b/app/services/message_send.py new file mode 100644 index 0000000..2a5dffb --- /dev/null +++ b/app/services/message_send.py @@ -0,0 +1,352 @@ +"""Shared send/resend orchestration for outgoing messages.""" + +import logging +from collections.abc import Callable +from typing import Any + +from fastapi import HTTPException +from meshcore import EventType + +from app.region_scope import normalize_region_scope +from app.repository import AppSettingsRepository, ContactRepository, MessageRepository +from app.services.messages import ( + build_message_model, + create_outgoing_channel_message, + create_outgoing_direct_message, +) + +logger = logging.getLogger(__name__) + +BroadcastFn = Callable[..., Any] +TrackAckFn = Callable[[str, int, int], None] +NowFn = Callable[[], float] + + +async def send_channel_message_with_effective_scope( + *, + mc, + channel, + key_bytes: bytes, + text: str, + timestamp_bytes: bytes, + action_label: str, + temp_radio_slot: int, + error_broadcast_fn: BroadcastFn, + app_settings_repository=AppSettingsRepository, +) -> Any: + """Send a channel message, temporarily overriding flood scope when configured.""" + override_scope = normalize_region_scope(channel.flood_scope_override) + baseline_scope = "" + + if override_scope: + settings = await app_settings_repository.get() + baseline_scope = normalize_region_scope(settings.flood_scope) + + if override_scope and override_scope != baseline_scope: + logger.info( + "Temporarily applying channel flood_scope override for %s: %r", + channel.name, + override_scope, + ) + override_result = await mc.commands.set_flood_scope(override_scope) + if override_result is not None and override_result.type == EventType.ERROR: + logger.warning( + "Failed to apply channel flood_scope override for %s: %s", + channel.name, + override_result.payload, + ) + raise HTTPException( + status_code=500, + detail=( + f"Failed to apply regional override {override_scope!r} before {action_label}: " + f"{override_result.payload}" + ), + ) + + try: + set_result = await mc.commands.set_channel( + channel_idx=temp_radio_slot, + channel_name=channel.name, + channel_secret=key_bytes, + ) + if set_result.type == EventType.ERROR: + logger.warning( + "Failed to set channel on radio slot %d before %s: %s", + temp_radio_slot, + action_label, + set_result.payload, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to configure channel on radio before {action_label}", + ) + + return await mc.commands.send_chan_msg( + chan=temp_radio_slot, + msg=text, + timestamp=timestamp_bytes, + ) + finally: + if override_scope and override_scope != baseline_scope: + try: + restore_result = await mc.commands.set_flood_scope( + baseline_scope if baseline_scope else "" + ) + if restore_result is not None and restore_result.type == EventType.ERROR: + logger.error( + "Failed to restore baseline flood_scope after sending to %s: %s", + channel.name, + restore_result.payload, + ) + error_broadcast_fn( + "Regional override restore failed", + ( + f"Sent to {channel.name}, but restoring flood scope failed. " + "The radio may still be region-scoped. Consider rebooting the radio." + ), + ) + else: + logger.debug( + "Restored baseline flood_scope after channel send: %r", + baseline_scope or "(disabled)", + ) + except Exception: + logger.exception( + "Failed to restore baseline flood_scope after sending to %s", + channel.name, + ) + error_broadcast_fn( + "Regional override restore failed", + ( + f"Sent to {channel.name}, but restoring flood scope failed. " + "The radio may still be region-scoped. Consider rebooting the radio." + ), + ) + + +async def send_direct_message_to_contact( + *, + contact, + text: str, + radio_manager, + broadcast_fn: BroadcastFn, + track_pending_ack_fn: TrackAckFn, + now_fn: NowFn, + message_repository=MessageRepository, + contact_repository=ContactRepository, +) -> Any: + """Send a direct message and persist/broadcast the outgoing row.""" + contact_data = contact.to_radio_dict() + async with radio_manager.radio_operation("send_direct_message") as mc: + logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12]) + add_result = await mc.commands.add_contact(contact_data) + if add_result.type == EventType.ERROR: + logger.warning("Failed to add contact to radio: %s", add_result.payload) + + cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12]) + if not cached_contact: + cached_contact = contact_data + + logger.info("Sending direct message to %s", contact.public_key[:12]) + now = int(now_fn()) + result = await mc.commands.send_msg( + dst=cached_contact, + msg=text, + timestamp=now, + ) + + if result.type == EventType.ERROR: + raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") + + message = await create_outgoing_direct_message( + conversation_key=contact.public_key.lower(), + text=text, + sender_timestamp=now, + received_at=now, + broadcast_fn=broadcast_fn, + message_repository=message_repository, + ) + if message is None: + raise HTTPException( + status_code=500, + detail="Failed to store outgoing message - unexpected duplicate", + ) + + await contact_repository.update_last_contacted(contact.public_key.lower(), now) + + expected_ack = result.payload.get("expected_ack") + suggested_timeout: int = result.payload.get("suggested_timeout", 10000) + if expected_ack: + ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack + track_pending_ack_fn(ack_code, message.id, suggested_timeout) + logger.debug("Tracking ACK %s for message %d", ack_code, message.id) + + return message + + +async def send_channel_message_to_channel( + *, + channel, + channel_key_upper: str, + key_bytes: bytes, + text: str, + radio_manager, + broadcast_fn: BroadcastFn, + error_broadcast_fn: BroadcastFn, + now_fn: NowFn, + temp_radio_slot: int, + message_repository=MessageRepository, +) -> Any: + """Send a channel message and persist/broadcast the outgoing row.""" + message_id: int | None = None + now: int | None = None + radio_name = "" + our_public_key: str | None = None + text_with_sender = text + + async with radio_manager.radio_operation("send_channel_message") as mc: + radio_name = mc.self_info.get("name", "") if mc.self_info else "" + our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None + text_with_sender = f"{radio_name}: {text}" if radio_name else text + logger.info("Sending channel message to %s: %s", channel.name, text[:50]) + + now = int(now_fn()) + timestamp_bytes = now.to_bytes(4, "little") + + result = await send_channel_message_with_effective_scope( + mc=mc, + channel=channel, + key_bytes=key_bytes, + text=text, + timestamp_bytes=timestamp_bytes, + action_label="sending message", + temp_radio_slot=temp_radio_slot, + error_broadcast_fn=error_broadcast_fn, + ) + + if result.type == EventType.ERROR: + raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") + + outgoing_message = await create_outgoing_channel_message( + conversation_key=channel_key_upper, + text=text_with_sender, + sender_timestamp=now, + received_at=now, + sender_name=radio_name or None, + sender_key=our_public_key, + channel_name=channel.name, + broadcast_fn=broadcast_fn, + message_repository=message_repository, + ) + if outgoing_message is None: + raise HTTPException( + status_code=500, + detail="Failed to store outgoing message - unexpected duplicate", + ) + message_id = outgoing_message.id + + if message_id is None or now is None: + raise HTTPException(status_code=500, detail="Failed to store outgoing message") + + acked_count, paths = await message_repository.get_ack_and_paths(message_id) + return build_message_model( + message_id=message_id, + msg_type="CHAN", + conversation_key=channel_key_upper, + text=text_with_sender, + sender_timestamp=now, + received_at=now, + paths=paths, + outgoing=True, + acked=acked_count, + sender_name=radio_name or None, + sender_key=our_public_key, + channel_name=channel.name, + ) + + +async def resend_channel_message_record( + *, + message, + channel, + new_timestamp: bool, + radio_manager, + broadcast_fn: BroadcastFn, + error_broadcast_fn: BroadcastFn, + now_fn: NowFn, + temp_radio_slot: int, + message_repository=MessageRepository, +) -> dict[str, Any]: + """Resend a stored outgoing channel message.""" + try: + key_bytes = bytes.fromhex(message.conversation_key) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid channel key format: {message.conversation_key}", + ) from None + + now: int | None = None + if new_timestamp: + now = int(now_fn()) + timestamp_bytes = now.to_bytes(4, "little") + else: + timestamp_bytes = message.sender_timestamp.to_bytes(4, "little") + + resend_public_key: str | None = None + radio_name = "" + + async with radio_manager.radio_operation("resend_channel_message") as mc: + radio_name = mc.self_info.get("name", "") if mc.self_info else "" + resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None + text_to_send = message.text + if radio_name and text_to_send.startswith(f"{radio_name}: "): + text_to_send = text_to_send[len(f"{radio_name}: ") :] + + result = await send_channel_message_with_effective_scope( + mc=mc, + channel=channel, + key_bytes=key_bytes, + text=text_to_send, + timestamp_bytes=timestamp_bytes, + action_label="resending message", + temp_radio_slot=temp_radio_slot, + error_broadcast_fn=error_broadcast_fn, + ) + if result.type == EventType.ERROR: + raise HTTPException( + status_code=500, + detail=f"Failed to resend message: {result.payload}", + ) + + if new_timestamp: + if now is None: + raise HTTPException(status_code=500, detail="Failed to assign resend timestamp") + new_message = await create_outgoing_channel_message( + conversation_key=message.conversation_key, + text=message.text, + sender_timestamp=now, + received_at=now, + sender_name=radio_name or None, + sender_key=resend_public_key, + channel_name=channel.name, + broadcast_fn=broadcast_fn, + message_repository=message_repository, + ) + if new_message is None: + logger.warning( + "Duplicate timestamp collision resending message %d — radio sent but DB row not created", + message.id, + ) + return {"status": "ok", "message_id": message.id} + + logger.info( + "Resent channel message %d as new message %d to %s", + message.id, + new_message.id, + channel.name, + ) + return {"status": "ok", "message_id": new_message.id} + + logger.info("Resent channel message %d to %s", message.id, channel.name) + return {"status": "ok", "message_id": message.id} From 344cee550870e574f1f54ec81933357820ebed79 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 18:02:58 -0700 Subject: [PATCH 06/27] extract radio lifecycle service --- app/main.py | 13 +- app/radio.py | 213 +------------------------ app/routers/radio.py | 22 ++- app/services/radio_lifecycle.py | 221 ++++++++++++++++++++++++++ tests/test_radio_lifecycle_service.py | 84 ++++++++++ 5 files changed, 332 insertions(+), 221 deletions(-) create mode 100644 app/services/radio_lifecycle.py create mode 100644 tests/test_radio_lifecycle_service.py diff --git a/app/main.py b/app/main.py index 4becab6..e174f0c 100644 --- a/app/main.py +++ b/app/main.py @@ -37,13 +37,14 @@ logger = logging.getLogger(__name__) async def _startup_radio_connect_and_setup() -> None: """Connect/setup the radio in the background so HTTP serving can start immediately.""" - try: - connected = await radio_manager.reconnect(broadcast_on_success=False) - if connected: - await radio_manager.post_connect_setup() - from app.websocket import broadcast_health + from app.services.radio_lifecycle import reconnect_and_prepare_radio - broadcast_health(True, radio_manager.connection_info) + try: + connected = await reconnect_and_prepare_radio( + radio_manager, + broadcast_on_success=True, + ) + if connected: logger.info("Connected to radio") else: logger.warning("Failed to connect to radio on startup") diff --git a/app/radio.py b/app/radio.py index ed86958..7ede1f4 100644 --- a/app/radio.py +++ b/app/radio.py @@ -217,146 +217,10 @@ class RadioManager: self._release_operation_lock(name) async def post_connect_setup(self) -> None: - """Full post-connection setup: handlers, key export, sync, advertisements, polling. + """Run shared post-connection orchestration after transport setup succeeds.""" + from app.services.radio_lifecycle import run_post_connect_setup - Called after every successful connection or reconnection. - Idempotent — safe to call repeatedly (periodic tasks have start guards). - """ - from app.event_handlers import register_event_handlers - from app.keystore import export_and_store_private_key - from app.radio_sync import ( - drain_pending_messages, - send_advertisement, - start_message_polling, - start_periodic_advert, - start_periodic_sync, - sync_and_offload_all, - sync_radio_time, - ) - - if not self._meshcore: - return - - if self._setup_lock is None: - self._setup_lock = asyncio.Lock() - - async with self._setup_lock: - if not self._meshcore: - return - self._setup_in_progress = True - self._setup_complete = False - mc = self._meshcore - try: - # Register event handlers (no radio I/O, just callback setup) - register_event_handlers(mc) - - # Hold the operation lock for all radio I/O during setup. - # This prevents user-initiated operations (send message, etc.) - # from interleaving commands on the serial link. - await self._acquire_operation_lock("post_connect_setup", blocking=True) - try: - await export_and_store_private_key(mc) - - # Sync radio clock with system time - await sync_radio_time(mc) - - # Apply flood scope from settings (best-effort; older firmware - # may not support set_flood_scope) - from app.region_scope import normalize_region_scope - from app.repository import AppSettingsRepository - - app_settings = await AppSettingsRepository.get() - scope = normalize_region_scope(app_settings.flood_scope) - try: - await mc.commands.set_flood_scope(scope if scope else "") - logger.info("Applied flood_scope=%r", scope or "(disabled)") - except Exception as exc: - logger.warning( - "set_flood_scope failed (firmware may not support it): %s", exc - ) - - # Query path hash mode support (best-effort; older firmware won't report it). - # If the library's parsed payload is missing path_hash_mode (e.g. stale - # .pyc on WSL2 Windows mounts), fall back to raw-frame extraction. - reader = mc._reader - _original_handle_rx = reader.handle_rx - _captured_frame: list[bytes] = [] - - async def _capture_handle_rx(data: bytearray) -> None: - from meshcore.packets import PacketType - - if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value: - _captured_frame.append(bytes(data)) - return await _original_handle_rx(data) - - reader.handle_rx = _capture_handle_rx - self.path_hash_mode = 0 - self.path_hash_mode_supported = False - try: - device_query = await mc.commands.send_device_query() - if device_query and "path_hash_mode" in device_query.payload: - self.path_hash_mode = device_query.payload["path_hash_mode"] - self.path_hash_mode_supported = True - elif _captured_frame: - # Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode - raw = _captured_frame[-1] - fw_ver = raw[1] if len(raw) > 1 else 0 - if fw_ver >= 10 and len(raw) >= 82: - self.path_hash_mode = raw[81] - self.path_hash_mode_supported = True - logger.warning( - "path_hash_mode=%d extracted from raw frame " - "(stale .pyc? try: rm %s)", - self.path_hash_mode, - getattr( - __import__("meshcore.reader", fromlist=["reader"]), - "__cached__", - "meshcore __pycache__/reader.*.pyc", - ), - ) - if self.path_hash_mode_supported: - logger.info("Path hash mode: %d (supported)", self.path_hash_mode) - else: - logger.debug("Firmware does not report path_hash_mode") - except Exception as exc: - logger.debug("Failed to query path_hash_mode: %s", exc) - finally: - reader.handle_rx = _original_handle_rx - - # Sync contacts/channels from radio to DB and clear radio - logger.info("Syncing and offloading radio data...") - result = await sync_and_offload_all(mc) - logger.info("Sync complete: %s", result) - - # Send advertisement to announce our presence (if enabled and not throttled) - if await send_advertisement(mc): - logger.info("Advertisement sent") - else: - logger.debug("Advertisement skipped (disabled or throttled)") - - # Drain any messages that were queued before we connected. - # This must happen BEFORE starting auto-fetch, otherwise both - # compete on get_msg() with interleaved radio I/O. - drained = await drain_pending_messages(mc) - if drained > 0: - logger.info("Drained %d pending message(s)", drained) - - await mc.start_auto_message_fetching() - logger.info("Auto message fetching started") - finally: - self._release_operation_lock("post_connect_setup") - - # Start background tasks AFTER releasing the operation lock. - # These tasks acquire their own locks when they need radio access. - start_periodic_sync() - start_periodic_advert() - start_message_polling() - - self._setup_complete = True - finally: - self._setup_in_progress = False - - logger.info("Post-connect setup complete") + await run_post_connect_setup(self) @property def meshcore(self) -> MeshCore | None: @@ -516,77 +380,12 @@ class RadioManager: async def start_connection_monitor(self) -> None: """Start background task to monitor connection and auto-reconnect.""" + from app.services.radio_lifecycle import connection_monitor_loop + if self._reconnect_task is not None: return - async def monitor_loop(): - from app.websocket import broadcast_health - - CHECK_INTERVAL_SECONDS = 5 - UNRESPONSIVE_THRESHOLD = 3 - consecutive_setup_failures = 0 - - while True: - try: - await asyncio.sleep(CHECK_INTERVAL_SECONDS) - - current_connected = self.is_connected - - # Detect status change - if self._last_connected and not current_connected: - # Connection lost - logger.warning("Radio connection lost, broadcasting status change") - broadcast_health(False, self._connection_info) - self._last_connected = False - consecutive_setup_failures = 0 - - if not current_connected: - # Attempt reconnection on every loop while disconnected - if not self.is_reconnecting and await self.reconnect( - broadcast_on_success=False - ): - await self.post_connect_setup() - broadcast_health(True, self._connection_info) - self._last_connected = True - consecutive_setup_failures = 0 - - elif not self._last_connected and current_connected: - # Connection restored (might have reconnected automatically). - # Always run setup before reporting healthy. - logger.info("Radio connection restored") - await self.post_connect_setup() - broadcast_health(True, self._connection_info) - self._last_connected = True - consecutive_setup_failures = 0 - - elif current_connected and not self._setup_complete: - # Transport connected but setup incomplete — retry - logger.info("Retrying post-connect setup...") - await self.post_connect_setup() - broadcast_health(True, self._connection_info) - consecutive_setup_failures = 0 - - except asyncio.CancelledError: - # Task is being cancelled, exit cleanly - break - except Exception as e: - consecutive_setup_failures += 1 - if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD: - logger.error( - "Post-connect setup has failed %d times in a row. " - "The radio port appears open but the radio is not " - "responding to commands. Common causes: another " - "process has the serial port open (check for other " - "RemoteTerm instances, serial monitors, etc.), the " - "firmware is in repeater mode (not client), or the " - "radio needs a power cycle. Will keep retrying.", - consecutive_setup_failures, - ) - elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD: - logger.exception("Error in connection monitor, continuing: %s", e) - # After the threshold, silently retry (avoid log spam) - - self._reconnect_task = asyncio.create_task(monitor_loop()) + self._reconnect_task = asyncio.create_task(connection_monitor_loop(self)) logger.info("Radio connection monitor started") async def stop_connection_monitor(self) -> None: diff --git a/app/routers/radio.py b/app/routers/radio.py index 56b052a..934372d 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -207,6 +207,8 @@ async def send_advertisement() -> dict: async def _attempt_reconnect() -> dict: """Shared reconnection logic for reboot and reconnect endpoints.""" + from app.services.radio_lifecycle import reconnect_and_prepare_radio + if radio_manager.is_reconnecting: return { "status": "pending", @@ -214,14 +216,11 @@ async def _attempt_reconnect() -> dict: "connected": False, } - success = await radio_manager.reconnect() - if not success: - raise HTTPException( - status_code=503, detail="Failed to reconnect. Check radio connection and power." - ) - try: - await radio_manager.post_connect_setup() + success = await reconnect_and_prepare_radio( + radio_manager, + broadcast_on_success=True, + ) except Exception as e: logger.exception("Post-connect setup failed after reconnect") raise HTTPException( @@ -229,6 +228,11 @@ async def _attempt_reconnect() -> dict: detail=f"Radio connected but setup failed: {e}", ) from e + if not success: + raise HTTPException( + status_code=503, detail="Failed to reconnect. Check radio connection and power." + ) + return {"status": "ok", "message": "Reconnected successfully", "connected": True} @@ -260,13 +264,15 @@ async def reconnect_radio() -> dict: if no specific port is configured. Useful when the radio has been disconnected or power-cycled. """ + from app.services.radio_lifecycle import prepare_connected_radio + if radio_manager.is_connected: if radio_manager.is_setup_complete: return {"status": "ok", "message": "Already connected", "connected": True} logger.info("Radio connected but setup incomplete, retrying setup") try: - await radio_manager.post_connect_setup() + await prepare_connected_radio(radio_manager, broadcast_on_success=True) return {"status": "ok", "message": "Setup completed", "connected": True} except Exception as e: logger.exception("Post-connect setup failed") diff --git a/app/services/radio_lifecycle.py b/app/services/radio_lifecycle.py new file mode 100644 index 0000000..0133d14 --- /dev/null +++ b/app/services/radio_lifecycle.py @@ -0,0 +1,221 @@ +import asyncio +import logging + +logger = logging.getLogger(__name__) + + +async def run_post_connect_setup(radio_manager) -> None: + """Run shared radio initialization after a transport connection succeeds.""" + from app.event_handlers import register_event_handlers + from app.keystore import export_and_store_private_key + from app.radio_sync import ( + drain_pending_messages, + send_advertisement, + start_message_polling, + start_periodic_advert, + start_periodic_sync, + sync_and_offload_all, + sync_radio_time, + ) + + if not radio_manager.meshcore: + return + + if radio_manager._setup_lock is None: + radio_manager._setup_lock = asyncio.Lock() + + async with radio_manager._setup_lock: + if not radio_manager.meshcore: + return + radio_manager._setup_in_progress = True + radio_manager._setup_complete = False + mc = radio_manager.meshcore + try: + # Register event handlers (no radio I/O, just callback setup) + register_event_handlers(mc) + + # Hold the operation lock for all radio I/O during setup. + # This prevents user-initiated operations (send message, etc.) + # from interleaving commands on the serial link. + await radio_manager._acquire_operation_lock("post_connect_setup", blocking=True) + try: + await export_and_store_private_key(mc) + + # Sync radio clock with system time + await sync_radio_time(mc) + + # Apply flood scope from settings (best-effort; older firmware + # may not support set_flood_scope) + from app.region_scope import normalize_region_scope + from app.repository import AppSettingsRepository + + app_settings = await AppSettingsRepository.get() + scope = normalize_region_scope(app_settings.flood_scope) + try: + await mc.commands.set_flood_scope(scope if scope else "") + logger.info("Applied flood_scope=%r", scope or "(disabled)") + except Exception as exc: + logger.warning("set_flood_scope failed (firmware may not support it): %s", exc) + + # Query path hash mode support (best-effort; older firmware won't report it). + # If the library's parsed payload is missing path_hash_mode (e.g. stale + # .pyc on WSL2 Windows mounts), fall back to raw-frame extraction. + reader = mc._reader + _original_handle_rx = reader.handle_rx + _captured_frame: list[bytes] = [] + + async def _capture_handle_rx(data: bytearray) -> None: + from meshcore.packets import PacketType + + if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value: + _captured_frame.append(bytes(data)) + return await _original_handle_rx(data) + + reader.handle_rx = _capture_handle_rx + radio_manager.path_hash_mode = 0 + radio_manager.path_hash_mode_supported = False + try: + device_query = await mc.commands.send_device_query() + if device_query and "path_hash_mode" in device_query.payload: + radio_manager.path_hash_mode = device_query.payload["path_hash_mode"] + radio_manager.path_hash_mode_supported = True + elif _captured_frame: + # Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode + raw = _captured_frame[-1] + fw_ver = raw[1] if len(raw) > 1 else 0 + if fw_ver >= 10 and len(raw) >= 82: + radio_manager.path_hash_mode = raw[81] + radio_manager.path_hash_mode_supported = True + logger.warning( + "path_hash_mode=%d extracted from raw frame " + "(stale .pyc? try: rm %s)", + radio_manager.path_hash_mode, + getattr( + __import__("meshcore.reader", fromlist=["reader"]), + "__cached__", + "meshcore __pycache__/reader.*.pyc", + ), + ) + if radio_manager.path_hash_mode_supported: + logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode) + else: + logger.debug("Firmware does not report path_hash_mode") + except Exception as exc: + logger.debug("Failed to query path_hash_mode: %s", exc) + finally: + reader.handle_rx = _original_handle_rx + + # Sync contacts/channels from radio to DB and clear radio + logger.info("Syncing and offloading radio data...") + result = await sync_and_offload_all(mc) + logger.info("Sync complete: %s", result) + + # Send advertisement to announce our presence (if enabled and not throttled) + if await send_advertisement(mc): + logger.info("Advertisement sent") + else: + logger.debug("Advertisement skipped (disabled or throttled)") + + # Drain any messages that were queued before we connected. + # This must happen BEFORE starting auto-fetch, otherwise both + # compete on get_msg() with interleaved radio I/O. + drained = await drain_pending_messages(mc) + if drained > 0: + logger.info("Drained %d pending message(s)", drained) + + await mc.start_auto_message_fetching() + logger.info("Auto message fetching started") + finally: + radio_manager._release_operation_lock("post_connect_setup") + + # Start background tasks AFTER releasing the operation lock. + # These tasks acquire their own locks when they need radio access. + start_periodic_sync() + start_periodic_advert() + start_message_polling() + + radio_manager._setup_complete = True + finally: + radio_manager._setup_in_progress = False + + logger.info("Post-connect setup complete") + + +async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> None: + """Finish setup for an already-connected radio and optionally broadcast health.""" + from app.websocket import broadcast_health + + await radio_manager.post_connect_setup() + radio_manager._last_connected = True + if broadcast_on_success: + broadcast_health(True, radio_manager.connection_info) + + +async def reconnect_and_prepare_radio( + radio_manager, + *, + broadcast_on_success: bool = True, +) -> bool: + """Reconnect the transport, then run post-connect setup before reporting healthy.""" + connected = await radio_manager.reconnect(broadcast_on_success=False) + if not connected: + return False + + await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success) + return True + + +async def connection_monitor_loop(radio_manager) -> None: + """Monitor radio health and keep transport/setup state converged.""" + from app.websocket import broadcast_health + + check_interval_seconds = 5 + unresponsive_threshold = 3 + consecutive_setup_failures = 0 + + while True: + try: + await asyncio.sleep(check_interval_seconds) + + current_connected = radio_manager.is_connected + + if radio_manager._last_connected and not current_connected: + logger.warning("Radio connection lost, broadcasting status change") + broadcast_health(False, radio_manager.connection_info) + radio_manager._last_connected = False + consecutive_setup_failures = 0 + + if not current_connected: + if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio( + radio_manager, + broadcast_on_success=True, + ): + consecutive_setup_failures = 0 + + elif not radio_manager._last_connected and current_connected: + logger.info("Radio connection restored") + await prepare_connected_radio(radio_manager, broadcast_on_success=True) + consecutive_setup_failures = 0 + + elif current_connected and not radio_manager.is_setup_complete: + logger.info("Retrying post-connect setup...") + await prepare_connected_radio(radio_manager, broadcast_on_success=True) + consecutive_setup_failures = 0 + + except asyncio.CancelledError: + break + except Exception as e: + consecutive_setup_failures += 1 + if consecutive_setup_failures == unresponsive_threshold: + logger.error( + "Post-connect setup has failed %d times in a row. " + "The radio port appears open but the radio is not " + "responding to commands. Common causes: another " + "process has the serial port open (check for other " + "RemoteTerm instances, serial monitors, etc.), the " + "firmware is in repeater mode (not client), or the " + "radio needs a power cycle. Will keep retrying.", + consecutive_setup_failures, + ) + elif consecutive_setup_failures < unresponsive_threshold: + logger.exception("Error in connection monitor, continuing: %s", e) diff --git a/tests/test_radio_lifecycle_service.py b/tests/test_radio_lifecycle_service.py new file mode 100644 index 0000000..6b96234 --- /dev/null +++ b/tests/test_radio_lifecycle_service.py @@ -0,0 +1,84 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.radio_lifecycle import prepare_connected_radio, reconnect_and_prepare_radio + + +class TestPrepareConnectedRadio: + @pytest.mark.asyncio + async def test_runs_setup_then_broadcasts_health(self): + radio_manager = MagicMock() + radio_manager._last_connected = False + radio_manager.connection_info = "TCP: test:4000" + + call_order: list[str] = [] + + async def _setup(): + call_order.append("setup") + + radio_manager.post_connect_setup = AsyncMock(side_effect=_setup) + + with patch("app.websocket.broadcast_health") as mock_broadcast: + await prepare_connected_radio(radio_manager, broadcast_on_success=True) + + assert call_order == ["setup"] + assert radio_manager._last_connected is True + mock_broadcast.assert_called_once_with(True, "TCP: test:4000") + + @pytest.mark.asyncio + async def test_can_skip_broadcast(self): + radio_manager = MagicMock() + radio_manager._last_connected = False + radio_manager.connection_info = "TCP: test:4000" + radio_manager.post_connect_setup = AsyncMock() + + with patch("app.websocket.broadcast_health") as mock_broadcast: + await prepare_connected_radio(radio_manager, broadcast_on_success=False) + + assert radio_manager._last_connected is True + mock_broadcast.assert_not_called() + + +class TestReconnectAndPrepareRadio: + @pytest.mark.asyncio + async def test_reconnects_without_early_health_broadcast(self): + radio_manager = MagicMock() + radio_manager._last_connected = False + radio_manager.connection_info = "Serial: /dev/ttyUSB0" + + reconnect_calls: list[bool] = [] + call_order: list[str] = [] + + async def _reconnect(*, broadcast_on_success: bool): + reconnect_calls.append(broadcast_on_success) + call_order.append("reconnect") + return True + + async def _setup(): + call_order.append("setup") + + radio_manager.reconnect = AsyncMock(side_effect=_reconnect) + radio_manager.post_connect_setup = AsyncMock(side_effect=_setup) + + with patch("app.websocket.broadcast_health") as mock_broadcast: + result = await reconnect_and_prepare_radio(radio_manager, broadcast_on_success=True) + + assert result is True + assert reconnect_calls == [False] + assert call_order == ["reconnect", "setup"] + assert radio_manager._last_connected is True + mock_broadcast.assert_called_once_with(True, "Serial: /dev/ttyUSB0") + + @pytest.mark.asyncio + async def test_returns_false_without_running_setup_when_reconnect_fails(self): + radio_manager = MagicMock() + radio_manager.reconnect = AsyncMock(return_value=False) + radio_manager.post_connect_setup = AsyncMock() + + with patch("app.websocket.broadcast_health") as mock_broadcast: + result = await reconnect_and_prepare_radio(radio_manager, broadcast_on_success=True) + + assert result is False + radio_manager.post_connect_setup.assert_not_awaited() + mock_broadcast.assert_not_called() From 946006bd7f4f51c73879cf88353265b1d335450c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 18:13:18 -0700 Subject: [PATCH 07/27] extract radio command service --- app/routers/radio.py | 99 +++++------------ app/services/radio_commands.py | 102 ++++++++++++++++++ tests/test_radio_commands_service.py | 154 +++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 73 deletions(-) create mode 100644 app/services/radio_commands.py create mode 100644 tests/test_radio_commands_service.py diff --git a/app/routers/radio.py b/app/routers/radio.py index 934372d..c58ea9c 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -1,13 +1,19 @@ import logging from fastapi import APIRouter, HTTPException -from meshcore import EventType from pydantic import BaseModel, Field from app.dependencies import require_connected from app.radio import radio_manager from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time +from app.services.radio_commands import ( + KeystoreRefreshError, + PathHashModeUnsupportedError, + RadioCommandRejectedError, + apply_radio_config_update, + import_private_key_and_refresh_keystore, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) @@ -87,57 +93,18 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: require_connected() async with radio_manager.radio_operation("update_radio_config") as mc: - if update.name is not None: - logger.info("Setting radio name to %s", update.name) - await mc.commands.set_name(update.name) - - if update.lat is not None or update.lon is not None: - current_info = mc.self_info - lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0) - lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0) - logger.info("Setting radio coordinates to %f, %f", lat, lon) - await mc.commands.set_coords(lat=lat, lon=lon) - - if update.tx_power is not None: - logger.info("Setting TX power to %d dBm", update.tx_power) - await mc.commands.set_tx_power(val=update.tx_power) - - if update.radio is not None: - logger.info( - "Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d", - update.radio.freq, - update.radio.bw, - update.radio.sf, - update.radio.cr, + try: + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=radio_manager.path_hash_mode_supported, + set_path_hash_mode=lambda mode: setattr(radio_manager, "path_hash_mode", mode), + sync_radio_time_fn=sync_radio_time, ) - await mc.commands.set_radio( - freq=update.radio.freq, - bw=update.radio.bw, - sf=update.radio.sf, - cr=update.radio.cr, - ) - - if update.path_hash_mode is not None: - if not radio_manager.path_hash_mode_supported: - raise HTTPException( - status_code=400, detail="Firmware does not support path hash mode setting" - ) - logger.info("Setting path hash mode to %d", update.path_hash_mode) - result = await mc.commands.set_path_hash_mode(update.path_hash_mode) - if result is not None and result.type == EventType.ERROR: - raise HTTPException( - status_code=500, - detail=f"Failed to set path hash mode: {result.payload}", - ) - radio_manager.path_hash_mode = update.path_hash_mode - - # Sync time with system clock - await sync_radio_time(mc) - - # Re-fetch self_info so the response reflects the changes we just made. - # Commands like set_name() write to flash but don't update the cached - # self_info — send_appstart() triggers a fresh SELF_INFO from the radio. - await mc.commands.send_appstart() + except PathHashModeUnsupportedError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RadioCommandRejectedError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc return await get_radio_config() @@ -154,30 +121,16 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict: logger.info("Importing private key") async with radio_manager.radio_operation("import_private_key") as mc: - result = await mc.commands.import_private_key(key_bytes) - - if result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to import private key: {result.payload}" - ) - - # Re-export from radio so the server-side keystore uses the new key - # for DM decryption immediately, rather than waiting for reconnect. from app.keystore import export_and_store_private_key - keystore_refreshed = await export_and_store_private_key(mc) - if not keystore_refreshed: - logger.warning("Keystore refresh failed after import, retrying once") - keystore_refreshed = await export_and_store_private_key(mc) - - if not keystore_refreshed: - raise HTTPException( - status_code=500, - detail=( - "Private key imported on radio, but server-side keystore " - "refresh failed. Reconnect to apply the new key for DM decryption." - ), - ) + try: + await import_private_key_and_refresh_keystore( + mc, + key_bytes, + export_and_store_private_key_fn=export_and_store_private_key, + ) + except (RadioCommandRejectedError, KeystoreRefreshError) as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc return {"status": "ok"} diff --git a/app/services/radio_commands.py b/app/services/radio_commands.py new file mode 100644 index 0000000..81fbeb9 --- /dev/null +++ b/app/services/radio_commands.py @@ -0,0 +1,102 @@ +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from meshcore import EventType + +logger = logging.getLogger(__name__) + + +class RadioCommandServiceError(RuntimeError): + """Base error for reusable radio command workflows.""" + + +class PathHashModeUnsupportedError(RadioCommandServiceError): + """Raised when firmware does not support path hash mode updates.""" + + +class RadioCommandRejectedError(RadioCommandServiceError): + """Raised when the radio reports an error for a command.""" + + +class KeystoreRefreshError(RadioCommandServiceError): + """Raised when server-side keystore refresh fails after import.""" + + +async def apply_radio_config_update( + mc, + update, + *, + path_hash_mode_supported: bool, + set_path_hash_mode: Callable[[int], None], + sync_radio_time_fn: Callable[[Any], Awaitable[Any]], +) -> None: + """Apply a validated radio-config update to the connected radio.""" + if update.name is not None: + logger.info("Setting radio name to %s", update.name) + await mc.commands.set_name(update.name) + + if update.lat is not None or update.lon is not None: + current_info = mc.self_info + lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0) + lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0) + logger.info("Setting radio coordinates to %f, %f", lat, lon) + await mc.commands.set_coords(lat=lat, lon=lon) + + if update.tx_power is not None: + logger.info("Setting TX power to %d dBm", update.tx_power) + await mc.commands.set_tx_power(val=update.tx_power) + + if update.radio is not None: + logger.info( + "Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d", + update.radio.freq, + update.radio.bw, + update.radio.sf, + update.radio.cr, + ) + await mc.commands.set_radio( + freq=update.radio.freq, + bw=update.radio.bw, + sf=update.radio.sf, + cr=update.radio.cr, + ) + + if update.path_hash_mode is not None: + if not path_hash_mode_supported: + raise PathHashModeUnsupportedError("Firmware does not support path hash mode setting") + + logger.info("Setting path hash mode to %d", update.path_hash_mode) + result = await mc.commands.set_path_hash_mode(update.path_hash_mode) + if result is not None and result.type == EventType.ERROR: + raise RadioCommandRejectedError(f"Failed to set path hash mode: {result.payload}") + set_path_hash_mode(update.path_hash_mode) + + await sync_radio_time_fn(mc) + + # Commands like set_name() write to flash but don't update cached self_info. + # send_appstart() forces a fresh SELF_INFO so the response reflects changes. + await mc.commands.send_appstart() + + +async def import_private_key_and_refresh_keystore( + mc, + key_bytes: bytes, + *, + export_and_store_private_key_fn: Callable[[Any], Awaitable[bool]], +) -> None: + """Import a private key and refresh the in-memory keystore immediately.""" + result = await mc.commands.import_private_key(key_bytes) + if result.type == EventType.ERROR: + raise RadioCommandRejectedError(f"Failed to import private key: {result.payload}") + + keystore_refreshed = await export_and_store_private_key_fn(mc) + if not keystore_refreshed: + logger.warning("Keystore refresh failed after import, retrying once") + keystore_refreshed = await export_and_store_private_key_fn(mc) + + if not keystore_refreshed: + raise KeystoreRefreshError( + "Private key imported on radio, but server-side keystore refresh failed. " + "Reconnect to apply the new key for DM decryption." + ) diff --git a/tests/test_radio_commands_service.py b/tests/test_radio_commands_service.py new file mode 100644 index 0000000..04f5b9d --- /dev/null +++ b/tests/test_radio_commands_service.py @@ -0,0 +1,154 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from meshcore import EventType + +from app.routers.radio import RadioConfigUpdate, RadioSettings +from app.services.radio_commands import ( + KeystoreRefreshError, + PathHashModeUnsupportedError, + RadioCommandRejectedError, + apply_radio_config_update, + import_private_key_and_refresh_keystore, +) + + +def _radio_result(event_type=EventType.OK, payload=None): + result = MagicMock() + result.type = event_type + result.payload = payload or {} + return result + + +def _mock_meshcore_with_info(): + mc = MagicMock() + mc.self_info = { + "adv_lat": 10.0, + "adv_lon": 20.0, + } + mc.commands = MagicMock() + mc.commands.set_name = AsyncMock() + mc.commands.set_coords = AsyncMock() + mc.commands.set_tx_power = AsyncMock() + mc.commands.set_radio = AsyncMock() + mc.commands.set_path_hash_mode = AsyncMock(return_value=_radio_result()) + mc.commands.send_appstart = AsyncMock() + mc.commands.import_private_key = AsyncMock(return_value=_radio_result()) + return mc + + +class TestApplyRadioConfigUpdate: + @pytest.mark.asyncio + async def test_updates_requested_fields_and_refreshes_info(self): + mc = _mock_meshcore_with_info() + sync_radio_time_fn = AsyncMock() + set_path_hash_mode = MagicMock() + update = RadioConfigUpdate( + name="NodeUpdated", + lat=1.23, + tx_power=17, + radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5), + path_hash_mode=1, + ) + + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=True, + set_path_hash_mode=set_path_hash_mode, + sync_radio_time_fn=sync_radio_time_fn, + ) + + mc.commands.set_name.assert_awaited_once_with("NodeUpdated") + mc.commands.set_coords.assert_awaited_once_with(lat=1.23, lon=20.0) + mc.commands.set_tx_power.assert_awaited_once_with(val=17) + mc.commands.set_radio.assert_awaited_once_with(freq=910.525, bw=62.5, sf=7, cr=5) + mc.commands.set_path_hash_mode.assert_awaited_once_with(1) + set_path_hash_mode.assert_called_once_with(1) + sync_radio_time_fn.assert_awaited_once_with(mc) + mc.commands.send_appstart.assert_awaited_once() + + @pytest.mark.asyncio + async def test_rejects_unsupported_path_hash_mode(self): + mc = _mock_meshcore_with_info() + update = RadioConfigUpdate(path_hash_mode=1) + + with pytest.raises(PathHashModeUnsupportedError): + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=False, + set_path_hash_mode=MagicMock(), + sync_radio_time_fn=AsyncMock(), + ) + + mc.commands.set_path_hash_mode.assert_not_awaited() + mc.commands.send_appstart.assert_not_awaited() + + @pytest.mark.asyncio + async def test_raises_when_radio_rejects_path_hash_mode(self): + mc = _mock_meshcore_with_info() + mc.commands.set_path_hash_mode = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"error": "nope"}) + ) + update = RadioConfigUpdate(path_hash_mode=1) + set_path_hash_mode = MagicMock() + + with pytest.raises(RadioCommandRejectedError): + await apply_radio_config_update( + mc, + update, + path_hash_mode_supported=True, + set_path_hash_mode=set_path_hash_mode, + sync_radio_time_fn=AsyncMock(), + ) + + set_path_hash_mode.assert_not_called() + mc.commands.send_appstart.assert_not_awaited() + + +class TestImportPrivateKeyAndRefreshKeystore: + @pytest.mark.asyncio + async def test_rejects_radio_error(self): + mc = _mock_meshcore_with_info() + mc.commands.import_private_key = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"error": "failed"}) + ) + export_fn = AsyncMock(return_value=True) + + with pytest.raises(RadioCommandRejectedError): + await import_private_key_and_refresh_keystore( + mc, + b"\xaa" * 64, + export_and_store_private_key_fn=export_fn, + ) + + export_fn.assert_not_awaited() + + @pytest.mark.asyncio + async def test_retries_keystore_refresh_once(self): + mc = _mock_meshcore_with_info() + export_fn = AsyncMock(side_effect=[False, True]) + + await import_private_key_and_refresh_keystore( + mc, + b"\xaa" * 64, + export_and_store_private_key_fn=export_fn, + ) + + mc.commands.import_private_key.assert_awaited_once_with(b"\xaa" * 64) + assert export_fn.await_count == 2 + + @pytest.mark.asyncio + async def test_raises_when_keystore_refresh_fails_twice(self): + mc = _mock_meshcore_with_info() + export_fn = AsyncMock(return_value=False) + + with pytest.raises(KeystoreRefreshError): + await import_private_key_and_refresh_keystore( + mc, + b"\xaa" * 64, + export_and_store_private_key_fn=export_fn, + ) + + assert export_fn.await_count == 2 From 5d509a88d97103f06891692885e182dfa5b851e2 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 18:27:01 -0700 Subject: [PATCH 08/27] extract frontend realtime state hook --- AGENTS.md | 32 ++- app/AGENTS.md | 26 +- frontend/AGENTS.md | 10 +- frontend/src/App.tsx | 219 ++------------- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useRealtimeAppState.ts | 264 ++++++++++++++++++ frontend/src/test/useRealtimeAppState.test.ts | 216 ++++++++++++++ frontend/src/useWebSocket.ts | 2 +- 8 files changed, 552 insertions(+), 218 deletions(-) create mode 100644 frontend/src/hooks/useRealtimeAppState.ts create mode 100644 frontend/src/test/useRealtimeAppState.test.ts diff --git a/AGENTS.md b/AGENTS.md index 02d75f0..7e3e156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,28 +28,29 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific ``` ┌─────────────────────────────────────────────────────────────────┐ -│ Frontend (React) │ +│ Frontend (React) │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ CrackerPanel (global collapsible, WebGPU cracking) │ │ │ └────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ useWebSocket ←──── Real-time updates │ -│ │ │ -│ api.ts ←──── REST API calls │ -└───────────────────────────┼──────────────────────────────────────┘ +│ │ │ +│ useWebSocket ←──── Real-time updates │ +│ │ │ +│ api.ts ←──── REST API calls │ +└───────────────────────────┼─────────────────────────────────────┘ │ HTTP + WebSocket (/api/*) ┌───────────────────────────┼──────────────────────────────────────┐ │ Backend (FastAPI) │ -│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │ -│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │ -│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │ -│ ↓ └───────────┘ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ RadioManager + Event Handlers │ │ -│ └──────────────────────────────────────────────────────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Routers │→ │ Services │→ │ Repositories │→ │ SQLite DB │ │ +│ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │ +│ ↓ │ ┌───────────┐ │ +│ ┌──────────────────────────┐ └──────────────→ │ WebSocket │ │ +│ │ RadioManager + lifecycle │ │ Manager │ │ +│ │ / event adapters │ └───────────┘ │ +│ └──────────────────────────┘ │ └───────────────────────────┼──────────────────────────────────────┘ │ Serial / TCP / BLE ┌──────┴──────┐ @@ -142,7 +143,7 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers. 1. User types message → clicks send 2. `api.sendChannelMessage()` → POST to backend -3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()` +3. Backend route delegates to service-layer send orchestration, which acquires the radio lock and calls MeshCore commands 4. Message stored in database with `outgoing=true` 5. For direct messages: ACK tracked; for channel: repeat detection @@ -162,6 +163,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ ├── AGENTS.md # Backend documentation │ ├── main.py # App entry, lifespan │ ├── routers/ # API endpoints +│ ├── services/ # Shared backend orchestration/domain services │ ├── packet_processor.py # Raw packet pipeline, dedup, path handling │ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout) │ ├── event_handlers.py # Radio events @@ -250,6 +252,8 @@ Key test files: - `tests/test_messages_search.py` - Message search, around endpoint, forward pagination - `tests/test_rx_log_data.py` - on_rx_log_data event handler integration - `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring +- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers +- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows - `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field - `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast) - `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio diff --git a/app/AGENTS.md b/app/AGENTS.md index 7e375ad..6455aae 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -21,11 +21,19 @@ app/ ├── migrations.py # Schema migrations (SQLite user_version) ├── models.py # Pydantic request/response models ├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout) -├── radio.py # RadioManager + auto-reconnect monitor +├── services/ # Shared orchestration/domain services +│ ├── messages.py # Shared message creation, dedup, ACK application +│ ├── message_send.py # Direct send, channel send, resend workflows +│ ├── dm_ack_tracker.py # Pending DM ACK state +│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring +│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers +│ └── radio_commands.py # Radio config/private-key command workflows +├── radio.py # RadioManager transport/session state + lock management ├── radio_sync.py # Polling, sync, periodic advertisement loop ├── decoder.py # Packet parsing/decryption ├── packet_processor.py # Raw packet pipeline, dedup, path handling ├── event_handlers.py # MeshCore event subscriptions and ACK tracking +├── events.py # Typed WS event payload serialization ├── websocket.py # WS manager + broadcast helpers ├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md) ├── dependencies.py # Shared FastAPI dependency providers @@ -53,13 +61,13 @@ app/ 1. Radio emits events. 2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling. -3. Decrypted messages are inserted into `messages` and broadcast over WS. +3. Shared message-domain services create/update `messages` and shape WS payloads. 4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt. ### Outgoing messages -1. Send endpoints in `routers/messages.py` call MeshCore commands. -2. Message is persisted as outgoing. +1. Send endpoints in `routers/messages.py` validate requests and delegate to `services/message_send.py`. +2. Service-layer send workflows call MeshCore commands, persist outgoing messages, and wire ACK tracking. 3. Endpoint broadcasts WS `message` event so all live clients update. 4. ACK/repeat updates arrive later as `message_acked` events. 5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend. Name changes require an explicit radio config update and are rare, but the `new_timestamp=true` resend path has no time window, so a mismatch is possible if the name was changed between the original send and a later resend. @@ -67,9 +75,9 @@ app/ ### Connection lifecycle - `RadioManager.start_connection_monitor()` checks health every 5s. -- Monitor reconnect path runs `post_connect_setup()` before broadcasting healthy state. -- Manual reconnect/reboot endpoints call `reconnect()` then `post_connect_setup()`. -- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks. +- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`. +- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state. +- Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks. ## Important Behaviors @@ -215,7 +223,7 @@ app/ - `error` — toast notification (reconnect failure, missing private key, etc.) - `success` — toast notification (historical decrypt complete, etc.) -Initial WS connect sends `health` only. Contacts/channels are loaded by REST. +Backend WS sends go through typed serialization in `events.py`. Initial WS connect sends `health` only. Contacts/channels are loaded by REST. Client sends `"ping"` text; server replies `{"type":"pong"}`. ## Data Model Notes @@ -289,6 +297,8 @@ tests/ ├── test_packet_pipeline.py # End-to-end packet processing ├── test_packets_router.py # Packets router endpoints (decrypt, maintenance) ├── test_radio.py # RadioManager, serial detection +├── test_radio_commands_service.py # Radio config/private-key service workflows +├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers ├── test_real_crypto.py # Real cryptographic operations ├── test_radio_operation.py # radio_operation() context manager ├── test_radio_router.py # Radio router endpoints diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 490aba8..1130b52 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -25,6 +25,7 @@ frontend/src/ ├── api.ts # Typed REST client ├── types.ts # Shared TS contracts ├── useWebSocket.ts # WS lifecycle + event dispatch +├── wsEvents.ts # Typed WS event parsing / discriminated union ├── messageCache.ts # Conversation-scoped cache ├── prefetch.ts # Consumes prefetched API promises started in index.html ├── index.css # Global styles/utilities @@ -36,6 +37,7 @@ frontend/src/ │ ├── index.ts # Central re-export of all hooks │ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps +│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) │ ├── useRadioControl.ts # Radio health/config state, reconnection │ ├── useAppSettings.ts # Settings, favorites, preferences migration @@ -138,8 +140,11 @@ frontend/src/ ├── useConversationMessages.race.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts + ├── useRealtimeAppState.test.ts + ├── useUnreadCounts.test.ts ├── useWebSocket.dispatch.test.ts - └── useWebSocket.lifecycle.test.ts + ├── useWebSocket.lifecycle.test.ts + └── wsEvents.test.ts ``` @@ -154,12 +159,14 @@ frontend/src/ - `useConversationRouter`: URL hash → active conversation routing - `useConversationMessages`: fetch, pagination, dedup/update helpers - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps +- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination - `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions) ### Initial load + realtime - Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads. - WebSocket: realtime deltas/events. +- On reconnect, `App.tsx` refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift. - On WS connect, backend sends `health` only; contacts/channels still come from REST. ### New Message modal @@ -193,6 +200,7 @@ frontend/src/ - Auto reconnect (3s) with cleanup guard on unmount. - Heartbeat ping every 30s. +- Incoming JSON is parsed through `wsEvents.ts`, which returns a typed discriminated union for known events and a centralized `unknown` fallback. - Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored). - For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b34a350..9b2e2f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,11 +14,11 @@ import { useWebSocket } from './useWebSocket'; import { useUnreadCounts, useConversationMessages, - getMessageContentKey, useRadioControl, useAppSettings, useConversationRouter, useContactsAndChannels, + useRealtimeAppState, } from './hooks'; import * as messageCache from './messageCache'; import { StatusBar } from './components/StatusBar'; @@ -62,24 +62,11 @@ import { SheetTitle, } from './components/ui/sheet'; import { Toaster, toast } from './components/ui/sonner'; -import { getStateKey } from './utils/conversationState'; -import { appendRawPacketUnique } from './utils/rawPacketIdentity'; import { messageContainsMention } from './utils/messageParser'; -import { mergeContactIntoList } from './utils/contactMerge'; import { getLocalLabel, getContrastTextColor } from './utils/localLabel'; import { cn } from '@/lib/utils'; import type { SearchNavigateTarget } from './components/SearchView'; -import type { - Channel, - Contact, - Conversation, - HealthStatus, - Message, - MessagePath, - RawPacket, -} from './types'; - -const MAX_RAW_PACKETS = 500; +import type { Channel, Conversation, Message, RawPacket } from './types'; export function App() { const messageInputRef = useRef(null); @@ -233,6 +220,29 @@ export function App() { return contact?.type === CONTACT_TYPE_REPEATER; }, [activeConversation, contacts]); + const wsHandlers = useRealtimeAppState({ + prevHealthRef, + setHealth, + fetchConfig, + setRawPackets, + triggerReconcile, + refreshUnreads, + setChannels, + fetchAllContacts, + setContacts, + blockedKeysRef, + blockedNamesRef, + activeConversationRef, + hasNewerMessagesRef, + addMessageIfNew, + trackNewMessage, + incrementUnread, + checkMention, + pendingDeleteFallbackRef, + setActiveConversation, + updateMessageAck, + }); + const mergeChannelIntoList = useCallback( (updated: Channel) => { setChannels((prev) => { @@ -248,185 +258,6 @@ export function App() { [setChannels] ); - // WebSocket handlers - memoized to prevent reconnection loops - const wsHandlers = useMemo( - () => ({ - onHealth: (data: HealthStatus) => { - const prev = prevHealthRef.current; - prevHealthRef.current = data; - setHealth(data); - const initializationCompleted = - prev !== null && - prev.radio_connected && - prev.radio_initializing && - data.radio_connected && - !data.radio_initializing; - - // Show toast on connection status change - if (prev !== null && prev.radio_connected !== data.radio_connected) { - if (data.radio_connected) { - toast.success('Radio connected', { - description: data.connection_info - ? `Connected via ${data.connection_info}` - : undefined, - }); - // Refresh config after reconnection (may have changed after reboot) - fetchConfig(); - } else { - toast.error('Radio disconnected', { - description: 'Check radio connection and power', - }); - } - } - - if (initializationCompleted) { - fetchConfig(); - } - }, - onError: (error: { message: string; details?: string }) => { - toast.error(error.message, { - description: error.details, - }); - }, - onSuccess: (success: { message: string; details?: string }) => { - toast.success(success.message, { - description: success.details, - }); - }, - onReconnect: () => { - // Clear raw packets: observation_id is a process-local counter that resets - // on backend restart, so stale packets would cause new ones to be deduped away. - setRawPackets([]); - // Silently recover any data missed during the disconnect window - triggerReconcile(); - refreshUnreads(); - api.getChannels().then(setChannels).catch(console.error); - fetchAllContacts() - .then((data) => setContacts(data)) - .catch(console.error); - }, - onMessage: (msg: Message) => { - // Filter blocked contacts on incoming (non-outgoing) messages - if (!msg.outgoing) { - const bKeys = blockedKeysRef.current; - const bNames = blockedNamesRef.current; - // Block DMs by sender key - if ( - bKeys.length > 0 && - msg.type === 'PRIV' && - bKeys.includes(msg.conversation_key.toLowerCase()) - ) - return; - // Block channel messages by sender key - if ( - bKeys.length > 0 && - msg.type === 'CHAN' && - msg.sender_key && - bKeys.includes(msg.sender_key.toLowerCase()) - ) - return; - // Block by sender name (works for both DMs and channel messages) - if (bNames.length > 0 && msg.sender_name && bNames.includes(msg.sender_name)) return; - } - - const activeConv = activeConversationRef.current; - - // Check if message belongs to the active conversation - const isForActiveConversation = (() => { - if (!activeConv) return false; - if (msg.type === 'CHAN' && activeConv.type === 'channel') { - return msg.conversation_key === activeConv.id; - } - if (msg.type === 'PRIV' && activeConv.type === 'contact') { - return msg.conversation_key === activeConv.id; - } - return false; - })(); - - // Only add to message list if it's for the active conversation - // and we're not viewing historical messages (hasNewerMessages means we jumped mid-history) - if (isForActiveConversation && !hasNewerMessagesRef.current) { - addMessageIfNew(msg); - } - - // Track for unread counts and sorting - trackNewMessage(msg); - - const contentKey = getMessageContentKey(msg); - - // For non-active conversations: update cache and count unreads - if (!isForActiveConversation) { - // Update message cache (instant restore on switch) — returns true if new - const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey); - - // Count unread for incoming messages (skip duplicates from multiple mesh paths) - if (!msg.outgoing && isNew) { - let stateKey: string | null = null; - if (msg.type === 'CHAN' && msg.conversation_key) { - stateKey = getStateKey('channel', msg.conversation_key); - } else if (msg.type === 'PRIV' && msg.conversation_key) { - stateKey = getStateKey('contact', msg.conversation_key); - } - if (stateKey) { - const hasMention = checkMention(msg.text); - incrementUnread(stateKey, hasMention); - } - } - } - }, - onContact: (contact: Contact) => { - setContacts((prev) => mergeContactIntoList(prev, contact)); - }, - onChannel: (channel: Channel) => { - mergeChannelIntoList(channel); - }, - onContactDeleted: (publicKey: string) => { - setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); - messageCache.remove(publicKey); - const active = activeConversationRef.current; - if (active?.type === 'contact' && active.id === publicKey) { - pendingDeleteFallbackRef.current = true; - setActiveConversation(null); - } - }, - onChannelDeleted: (key: string) => { - setChannels((prev) => prev.filter((c) => c.key !== key)); - messageCache.remove(key); - const active = activeConversationRef.current; - if (active?.type === 'channel' && active.id === key) { - pendingDeleteFallbackRef.current = true; - setActiveConversation(null); - } - }, - onRawPacket: (packet: RawPacket) => { - setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS)); - }, - onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => { - updateMessageAck(messageId, ackCount, paths); - messageCache.updateAck(messageId, ackCount, paths); - }, - }), - [ - addMessageIfNew, - trackNewMessage, - incrementUnread, - updateMessageAck, - checkMention, - fetchConfig, - mergeChannelIntoList, - prevHealthRef, - setHealth, - activeConversationRef, - hasNewerMessagesRef, - setActiveConversation, - setContacts, - setChannels, - triggerReconcile, - refreshUnreads, - fetchAllContacts, - ] - ); - // Connect to WebSocket useWebSocket(wsHandlers); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 62acf24..d4069c2 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -5,3 +5,4 @@ export { useRepeaterDashboard } from './useRepeaterDashboard'; export { useAppSettings } from './useAppSettings'; export { useConversationRouter } from './useConversationRouter'; export { useContactsAndChannels } from './useContactsAndChannels'; +export { useRealtimeAppState } from './useRealtimeAppState'; diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts new file mode 100644 index 0000000..122da47 --- /dev/null +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -0,0 +1,264 @@ +import { + useCallback, + useMemo, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import { api } from '../api'; +import * as messageCache from '../messageCache'; +import type { UseWebSocketOptions } from '../useWebSocket'; +import { toast } from '../components/ui/sonner'; +import { getStateKey } from '../utils/conversationState'; +import { mergeContactIntoList } from '../utils/contactMerge'; +import { appendRawPacketUnique } from '../utils/rawPacketIdentity'; +import { getMessageContentKey } from './useConversationMessages'; +import type { + Channel, + Contact, + Conversation, + HealthStatus, + Message, + MessagePath, + RawPacket, +} from '../types'; + +interface UseRealtimeAppStateArgs { + prevHealthRef: MutableRefObject; + setHealth: Dispatch>; + fetchConfig: () => void | Promise; + setRawPackets: Dispatch>; + triggerReconcile: () => void; + refreshUnreads: () => Promise; + setChannels: Dispatch>; + fetchAllContacts: () => Promise; + setContacts: Dispatch>; + blockedKeysRef: MutableRefObject; + blockedNamesRef: MutableRefObject; + activeConversationRef: MutableRefObject; + hasNewerMessagesRef: MutableRefObject; + addMessageIfNew: (msg: Message) => boolean; + trackNewMessage: (msg: Message) => void; + incrementUnread: (stateKey: string, hasMention?: boolean) => void; + checkMention: (text: string) => boolean; + pendingDeleteFallbackRef: MutableRefObject; + setActiveConversation: (conv: Conversation | null) => void; + updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; + maxRawPackets?: number; +} + +function isMessageBlocked(msg: Message, blockedKeys: string[], blockedNames: string[]): boolean { + if (msg.outgoing) { + return false; + } + + if (blockedKeys.length > 0) { + if (msg.type === 'PRIV' && blockedKeys.includes(msg.conversation_key.toLowerCase())) { + return true; + } + if ( + msg.type === 'CHAN' && + msg.sender_key && + blockedKeys.includes(msg.sender_key.toLowerCase()) + ) { + return true; + } + } + + return blockedNames.length > 0 && !!msg.sender_name && blockedNames.includes(msg.sender_name); +} + +function isActiveConversationMessage( + activeConversation: Conversation | null, + msg: Message +): boolean { + if (!activeConversation) return false; + if (msg.type === 'CHAN' && activeConversation.type === 'channel') { + return msg.conversation_key === activeConversation.id; + } + if (msg.type === 'PRIV' && activeConversation.type === 'contact') { + return msg.conversation_key === activeConversation.id; + } + return false; +} + +export function useRealtimeAppState({ + prevHealthRef, + setHealth, + fetchConfig, + setRawPackets, + triggerReconcile, + refreshUnreads, + setChannels, + fetchAllContacts, + setContacts, + blockedKeysRef, + blockedNamesRef, + activeConversationRef, + hasNewerMessagesRef, + addMessageIfNew, + trackNewMessage, + incrementUnread, + checkMention, + pendingDeleteFallbackRef, + setActiveConversation, + updateMessageAck, + maxRawPackets = 500, +}: UseRealtimeAppStateArgs): UseWebSocketOptions { + const mergeChannelIntoList = useCallback( + (updated: Channel) => { + setChannels((prev) => { + const existingIndex = prev.findIndex((channel) => channel.key === updated.key); + if (existingIndex === -1) { + return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name)); + } + const next = [...prev]; + next[existingIndex] = updated; + return next; + }); + }, + [setChannels] + ); + + return useMemo( + () => ({ + onHealth: (data: HealthStatus) => { + const prev = prevHealthRef.current; + prevHealthRef.current = data; + setHealth(data); + const initializationCompleted = + prev !== null && + prev.radio_connected && + prev.radio_initializing && + data.radio_connected && + !data.radio_initializing; + + if (prev !== null && prev.radio_connected !== data.radio_connected) { + if (data.radio_connected) { + toast.success('Radio connected', { + description: data.connection_info + ? `Connected via ${data.connection_info}` + : undefined, + }); + fetchConfig(); + } else { + toast.error('Radio disconnected', { + description: 'Check radio connection and power', + }); + } + } + + if (initializationCompleted) { + fetchConfig(); + } + }, + onError: (error: { message: string; details?: string }) => { + toast.error(error.message, { + description: error.details, + }); + }, + onSuccess: (success: { message: string; details?: string }) => { + toast.success(success.message, { + description: success.details, + }); + }, + onReconnect: () => { + setRawPackets([]); + triggerReconcile(); + refreshUnreads(); + api.getChannels().then(setChannels).catch(console.error); + fetchAllContacts() + .then((data) => setContacts(data)) + .catch(console.error); + }, + onMessage: (msg: Message) => { + if (isMessageBlocked(msg, blockedKeysRef.current, blockedNamesRef.current)) { + return; + } + + const isForActiveConversation = isActiveConversationMessage( + activeConversationRef.current, + msg + ); + + if (isForActiveConversation && !hasNewerMessagesRef.current) { + addMessageIfNew(msg); + } + + trackNewMessage(msg); + + const contentKey = getMessageContentKey(msg); + if (!isForActiveConversation) { + const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey); + + if (!msg.outgoing && isNew) { + let stateKey: string | null = null; + if (msg.type === 'CHAN' && msg.conversation_key) { + stateKey = getStateKey('channel', msg.conversation_key); + } else if (msg.type === 'PRIV' && msg.conversation_key) { + stateKey = getStateKey('contact', msg.conversation_key); + } + if (stateKey) { + incrementUnread(stateKey, checkMention(msg.text)); + } + } + } + }, + onContact: (contact: Contact) => { + setContacts((prev) => mergeContactIntoList(prev, contact)); + }, + onChannel: (channel: Channel) => { + mergeChannelIntoList(channel); + }, + onContactDeleted: (publicKey: string) => { + setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); + messageCache.remove(publicKey); + const active = activeConversationRef.current; + if (active?.type === 'contact' && active.id === publicKey) { + pendingDeleteFallbackRef.current = true; + setActiveConversation(null); + } + }, + onChannelDeleted: (key: string) => { + setChannels((prev) => prev.filter((c) => c.key !== key)); + messageCache.remove(key); + const active = activeConversationRef.current; + if (active?.type === 'channel' && active.id === key) { + pendingDeleteFallbackRef.current = true; + setActiveConversation(null); + } + }, + onRawPacket: (packet: RawPacket) => { + setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets)); + }, + onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => { + updateMessageAck(messageId, ackCount, paths); + messageCache.updateAck(messageId, ackCount, paths); + }, + }), + [ + activeConversationRef, + addMessageIfNew, + blockedKeysRef, + blockedNamesRef, + checkMention, + fetchAllContacts, + fetchConfig, + hasNewerMessagesRef, + incrementUnread, + maxRawPackets, + mergeChannelIntoList, + pendingDeleteFallbackRef, + prevHealthRef, + refreshUnreads, + setActiveConversation, + setChannels, + setContacts, + setHealth, + setRawPackets, + trackNewMessage, + triggerReconcile, + updateMessageAck, + ] + ); +} diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts new file mode 100644 index 0000000..63beaf8 --- /dev/null +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -0,0 +1,216 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRealtimeAppState } from '../hooks/useRealtimeAppState'; +import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types'; + +const mocks = vi.hoisted(() => ({ + api: { + getChannels: vi.fn(), + }, + toast: { + success: vi.fn(), + error: vi.fn(), + }, + messageCache: { + addMessage: vi.fn(), + remove: vi.fn(), + updateAck: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: mocks.api, +})); + +vi.mock('../components/ui/sonner', () => ({ + toast: mocks.toast, +})); + +vi.mock('../messageCache', () => mocks.messageCache); + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +const incomingDm: Message = { + id: 7, + type: 'PRIV', + conversation_key: 'aa'.repeat(32), + text: 'hello', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: 'aa'.repeat(32), + outgoing: false, + acked: 0, + sender_name: 'Alice', +}; + +function createRealtimeArgs(overrides: Partial[0]> = {}) { + const setHealth = vi.fn(); + const setRawPackets = vi.fn(); + const setChannels = vi.fn(); + const setContacts = vi.fn(); + + return { + args: { + prevHealthRef: { current: null as HealthStatus | null }, + setHealth, + fetchConfig: vi.fn(), + setRawPackets, + triggerReconcile: vi.fn(), + refreshUnreads: vi.fn(async () => {}), + setChannels, + fetchAllContacts: vi.fn(async () => [] as Contact[]), + setContacts, + blockedKeysRef: { current: [] as string[] }, + blockedNamesRef: { current: [] as string[] }, + activeConversationRef: { current: null as Conversation | null }, + hasNewerMessagesRef: { current: false }, + addMessageIfNew: vi.fn(), + trackNewMessage: vi.fn(), + incrementUnread: vi.fn(), + checkMention: vi.fn(() => false), + pendingDeleteFallbackRef: { current: false }, + setActiveConversation: vi.fn(), + updateMessageAck: vi.fn(), + ...overrides, + }, + fns: { + setHealth, + setRawPackets, + setChannels, + setContacts, + }, + }; +} + +describe('useRealtimeAppState', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.api.getChannels.mockResolvedValue([publicChannel]); + }); + + it('reconnect clears raw packets and refetches channels/contacts/unreads', async () => { + const contacts: Contact[] = [ + { + public_key: 'bb'.repeat(32), + name: 'Bob', + type: 1, + flags: 0, + last_path: null, + last_path_len: 0, + out_path_hash_mode: 0, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }, + ]; + + const { args, fns } = createRealtimeArgs({ + fetchAllContacts: vi.fn(async () => contacts), + }); + + const { result } = renderHook(() => useRealtimeAppState(args)); + + act(() => { + result.current.onReconnect?.(); + }); + + await waitFor(() => { + expect(args.triggerReconcile).toHaveBeenCalledTimes(1); + expect(args.refreshUnreads).toHaveBeenCalledTimes(1); + expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); + expect(args.fetchAllContacts).toHaveBeenCalledTimes(1); + expect(fns.setRawPackets).toHaveBeenCalledWith([]); + expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]); + expect(fns.setContacts).toHaveBeenCalledWith(contacts); + }); + }); + + it('tracks unread state for a new non-active incoming message', () => { + mocks.messageCache.addMessage.mockReturnValue(true); + const { args } = createRealtimeArgs({ + checkMention: vi.fn(() => true), + }); + + const { result } = renderHook(() => useRealtimeAppState(args)); + + act(() => { + result.current.onMessage?.(incomingDm); + }); + + expect(args.addMessageIfNew).not.toHaveBeenCalled(); + expect(args.trackNewMessage).toHaveBeenCalledWith(incomingDm); + expect(mocks.messageCache.addMessage).toHaveBeenCalledWith( + incomingDm.conversation_key, + incomingDm, + expect.any(String) + ); + expect(args.incrementUnread).toHaveBeenCalledWith( + `contact-${incomingDm.conversation_key}`, + true + ); + }); + + it('deleting the active contact clears it and marks fallback recovery pending', () => { + const pendingDeleteFallbackRef = { current: false }; + const activeConversationRef = { + current: { + type: 'contact', + id: incomingDm.conversation_key, + name: 'Alice', + } satisfies Conversation, + }; + const { args, fns } = createRealtimeArgs({ + activeConversationRef, + pendingDeleteFallbackRef, + }); + + const { result } = renderHook(() => useRealtimeAppState(args)); + + act(() => { + result.current.onContactDeleted?.(incomingDm.conversation_key); + }); + + expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function)); + expect(mocks.messageCache.remove).toHaveBeenCalledWith(incomingDm.conversation_key); + expect(args.setActiveConversation).toHaveBeenCalledWith(null); + expect(pendingDeleteFallbackRef.current).toBe(true); + }); + + it('appends raw packets using observation identity dedup', () => { + const { args, fns } = createRealtimeArgs(); + const packet: RawPacket = { + id: 1, + observation_id: 2, + timestamp: 1700000000, + data: 'aabb', + payload_type: 'GROUP_TEXT', + snr: 7.5, + rssi: -80, + decrypted: false, + decrypted_info: null, + }; + + const { result } = renderHook(() => useRealtimeAppState(args)); + + act(() => { + result.current.onRawPacket?.(packet); + }); + + expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function)); + }); +}); diff --git a/frontend/src/useWebSocket.ts b/frontend/src/useWebSocket.ts index 70548b2..f97294e 100644 --- a/frontend/src/useWebSocket.ts +++ b/frontend/src/useWebSocket.ts @@ -12,7 +12,7 @@ interface SuccessEvent { details?: string; } -interface UseWebSocketOptions { +export interface UseWebSocketOptions { onHealth?: (health: HealthStatus) => void; onMessage?: (message: Message) => void; onContact?: (contact: Contact) => void; From 56e5e0d278d919927392783e0e9d353a2e03acbb Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 18:37:06 -0700 Subject: [PATCH 09/27] extract frontend conversation actions hook --- frontend/AGENTS.md | 8 +- frontend/src/App.tsx | 214 +++------------ frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useConversationActions.ts | 255 ++++++++++++++++++ .../src/test/useConversationActions.test.ts | 179 ++++++++++++ 5 files changed, 474 insertions(+), 183 deletions(-) create mode 100644 frontend/src/hooks/useConversationActions.ts create mode 100644 frontend/src/test/useConversationActions.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 1130b52..fed4a71 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -35,6 +35,7 @@ frontend/src/ │ └── utils.ts # cn() — clsx + tailwind-merge helper ├── hooks/ │ ├── index.ts # Central re-export of all hooks +│ ├── useConversationActions.ts # Send/navigation/info-pane conversation actions │ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery @@ -157,6 +158,7 @@ frontend/src/ - `useAppSettings`: settings CRUD, favorites, preferences migration - `useContactsAndChannels`: contact/channel lists, creation, deletion - `useConversationRouter`: URL hash → active conversation routing +- `useConversationActions`: send/resend/trace/navigation handlers and info-pane state - `useConversationMessages`: fetch, pagination, dedup/update helpers - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps - `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination @@ -284,7 +286,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf - Nearest repeaters (resolved from first-hop path prefixes) - Recent advert paths -State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot. +State: `useConversationActions` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot. ## Channel Info Pane @@ -296,7 +298,7 @@ Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right d - First message date - Top senders in last 24h (name + count) -State: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot. +State: `useConversationActions` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot. ## Repeater Dashboard @@ -316,7 +318,7 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors: -- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation. +- **State**: `targetMessageId` is shared between `App.tsx`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. - **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results. - **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. - **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b2e2f1..51664c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,9 +18,9 @@ import { useAppSettings, useConversationRouter, useContactsAndChannels, + useConversationActions, useRealtimeAppState, } from './hooks'; -import * as messageCache from './messageCache'; import { StatusBar } from './components/StatusBar'; import { Sidebar } from './components/Sidebar'; import { ChatHeader } from './components/ChatHeader'; @@ -61,12 +61,11 @@ import { SheetHeader, SheetTitle, } from './components/ui/sheet'; -import { Toaster, toast } from './components/ui/sonner'; +import { Toaster } from './components/ui/sonner'; import { messageContainsMention } from './utils/messageParser'; import { getLocalLabel, getContrastTextColor } from './utils/localLabel'; import { cn } from '@/lib/utils'; -import type { SearchNavigateTarget } from './components/SearchView'; -import type { Channel, Conversation, Message, RawPacket } from './types'; +import type { Conversation, RawPacket } from './types'; export function App() { const messageInputRef = useRef(null); @@ -78,9 +77,6 @@ export function App() { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); - const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); - const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); - const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); const [targetMessageId, setTargetMessageId] = useState(null); // Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state) @@ -242,21 +238,37 @@ export function App() { setActiveConversation, updateMessageAck, }); - - const mergeChannelIntoList = useCallback( - (updated: Channel) => { - setChannels((prev) => { - const existingIndex = prev.findIndex((channel) => channel.key === updated.key); - if (existingIndex === -1) { - return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name)); - } - const next = [...prev]; - next[existingIndex] = updated; - return next; - }); - }, - [setChannels] - ); + const { + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleSendMessage, + handleResendChannelMessage, + handleSetChannelFloodScopeOverride, + handleSenderClick, + handleTrace, + handleBlockKey, + handleBlockName, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + } = useConversationActions({ + activeConversation, + activeConversationRef, + setTargetMessageId, + channels, + setChannels, + addMessageIfNew, + jumpToBottom, + handleToggleBlockedKey, + handleToggleBlockedName, + handleSelectConversation, + messageInputRef, + }); // Connect to WebSocket useWebSocket(wsHandlers); @@ -288,106 +300,6 @@ export function App() { setContactsLoaded, ]); - // Send message handler - const handleSendMessage = useCallback( - async (text: string) => { - if (!activeConversation) return; - - const conversationId = activeConversation.id; - - let sent: Message; - if (activeConversation.type === 'channel') { - sent = await api.sendChannelMessage(activeConversation.id, text); - } else { - sent = await api.sendDirectMessage(activeConversation.id, text); - } - - if (activeConversationRef.current?.id === conversationId) { - addMessageIfNew(sent); - } - }, - [activeConversation, addMessageIfNew, activeConversationRef] - ); - - // Handle resend channel message - const handleResendChannelMessage = useCallback( - async (messageId: number, newTimestamp?: boolean) => { - try { - // New-timestamp resend creates a new message; the backend broadcast_event - // will add it to the conversation via WebSocket. - await api.resendChannelMessage(messageId, newTimestamp); - toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); - } catch (err) { - toast.error('Failed to resend', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, - [] - ); - - const handleSetChannelFloodScopeOverride = useCallback( - async (channelKey: string, floodScopeOverride: string) => { - try { - const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride); - mergeChannelIntoList(updated); - toast.success( - updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared' - ); - } catch (err) { - toast.error('Failed to update regional override', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, - [mergeChannelIntoList] - ); - - // Handle sender click to add mention - const handleSenderClick = useCallback((sender: string) => { - messageInputRef.current?.appendText(`@[${sender}] `); - }, []); - - // Handle direct trace request - const handleTrace = useCallback(async () => { - if (!activeConversation || activeConversation.type !== 'contact') return; - toast('Trace started...'); - try { - const result = await api.requestTrace(activeConversation.id); - const parts: string[] = []; - if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`); - if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`); - const detail = parts.join(', '); - toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!'); - } catch (err) { - toast.error('Trace failed', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, [activeConversation]); - - // Wrappers that clear cache and hard-refetch messages after block changes. - // jumpToBottom does cache.remove + fetchMessages(true) which fully replaces - // the message state; triggerReconcile only merges diffs and would keep - // blocked messages already in state. - const handleBlockKey = useCallback( - async (key: string) => { - await handleToggleBlockedKey(key); - messageCache.clear(); - jumpToBottom(); - }, - [handleToggleBlockedKey, jumpToBottom] - ); - - const handleBlockName = useCallback( - async (name: string) => { - await handleToggleBlockedName(name); - messageCache.clear(); - jumpToBottom(); - }, - [handleToggleBlockedName, jumpToBottom] - ); - const handleCloseSettingsView = useCallback(() => { startTransition(() => setShowSettings(false)); setSidebarOpen(false); @@ -409,64 +321,6 @@ export function App() { setShowCracker((prev) => !prev); }, []); - const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { - setInfoPaneContactKey(publicKey); - setInfoPaneFromChannel(fromChannel ?? false); - }, []); - - const handleCloseContactInfo = useCallback(() => { - setInfoPaneContactKey(null); - }, []); - - const handleOpenChannelInfo = useCallback((channelKey: string) => { - setInfoPaneChannelKey(channelKey); - }, []); - - const handleCloseChannelInfo = useCallback(() => { - setInfoPaneChannelKey(null); - }, []); - - const handleSelectConversationWithTargetReset = useCallback( - (conv: Conversation, options?: { preserveTarget?: boolean }) => { - if (conv.type !== 'search' && !options?.preserveTarget) { - setTargetMessageId(null); - } - handleSelectConversation(conv); - }, - [handleSelectConversation] - ); - - const handleNavigateToChannel = useCallback( - (channelKey: string) => { - const channel = channels.find((c) => c.key === channelKey); - if (channel) { - handleSelectConversationWithTargetReset({ - type: 'channel', - id: channel.key, - name: channel.name, - }); - setInfoPaneContactKey(null); - } - }, - [channels, handleSelectConversationWithTargetReset] - ); - - const handleNavigateToMessage = useCallback( - (target: SearchNavigateTarget) => { - const convType = target.type === 'CHAN' ? 'channel' : 'contact'; - setTargetMessageId(target.id); - handleSelectConversationWithTargetReset( - { - type: convType, - id: target.conversation_key, - name: target.conversation_name, - }, - { preserveTarget: true } - ); - }, - [handleSelectConversationWithTargetReset] - ); - // Sidebar content (shared between desktop and mobile) const sidebarContent = ( ; + setTargetMessageId: Dispatch>; + channels: Channel[]; + setChannels: React.Dispatch>; + addMessageIfNew: (msg: Message) => boolean; + jumpToBottom: () => void; + handleToggleBlockedKey: (key: string) => Promise; + handleToggleBlockedName: (name: string) => Promise; + handleSelectConversation: (conv: Conversation) => void; + messageInputRef: RefObject; +} + +interface UseConversationActionsResult { + infoPaneContactKey: string | null; + infoPaneFromChannel: boolean; + infoPaneChannelKey: string | null; + handleSendMessage: (text: string) => Promise; + handleResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; + handleSetChannelFloodScopeOverride: ( + channelKey: string, + floodScopeOverride: string + ) => Promise; + handleSenderClick: (sender: string) => void; + handleTrace: () => Promise; + handleBlockKey: (key: string) => Promise; + handleBlockName: (name: string) => Promise; + handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; + handleCloseContactInfo: () => void; + handleOpenChannelInfo: (channelKey: string) => void; + handleCloseChannelInfo: () => void; + handleSelectConversationWithTargetReset: ( + conv: Conversation, + options?: { preserveTarget?: boolean } + ) => void; + handleNavigateToChannel: (channelKey: string) => void; + handleNavigateToMessage: (target: SearchNavigateTarget) => void; +} + +export function useConversationActions({ + activeConversation, + activeConversationRef, + setTargetMessageId, + channels, + setChannels, + addMessageIfNew, + jumpToBottom, + handleToggleBlockedKey, + handleToggleBlockedName, + handleSelectConversation, + messageInputRef, +}: UseConversationActionsArgs): UseConversationActionsResult { + const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); + const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); + const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); + + const mergeChannelIntoList = useCallback( + (updated: Channel) => { + setChannels((prev) => { + const existingIndex = prev.findIndex((channel) => channel.key === updated.key); + if (existingIndex === -1) { + return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name)); + } + const next = [...prev]; + next[existingIndex] = updated; + return next; + }); + }, + [setChannels] + ); + + const handleSendMessage = useCallback( + async (text: string) => { + if (!activeConversation) return; + + const conversationId = activeConversation.id; + const sent = + activeConversation.type === 'channel' + ? await api.sendChannelMessage(activeConversation.id, text) + : await api.sendDirectMessage(activeConversation.id, text); + + if (activeConversationRef.current?.id === conversationId) { + addMessageIfNew(sent); + } + }, + [activeConversation, activeConversationRef, addMessageIfNew] + ); + + const handleResendChannelMessage = useCallback( + async (messageId: number, newTimestamp?: boolean) => { + try { + await api.resendChannelMessage(messageId, newTimestamp); + toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); + } catch (err) { + toast.error('Failed to resend', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, + [] + ); + + const handleSetChannelFloodScopeOverride = useCallback( + async (channelKey: string, floodScopeOverride: string) => { + try { + const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride); + mergeChannelIntoList(updated); + toast.success( + updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared' + ); + } catch (err) { + toast.error('Failed to update regional override', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, + [mergeChannelIntoList] + ); + + const handleSenderClick = useCallback( + (sender: string) => { + messageInputRef.current?.appendText(`@[${sender}] `); + }, + [messageInputRef] + ); + + const handleTrace = useCallback(async () => { + if (!activeConversation || activeConversation.type !== 'contact') return; + toast('Trace started...'); + try { + const result = await api.requestTrace(activeConversation.id); + const parts: string[] = []; + if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`); + if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`); + const detail = parts.join(', '); + toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!'); + } catch (err) { + toast.error('Trace failed', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, [activeConversation]); + + const handleBlockKey = useCallback( + async (key: string) => { + await handleToggleBlockedKey(key); + messageCache.clear(); + jumpToBottom(); + }, + [handleToggleBlockedKey, jumpToBottom] + ); + + const handleBlockName = useCallback( + async (name: string) => { + await handleToggleBlockedName(name); + messageCache.clear(); + jumpToBottom(); + }, + [handleToggleBlockedName, jumpToBottom] + ); + + const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { + setInfoPaneContactKey(publicKey); + setInfoPaneFromChannel(fromChannel ?? false); + }, []); + + const handleCloseContactInfo = useCallback(() => { + setInfoPaneContactKey(null); + }, []); + + const handleOpenChannelInfo = useCallback((channelKey: string) => { + setInfoPaneChannelKey(channelKey); + }, []); + + const handleCloseChannelInfo = useCallback(() => { + setInfoPaneChannelKey(null); + }, []); + + const handleSelectConversationWithTargetReset = useCallback( + (conv: Conversation, options?: { preserveTarget?: boolean }) => { + if (conv.type !== 'search' && !options?.preserveTarget) { + setTargetMessageId(null); + } + handleSelectConversation(conv); + }, + [handleSelectConversation, setTargetMessageId] + ); + + const handleNavigateToChannel = useCallback( + (channelKey: string) => { + const channel = channels.find((c) => c.key === channelKey); + if (channel) { + handleSelectConversationWithTargetReset({ + type: 'channel', + id: channel.key, + name: channel.name, + }); + setInfoPaneContactKey(null); + } + }, + [channels, handleSelectConversationWithTargetReset] + ); + + const handleNavigateToMessage = useCallback( + (target: SearchNavigateTarget) => { + const convType = target.type === 'CHAN' ? 'channel' : 'contact'; + setTargetMessageId(target.id); + handleSelectConversationWithTargetReset( + { + type: convType, + id: target.conversation_key, + name: target.conversation_name, + }, + { preserveTarget: true } + ); + }, + [handleSelectConversationWithTargetReset, setTargetMessageId] + ); + + return { + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleSendMessage, + handleResendChannelMessage, + handleSetChannelFloodScopeOverride, + handleSenderClick, + handleTrace, + handleBlockKey, + handleBlockName, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + }; +} diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts new file mode 100644 index 0000000..5778166 --- /dev/null +++ b/frontend/src/test/useConversationActions.test.ts @@ -0,0 +1,179 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useConversationActions } from '../hooks/useConversationActions'; +import type { Channel, Conversation, Message } from '../types'; + +const mocks = vi.hoisted(() => ({ + api: { + requestTrace: vi.fn(), + resendChannelMessage: vi.fn(), + sendChannelMessage: vi.fn(), + sendDirectMessage: vi.fn(), + setChannelFloodScopeOverride: vi.fn(), + }, + messageCache: { + clear: vi.fn(), + }, + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: mocks.api, +})); + +vi.mock('../messageCache', () => mocks.messageCache); + +vi.mock('../components/ui/sonner', () => ({ + toast: mocks.toast, +})); + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +const sentMessage: Message = { + id: 42, + type: 'CHAN', + conversation_key: publicChannel.key, + text: 'hello mesh', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: null, + outgoing: true, + acked: 0, + sender_name: 'Radio', +}; + +function createArgs(overrides: Partial[0]> = {}) { + const activeConversation: Conversation = { + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }; + + return { + activeConversation, + activeConversationRef: { current: activeConversation }, + setTargetMessageId: vi.fn(), + channels: [publicChannel], + setChannels: vi.fn(), + addMessageIfNew: vi.fn(() => true), + jumpToBottom: vi.fn(), + handleToggleBlockedKey: vi.fn(async () => {}), + handleToggleBlockedName: vi.fn(async () => {}), + handleSelectConversation: vi.fn(), + messageInputRef: { current: { appendText: vi.fn() } }, + ...overrides, + }; +} + +describe('useConversationActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('appends a sent message when the user is still in the same conversation', async () => { + mocks.api.sendChannelMessage.mockResolvedValue(sentMessage); + const args = createArgs(); + + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handleSendMessage(sentMessage.text); + }); + + expect(mocks.api.sendChannelMessage).toHaveBeenCalledWith(publicChannel.key, sentMessage.text); + expect(args.addMessageIfNew).toHaveBeenCalledWith(sentMessage); + }); + + it('does not append a sent message after the active conversation changes', async () => { + let resolveSend: ((message: Message) => void) | null = null; + mocks.api.sendChannelMessage.mockImplementation( + () => + new Promise((resolve) => { + resolveSend = resolve; + }) + ); + + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + const sendPromise = result.current.handleSendMessage(sentMessage.text); + args.activeConversationRef.current = { + type: 'contact', + id: 'aa'.repeat(32), + name: 'Alice', + }; + resolveSend?.(sentMessage); + await sendPromise; + }); + + expect(args.addMessageIfNew).not.toHaveBeenCalled(); + }); + + it('resets the jump target when switching to a normal conversation', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + act(() => { + result.current.handleSelectConversationWithTargetReset({ + type: 'contact', + id: 'bb'.repeat(32), + name: 'Bob', + }); + }); + + expect(args.setTargetMessageId).toHaveBeenCalledWith(null); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'contact', + id: 'bb'.repeat(32), + name: 'Bob', + }); + }); + + it('navigates search results into the target conversation and preserves the jump target', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + act(() => { + result.current.handleNavigateToMessage({ + id: 321, + type: 'CHAN', + conversation_key: publicChannel.key, + conversation_name: publicChannel.name, + }); + }); + + expect(args.setTargetMessageId).toHaveBeenCalledWith(321); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }); + + it('clears cached messages and jumps to the latest page after blocking a key', async () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handleBlockKey('cc'.repeat(32)); + }); + + expect(args.handleToggleBlockedKey).toHaveBeenCalledWith('cc'.repeat(32)); + expect(mocks.messageCache.clear).toHaveBeenCalledTimes(1); + expect(args.jumpToBottom).toHaveBeenCalledTimes(1); + }); +}); From ae0ef90fe2cbdbcddbf2aec8544ab331aabaf711 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 19:12:26 -0700 Subject: [PATCH 10/27] extract conversation timeline hook --- frontend/AGENTS.md | 9 +- frontend/src/hooks/useConversationMessages.ts | 447 ++---------------- frontend/src/hooks/useConversationTimeline.ts | 399 ++++++++++++++++ .../test/useConversationMessages.race.test.ts | 61 +++ 4 files changed, 499 insertions(+), 417 deletions(-) create mode 100644 frontend/src/hooks/useConversationTimeline.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index fed4a71..6531178 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -36,7 +36,8 @@ frontend/src/ ├── hooks/ │ ├── index.ts # Central re-export of all hooks │ ├── useConversationActions.ts # Send/navigation/info-pane conversation actions -│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering +│ ├── useConversationMessages.ts # Dedup/update helpers over the conversation timeline +│ ├── useConversationTimeline.ts # Fetch, cache restore, jump-target loading, pagination, reconcile │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) @@ -159,7 +160,8 @@ frontend/src/ - `useContactsAndChannels`: contact/channel lists, creation, deletion - `useConversationRouter`: URL hash → active conversation routing - `useConversationActions`: send/resend/trace/navigation handlers and info-pane state -- `useConversationMessages`: fetch, pagination, dedup/update helpers +- `useConversationMessages`: dedup/update helpers and pending ACK buffering +- `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps - `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination - `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions) @@ -319,8 +321,9 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors: - **State**: `targetMessageId` is shared between `App.tsx`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. +- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page. - **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results. -- **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. +- **Jump-to-message**: `useConversationTimeline` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. - **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling. - **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page). diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index f75980d..e85d146 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -1,10 +1,13 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; -import { toast } from '../components/ui/sonner'; -import { api, isAbortError } from '../api'; -import * as messageCache from '../messageCache'; +import { + useCallback, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import { useConversationTimeline } from './useConversationTimeline'; import type { Conversation, Message, MessagePath } from '../types'; -const MESSAGE_PAGE_SIZE = 200; const MAX_PENDING_ACKS = 500; interface PendingAckUpdate { @@ -64,8 +67,8 @@ interface UseConversationMessagesResult { hasOlderMessages: boolean; hasNewerMessages: boolean; loadingNewer: boolean; - hasNewerMessagesRef: React.MutableRefObject; - setMessages: React.Dispatch>; + hasNewerMessagesRef: MutableRefObject; + setMessages: Dispatch>; fetchOlderMessages: () => Promise; fetchNewerMessages: () => Promise; jumpToBottom: () => void; @@ -78,13 +81,6 @@ export function useConversationMessages( activeConversation: Conversation | null, targetMessageId?: number | null ): UseConversationMessagesResult { - const [messages, setMessages] = useState([]); - const [messagesLoading, setMessagesLoading] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [hasOlderMessages, setHasOlderMessages] = useState(false); - const [hasNewerMessages, setHasNewerMessages] = useState(false); - const [loadingNewer, setLoadingNewer] = useState(false); - // Track seen message content for deduplication const seenMessageContent = useRef>(new Set()); @@ -92,31 +88,6 @@ export function useConversationMessages( // Buffer latest ACK state by message_id and apply when the message arrives. const pendingAcksRef = useRef>(new Map()); - // AbortController for cancelling in-flight requests on conversation change - const abortControllerRef = useRef(null); - - // Ref to track the conversation ID being fetched to prevent stale responses - const fetchingConversationIdRef = useRef(null); - - // --- Cache integration refs --- - // Keep refs in sync with state so we can read current values in the switch effect - const messagesRef = useRef([]); - const hasOlderMessagesRef = useRef(false); - const hasNewerMessagesRef = useRef(false); - const prevConversationIdRef = useRef(null); - - useEffect(() => { - messagesRef.current = messages; - }, [messages]); - - useEffect(() => { - hasOlderMessagesRef.current = hasOlderMessages; - }, [hasOlderMessages]); - - useEffect(() => { - hasNewerMessagesRef.current = hasNewerMessages; - }, [hasNewerMessages]); - const setPendingAck = useCallback( (messageId: number, ackCount: number, paths?: MessagePath[]) => { const existing = pendingAcksRef.current.get(messageId); @@ -149,379 +120,27 @@ export function useConversationMessages( }; }, []); - // Fetch messages for active conversation - // Note: This is called manually and from the useEffect. The useEffect handles - // cancellation via AbortController; manual calls (e.g., after sending a message) - // don't need cancellation. - const fetchMessages = useCallback( - async (showLoading = false, signal?: AbortSignal) => { - if ( - !activeConversation || - activeConversation.type === 'raw' || - activeConversation.type === 'map' || - activeConversation.type === 'visualizer' || - activeConversation.type === 'search' - ) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - // Track which conversation we're fetching for - const conversationId = activeConversation.id; - - if (showLoading) { - setMessagesLoading(true); - // Clear messages first so MessageList resets scroll state for new conversation - setMessages([]); - } - try { - const data = await api.getMessages( - { - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: activeConversation.id, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ); - - // Check if this response is still for the current conversation - // This handles the race where the conversation changed while awaiting - if (fetchingConversationIdRef.current !== conversationId) { - // Stale response - conversation changed while we were fetching - return; - } - - const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); - setMessages(messagesWithPendingAck); - // Track seen content for new messages - seenMessageContent.current.clear(); - for (const msg of messagesWithPendingAck) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - // If we got a full page, there might be more - setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - // Don't show error toast for aborted requests (user switched conversations) - if (isAbortError(err)) { - return; - } - console.error('Failed to fetch messages:', err); - toast.error('Failed to load messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - if (showLoading) { - setMessagesLoading(false); - } - } - }, - [activeConversation, applyPendingAck] - ); - - // Fetch older messages (cursor-based pagination) - const fetchOlderMessages = useCallback(async () => { - if ( - !activeConversation || - activeConversation.type === 'raw' || - loadingOlder || - !hasOlderMessages - ) - return; - - const conversationId = activeConversation.id; - - // Get the true oldest message as cursor for the next page - const oldestMessage = messages.reduce( - (oldest, msg) => { - if (!oldest) return msg; - if (msg.received_at < oldest.received_at) return msg; - if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; - return oldest; - }, - null as Message | null - ); - if (!oldestMessage) return; - - setLoadingOlder(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - before: oldestMessage.received_at, - before_id: oldestMessage.id, - }); - - // Guard against stale response if the user switched conversations mid-request - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - - if (dataWithPendingAck.length > 0) { - // Prepend older messages (they come sorted DESC, so older are at the end) - setMessages((prev) => [...prev, ...dataWithPendingAck]); - // Track seen content - for (const msg of dataWithPendingAck) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - } - // If we got less than a full page, no more messages - setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch older messages:', err); - toast.error('Failed to load older messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingOlder(false); - } - }, [activeConversation, loadingOlder, hasOlderMessages, messages, applyPendingAck]); - - // Fetch newer messages (forward cursor pagination) - const fetchNewerMessages = useCallback(async () => { - if ( - !activeConversation || - activeConversation.type === 'raw' || - loadingNewer || - !hasNewerMessages - ) - return; - - const conversationId = activeConversation.id; - - // Get the newest message as forward cursor - const newestMessage = messages.reduce( - (newest, msg) => { - if (!newest) return msg; - if (msg.received_at > newest.received_at) return msg; - if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; - return newest; - }, - null as Message | null - ); - if (!newestMessage) return; - - setLoadingNewer(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - after: newestMessage.received_at, - after_id: newestMessage.id, - }); - - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - - // Deduplicate against already-seen messages (WS race) - const newMessages = dataWithPendingAck.filter( - (msg) => !seenMessageContent.current.has(getMessageContentKey(msg)) - ); - if (newMessages.length > 0) { - setMessages((prev) => [...prev, ...newMessages]); - for (const msg of newMessages) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - } - setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch newer messages:', err); - toast.error('Failed to load newer messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingNewer(false); - } - }, [activeConversation, loadingNewer, hasNewerMessages, messages, applyPendingAck]); - - // Jump to bottom: re-fetch latest page, clear hasNewerMessages - const jumpToBottom = useCallback(() => { - if (!activeConversation) return; - setHasNewerMessages(false); - // Invalidate cache so fetchMessages does a fresh load - messageCache.remove(activeConversation.id); - fetchMessages(true); - }, [activeConversation, fetchMessages]); - - // Trigger a background reconciliation for the current conversation. - // Used after WebSocket reconnects to silently recover any missed messages. - const triggerReconcile = useCallback(() => { - const conv = activeConversation; - if ( - !conv || - conv.type === 'raw' || - conv.type === 'map' || - conv.type === 'visualizer' || - conv.type === 'search' - ) - return; - const controller = new AbortController(); - reconcileFromBackend(conv, controller.signal); - }, [activeConversation]); // eslint-disable-line react-hooks/exhaustive-deps - - // Background reconciliation: silently fetch from backend after a cache restore - // and only update state if something differs (missed WS message, stale ack, etc.). - // No-ops on the happy path — zero rerenders when cache is already consistent. - function reconcileFromBackend(conversation: Conversation, signal: AbortSignal) { - const conversationId = conversation.id; - api - .getMessages( - { - type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ) - .then((data) => { - // Stale check — conversation may have changed while awaiting - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); - if (!merged) return; // Cache was consistent — no rerender - - setMessages(merged); - seenMessageContent.current.clear(); - for (const msg of merged) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { - setHasOlderMessages(true); - } - }) - .catch((err) => { - if (isAbortError(err)) return; - // Silent failure — we already have cached data - console.debug('Background reconciliation failed:', err); - }); - } - - // Fetch messages when conversation changes, with proper cancellation and caching - useEffect(() => { - // Abort any previous in-flight request - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const prevId = prevConversationIdRef.current; - - // Track which conversation we're now on - const newId = activeConversation?.id ?? null; - const conversationChanged = prevId !== newId; - fetchingConversationIdRef.current = newId; - prevConversationIdRef.current = newId; - - // When targetMessageId goes from a value to null (onTargetReached cleared it) - // but the conversation hasn't changed, the around-loaded messages are already - // displayed — do nothing. Without this guard the effect would re-enter the - // normal fetch path and replace the mid-history view with the latest page. - if (!conversationChanged && !targetMessageId) { - return; - } - - // Reset loadingOlder/loadingNewer — the previous conversation's in-flight - // fetch is irrelevant now (its stale-check will discard the response). - setLoadingOlder(false); - setLoadingNewer(false); - if (conversationChanged) { - setHasNewerMessages(false); - } - - // Save outgoing conversation to cache only when actually leaving it, and - // only if we were on the latest page (mid-history views would restore stale - // partial data on switch-back). - if ( - conversationChanged && - prevId && - messagesRef.current.length > 0 && - !hasNewerMessagesRef.current - ) { - messageCache.set(prevId, { - messages: messagesRef.current, - seenContent: new Set(seenMessageContent.current), - hasOlderMessages: hasOlderMessagesRef.current, - }); - } - - // Clear state for non-message views - if ( - !activeConversation || - activeConversation.type === 'raw' || - activeConversation.type === 'map' || - activeConversation.type === 'visualizer' || - activeConversation.type === 'search' - ) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - // Create AbortController for this conversation's fetch (cache reconcile or full fetch) - const controller = new AbortController(); - abortControllerRef.current = controller; - - // Jump-to-message: skip cache and load messages around the target - if (targetMessageId) { - setMessagesLoading(true); - setMessages([]); - const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; - api - .getMessagesAround( - targetMessageId, - msgType as 'PRIV' | 'CHAN', - activeConversation.id, - controller.signal - ) - .then((response) => { - if (fetchingConversationIdRef.current !== activeConversation.id) return; - const withAcks = response.messages.map((msg) => applyPendingAck(msg)); - setMessages(withAcks); - seenMessageContent.current.clear(); - for (const msg of withAcks) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - setHasOlderMessages(response.has_older); - setHasNewerMessages(response.has_newer); - }) - .catch((err) => { - if (isAbortError(err)) return; - console.error('Failed to fetch messages around target:', err); - toast.error('Failed to jump to message'); - }) - .finally(() => { - setMessagesLoading(false); - }); - } else { - // Check cache for the new conversation - const cached = messageCache.get(activeConversation.id); - if (cached) { - // Restore from cache instantly — no spinner - setMessages(cached.messages); - seenMessageContent.current = new Set(cached.seenContent); - setHasOlderMessages(cached.hasOlderMessages); - setMessagesLoading(false); - // Silently reconcile with backend in case we missed a WS message - reconcileFromBackend(activeConversation, controller.signal); - } else { - // Not cached — full fetch with spinner - fetchMessages(true, controller.signal); - } - } - - // Cleanup: abort request if conversation changes or component unmounts - return () => { - controller.abort(); - }; - // NOTE: Intentionally omitting fetchMessages and activeConversation from deps: - // - fetchMessages is recreated when activeConversation changes, which would cause infinite loops - // - activeConversation object identity changes on every render; we only care about id/type - // - We use fetchingConversationIdRef and AbortController to handle stale responses safely - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeConversation?.id, activeConversation?.type, targetMessageId]); + const { + messages, + messagesRef, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + hasNewerMessagesRef, + setMessages, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + triggerReconcile, + } = useConversationTimeline({ + activeConversation, + targetMessageId, + applyPendingAck, + getMessageContentKey, + seenMessageContentRef: seenMessageContent, + }); // Add a message if it's new (deduplication) // Returns true if the message was added, false if it was a duplicate @@ -555,7 +174,7 @@ export function useConversationMessages( return true; }, - [applyPendingAck] + [applyPendingAck, messagesRef, setMessages] ); // Update a message's ack count and paths @@ -592,7 +211,7 @@ export function useConversationMessages( return prev; }); }, - [setPendingAck] + [messagesRef, setMessages, setPendingAck] ); return { diff --git a/frontend/src/hooks/useConversationTimeline.ts b/frontend/src/hooks/useConversationTimeline.ts new file mode 100644 index 0000000..a080338 --- /dev/null +++ b/frontend/src/hooks/useConversationTimeline.ts @@ -0,0 +1,399 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import { toast } from '../components/ui/sonner'; +import { api, isAbortError } from '../api'; +import * as messageCache from '../messageCache'; +import type { Conversation, Message } from '../types'; + +const MESSAGE_PAGE_SIZE = 200; + +interface UseConversationTimelineArgs { + activeConversation: Conversation | null; + targetMessageId?: number | null; + applyPendingAck: (msg: Message) => Message; + getMessageContentKey: (msg: Message) => string; + seenMessageContentRef: MutableRefObject>; +} + +interface UseConversationTimelineResult { + messages: Message[]; + messagesRef: MutableRefObject; + messagesLoading: boolean; + loadingOlder: boolean; + hasOlderMessages: boolean; + hasNewerMessages: boolean; + loadingNewer: boolean; + hasNewerMessagesRef: MutableRefObject; + setMessages: Dispatch>; + fetchOlderMessages: () => Promise; + fetchNewerMessages: () => Promise; + jumpToBottom: () => void; + triggerReconcile: () => void; +} + +function isMessageConversation(conversation: Conversation | null): conversation is Conversation { + return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); +} + +export function useConversationTimeline({ + activeConversation, + targetMessageId, + applyPendingAck, + getMessageContentKey, + seenMessageContentRef, +}: UseConversationTimelineArgs): UseConversationTimelineResult { + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [hasOlderMessages, setHasOlderMessages] = useState(false); + const [hasNewerMessages, setHasNewerMessages] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); + + const abortControllerRef = useRef(null); + const fetchingConversationIdRef = useRef(null); + const messagesRef = useRef([]); + const hasOlderMessagesRef = useRef(false); + const hasNewerMessagesRef = useRef(false); + const prevConversationIdRef = useRef(null); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + hasOlderMessagesRef.current = hasOlderMessages; + }, [hasOlderMessages]); + + useEffect(() => { + hasNewerMessagesRef.current = hasNewerMessages; + }, [hasNewerMessages]); + + const syncSeenContent = useCallback( + (nextMessages: Message[]) => { + seenMessageContentRef.current.clear(); + for (const msg of nextMessages) { + seenMessageContentRef.current.add(getMessageContentKey(msg)); + } + }, + [getMessageContentKey, seenMessageContentRef] + ); + + const fetchLatestMessages = useCallback( + async (showLoading = false, signal?: AbortSignal) => { + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const conversationId = activeConversation.id; + + if (showLoading) { + setMessagesLoading(true); + setMessages([]); + } + + try { + const data = await api.getMessages( + { + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: activeConversation.id, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ); + + if (fetchingConversationIdRef.current !== conversationId) { + return; + } + + const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); + setMessages(messagesWithPendingAck); + syncSeenContent(messagesWithPendingAck); + setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + if (isAbortError(err)) { + return; + } + console.error('Failed to fetch messages:', err); + toast.error('Failed to load messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + if (showLoading) { + setMessagesLoading(false); + } + } + }, + [activeConversation, applyPendingAck, syncSeenContent] + ); + + const reconcileFromBackend = useCallback( + (conversation: Conversation, signal: AbortSignal) => { + const conversationId = conversation.id; + api + .getMessages( + { + type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ) + .then((data) => { + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); + if (!merged) return; + + setMessages(merged); + syncSeenContent(merged); + if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { + setHasOlderMessages(true); + } + }) + .catch((err) => { + if (isAbortError(err)) return; + console.debug('Background reconciliation failed:', err); + }); + }, + [applyPendingAck, syncSeenContent] + ); + + const fetchOlderMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return; + + const conversationId = activeConversation.id; + const oldestMessage = messages.reduce( + (oldest, msg) => { + if (!oldest) return msg; + if (msg.received_at < oldest.received_at) return msg; + if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; + return oldest; + }, + null as Message | null + ); + if (!oldestMessage) return; + + setLoadingOlder(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + before: oldestMessage.received_at, + before_id: oldestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + + if (dataWithPendingAck.length > 0) { + setMessages((prev) => [...prev, ...dataWithPendingAck]); + for (const msg of dataWithPendingAck) { + seenMessageContentRef.current.add(getMessageContentKey(msg)); + } + } + setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch older messages:', err); + toast.error('Failed to load older messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingOlder(false); + } + }, [ + activeConversation, + applyPendingAck, + getMessageContentKey, + hasOlderMessages, + loadingOlder, + messages, + seenMessageContentRef, + ]); + + const fetchNewerMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return; + + const conversationId = activeConversation.id; + const newestMessage = messages.reduce( + (newest, msg) => { + if (!newest) return msg; + if (msg.received_at > newest.received_at) return msg; + if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; + return newest; + }, + null as Message | null + ); + if (!newestMessage) return; + + setLoadingNewer(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + after: newestMessage.received_at, + after_id: newestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const newMessages = dataWithPendingAck.filter( + (msg) => !seenMessageContentRef.current.has(getMessageContentKey(msg)) + ); + + if (newMessages.length > 0) { + setMessages((prev) => [...prev, ...newMessages]); + for (const msg of newMessages) { + seenMessageContentRef.current.add(getMessageContentKey(msg)); + } + } + setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch newer messages:', err); + toast.error('Failed to load newer messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingNewer(false); + } + }, [ + activeConversation, + applyPendingAck, + getMessageContentKey, + hasNewerMessages, + loadingNewer, + messages, + seenMessageContentRef, + ]); + + const jumpToBottom = useCallback(() => { + if (!activeConversation) return; + setHasNewerMessages(false); + messageCache.remove(activeConversation.id); + fetchLatestMessages(true); + }, [activeConversation, fetchLatestMessages]); + + const triggerReconcile = useCallback(() => { + if (!isMessageConversation(activeConversation)) return; + const controller = new AbortController(); + reconcileFromBackend(activeConversation, controller.signal); + }, [activeConversation, reconcileFromBackend]); + + useEffect(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const prevId = prevConversationIdRef.current; + const newId = activeConversation?.id ?? null; + const conversationChanged = prevId !== newId; + fetchingConversationIdRef.current = newId; + prevConversationIdRef.current = newId; + + if (!conversationChanged && !targetMessageId) { + return; + } + + setLoadingOlder(false); + setLoadingNewer(false); + if (conversationChanged) { + setHasNewerMessages(false); + } + + if ( + conversationChanged && + prevId && + messagesRef.current.length > 0 && + !hasNewerMessagesRef.current + ) { + messageCache.set(prevId, { + messages: messagesRef.current, + seenContent: new Set(seenMessageContentRef.current), + hasOlderMessages: hasOlderMessagesRef.current, + }); + } + + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + if (targetMessageId) { + setMessagesLoading(true); + setMessages([]); + const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; + api + .getMessagesAround( + targetMessageId, + msgType as 'PRIV' | 'CHAN', + activeConversation.id, + controller.signal + ) + .then((response) => { + if (fetchingConversationIdRef.current !== activeConversation.id) return; + const withAcks = response.messages.map((msg) => applyPendingAck(msg)); + setMessages(withAcks); + syncSeenContent(withAcks); + setHasOlderMessages(response.has_older); + setHasNewerMessages(response.has_newer); + }) + .catch((err) => { + if (isAbortError(err)) return; + console.error('Failed to fetch messages around target:', err); + toast.error('Failed to jump to message'); + }) + .finally(() => { + setMessagesLoading(false); + }); + } else { + const cached = messageCache.get(activeConversation.id); + if (cached) { + setMessages(cached.messages); + seenMessageContentRef.current = new Set(cached.seenContent); + setHasOlderMessages(cached.hasOlderMessages); + setMessagesLoading(false); + reconcileFromBackend(activeConversation, controller.signal); + } else { + fetchLatestMessages(true, controller.signal); + } + } + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeConversation?.id, activeConversation?.type, targetMessageId]); + + return { + messages, + messagesRef, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + hasNewerMessagesRef, + setMessages, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + triggerReconcile, + }; +} diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index 5afa4a0..e56399a 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -392,4 +392,65 @@ describe('useConversationMessages forward pagination', () => { expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].text).toBe('latest-msg'); }); + + it('preserves around-loaded messages when the jump target is cleared in the same conversation', async () => { + const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' }; + + const aroundMessages = [ + createMessage({ + id: 4, + conversation_key: 'ch1', + text: 'older-context', + sender_timestamp: 1700000004, + received_at: 1700000004, + }), + createMessage({ + id: 5, + conversation_key: 'ch1', + text: 'target-message', + sender_timestamp: 1700000005, + received_at: 1700000005, + }), + createMessage({ + id: 6, + conversation_key: 'ch1', + text: 'newer-context', + sender_timestamp: 1700000006, + received_at: 1700000006, + }), + ]; + + mockGetMessagesAround.mockResolvedValueOnce({ + messages: aroundMessages, + has_older: true, + has_newer: true, + }); + + const { result, rerender } = renderHook< + ReturnType, + { conv: Conversation; target: number | null } + >(({ conv, target }) => useConversationMessages(conv, target), { + initialProps: { conv, target: 5 }, + }); + + await waitFor(() => expect(result.current.messagesLoading).toBe(false)); + expect(result.current.messages.map((message) => message.text)).toEqual([ + 'older-context', + 'target-message', + 'newer-context', + ]); + expect(mockGetMessages).not.toHaveBeenCalled(); + + rerender({ conv, target: null }); + + await waitFor(() => + expect(result.current.messages.map((message) => message.text)).toEqual([ + 'older-context', + 'target-message', + 'newer-context', + ]) + ); + expect(mockGetMessages).not.toHaveBeenCalled(); + expect(result.current.hasNewerMessages).toBe(true); + }); }); From 19d7c3c98c49280d967053196866ee7192c0383c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 19:41:03 -0700 Subject: [PATCH 11/27] extract conversation pane component --- frontend/AGENTS.md | 11 + frontend/src/App.tsx | 182 +++------------ frontend/src/components/ConversationPane.tsx | 219 +++++++++++++++++++ frontend/src/test/conversationPane.test.tsx | 195 +++++++++++++++++ 4 files changed, 459 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/ConversationPane.tsx create mode 100644 frontend/src/test/conversationPane.test.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6531178..81ab0eb 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -45,6 +45,9 @@ frontend/src/ │ ├── useAppSettings.ts # Settings, favorites, preferences migration │ ├── useConversationRouter.ts # URL hash → active conversation routing │ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion +├── components/ +│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty) +│ └── ... ├── utils/ │ ├── urlHash.ts # Hash parsing and encoding │ ├── conversationState.ts # State keys, in-memory + localStorage helpers @@ -166,6 +169,14 @@ frontend/src/ - `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination - `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions) +`ConversationPane.tsx` owns the main active-conversation surface branching: +- empty state +- map view +- visualizer +- raw packet feed +- repeater dashboard +- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`) + ### Initial load + realtime - Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51664c9..23a321b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,4 @@ -import { - useState, - useEffect, - useCallback, - useMemo, - useRef, - startTransition, - lazy, - Suspense, -} from 'react'; +import { useState, useEffect, useCallback, useRef, startTransition, lazy, Suspense } from 'react'; import { api } from './api'; import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; @@ -23,28 +14,16 @@ import { } from './hooks'; import { StatusBar } from './components/StatusBar'; import { Sidebar } from './components/Sidebar'; -import { ChatHeader } from './components/ChatHeader'; -import { MessageList } from './components/MessageList'; -import { MessageInput, type MessageInputHandle } from './components/MessageInput'; +import { ConversationPane } from './components/ConversationPane'; +import type { MessageInputHandle } from './components/MessageInput'; import { NewMessageModal } from './components/NewMessageModal'; import { SETTINGS_SECTION_LABELS, SETTINGS_SECTION_ORDER, type SettingsSection, } from './components/settings/settingsConstants'; -import { RawPacketList } from './components/RawPacketList'; import { ContactInfoPane } from './components/ContactInfoPane'; import { ChannelInfoPane } from './components/ChannelInfoPane'; -import { CONTACT_TYPE_REPEATER } from './types'; - -// Lazy-load heavy components to reduce initial bundle -const RepeaterDashboard = lazy(() => - import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard })) -); -const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView }))); -const VisualizerView = lazy(() => - import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView })) -); const SettingsModal = lazy(() => import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal })) ); @@ -209,13 +188,6 @@ export function App() { refreshUnreads, } = useUnreadCounts(channels, contacts, activeConversation); - // Determine if active contact is a repeater (used for routing to dashboard) - const activeContactIsRepeater = useMemo(() => { - if (!activeConversation || activeConversation.type !== 'contact') return false; - const contact = contacts.find((c) => c.public_key === activeConversation.id); - return contact?.type === CONTACT_TYPE_REPEATER; - }, [activeConversation, contacts]); - const wsHandlers = useRealtimeAppState({ prevHealthRef, setHealth, @@ -431,123 +403,37 @@ export function App() { (showSettings || activeConversation?.type === 'search') && 'hidden' )} > - {activeConversation ? ( - activeConversation.type === 'map' ? ( - <> -

- Node Map -

-
- - Loading map... -
- } - > - - - - - ) : activeConversation.type === 'visualizer' ? ( - - Loading visualizer... - - } - > - - - ) : activeConversation.type === 'raw' ? ( - <> -

- Raw Packet Feed -

-
- -
- - ) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? ( - - Loading dashboard... - - } - > - - - ) : ( - <> - - setTargetMessageId(null)} - hasNewerMessages={hasNewerMessages} - loadingNewer={loadingNewer} - onLoadNewer={fetchNewerMessages} - onJumpToBottom={jumpToBottom} - /> - - - ) - ) : ( -
- Select a conversation or start a new one -
- )} + setTargetMessageId(null)} + onLoadNewer={fetchNewerMessages} + onJumpToBottom={jumpToBottom} + onSendMessage={handleSendMessage} + /> {searchMounted.current && ( diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx new file mode 100644 index 0000000..5def504 --- /dev/null +++ b/frontend/src/components/ConversationPane.tsx @@ -0,0 +1,219 @@ +import { lazy, Suspense, useMemo, type Ref } from 'react'; + +import { ChatHeader } from './ChatHeader'; +import { MessageInput, type MessageInputHandle } from './MessageInput'; +import { MessageList } from './MessageList'; +import { RawPacketList } from './RawPacketList'; +import type { + Channel, + Contact, + Conversation, + Favorite, + HealthStatus, + Message, + RawPacket, + RadioConfig, +} from '../types'; +import { CONTACT_TYPE_REPEATER } from '../types'; + +const RepeaterDashboard = lazy(() => + import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard })) +); +const MapView = lazy(() => import('./MapView').then((m) => ({ default: m.MapView }))); +const VisualizerView = lazy(() => + import('./VisualizerView').then((m) => ({ default: m.VisualizerView })) +); + +interface ConversationPaneProps { + activeConversation: Conversation | null; + contacts: Contact[]; + channels: Channel[]; + rawPackets: RawPacket[]; + config: RadioConfig | null; + health: HealthStatus | null; + favorites: Favorite[]; + messages: Message[]; + messagesLoading: boolean; + loadingOlder: boolean; + hasOlderMessages: boolean; + targetMessageId: number | null; + hasNewerMessages: boolean; + loadingNewer: boolean; + messageInputRef: Ref; + onTrace: () => Promise; + onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; + onDeleteContact: (publicKey: string) => Promise; + onDeleteChannel: (key: string) => Promise; + onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise; + onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; + onOpenChannelInfo: (channelKey: string) => void; + onSenderClick: (sender: string) => void; + onLoadOlder: () => Promise; + onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; + onTargetReached: () => void; + onLoadNewer: () => Promise; + onJumpToBottom: () => void; + onSendMessage: (text: string) => Promise; +} + +function LoadingPane({ label }: { label: string }) { + return ( +
{label}
+ ); +} + +export function ConversationPane({ + activeConversation, + contacts, + channels, + rawPackets, + config, + health, + favorites, + messages, + messagesLoading, + loadingOlder, + hasOlderMessages, + targetMessageId, + hasNewerMessages, + loadingNewer, + messageInputRef, + onTrace, + onToggleFavorite, + onDeleteContact, + onDeleteChannel, + onSetChannelFloodScopeOverride, + onOpenContactInfo, + onOpenChannelInfo, + onSenderClick, + onLoadOlder, + onResendChannelMessage, + onTargetReached, + onLoadNewer, + onJumpToBottom, + onSendMessage, +}: ConversationPaneProps) { + const activeContactIsRepeater = useMemo(() => { + if (!activeConversation || activeConversation.type !== 'contact') return false; + const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id); + return contact?.type === CONTACT_TYPE_REPEATER; + }, [activeConversation, contacts]); + + if (!activeConversation) { + return ( +
+ Select a conversation or start a new one +
+ ); + } + + if (activeConversation.type === 'map') { + return ( + <> +

+ Node Map +

+
+ }> + + +
+ + ); + } + + if (activeConversation.type === 'visualizer') { + return ( + }> + + + ); + } + + if (activeConversation.type === 'raw') { + return ( + <> +

+ Raw Packet Feed +

+
+ +
+ + ); + } + + if (activeConversation.type === 'search') { + return null; + } + + if (activeContactIsRepeater) { + return ( + }> + + + ); + } + + return ( + <> + + + + + ); +} diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx new file mode 100644 index 0000000..9f35335 --- /dev/null +++ b/frontend/src/test/conversationPane.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ConversationPane } from '../components/ConversationPane'; +import type { + Channel, + Contact, + Conversation, + Favorite, + HealthStatus, + Message, + RadioConfig, +} from '../types'; + +vi.mock('../components/ChatHeader', () => ({ + ChatHeader: () =>
, +})); + +vi.mock('../components/MessageList', () => ({ + MessageList: () =>
, +})); + +vi.mock('../components/MessageInput', () => ({ + MessageInput: React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ appendText: vi.fn() })); + return
; + }), +})); + +vi.mock('../components/RawPacketList', () => ({ + RawPacketList: () =>
, +})); + +vi.mock('../components/RepeaterDashboard', () => ({ + RepeaterDashboard: () =>
, +})); + +vi.mock('../components/MapView', () => ({ + MapView: () =>
, +})); + +vi.mock('../components/VisualizerView', () => ({ + VisualizerView: () =>
, +})); + +const config: RadioConfig = { + public_key: 'aa'.repeat(32), + name: 'Radio', + lat: 1, + lon: 2, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: true, +}; + +const health: HealthStatus = { + status: 'ok', + radio_connected: true, + radio_initializing: false, + connection_info: 'serial', + database_size_mb: 1, + oldest_undecrypted_timestamp: null, + fanout_statuses: {}, + bots_disabled: false, +}; + +const channel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +const message: Message = { + id: 1, + type: 'CHAN', + conversation_key: channel.key, + text: 'hello', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: null, + outgoing: false, + acked: 0, + sender_name: null, +}; + +function createProps(overrides: Partial> = {}) { + return { + activeConversation: null as Conversation | null, + contacts: [] as Contact[], + channels: [channel], + rawPackets: [], + config, + health, + favorites: [] as Favorite[], + messages: [message], + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + targetMessageId: null, + hasNewerMessages: false, + loadingNewer: false, + messageInputRef: { current: null }, + onTrace: vi.fn(async () => {}), + onToggleFavorite: vi.fn(async () => {}), + onDeleteContact: vi.fn(async () => {}), + onDeleteChannel: vi.fn(async () => {}), + onSetChannelFloodScopeOverride: vi.fn(async () => {}), + onOpenContactInfo: vi.fn(), + onOpenChannelInfo: vi.fn(), + onSenderClick: vi.fn(), + onLoadOlder: vi.fn(async () => {}), + onResendChannelMessage: vi.fn(async () => {}), + onTargetReached: vi.fn(), + onLoadNewer: vi.fn(async () => {}), + onJumpToBottom: vi.fn(), + onSendMessage: vi.fn(async () => {}), + ...overrides, + }; +} + +describe('ConversationPane', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the empty state when no conversation is active', () => { + render(); + + expect(screen.getByText('Select a conversation or start a new one')).toBeInTheDocument(); + }); + + it('renders repeater dashboard instead of chat chrome for repeater contacts', async () => { + render( + + ); + + expect(await screen.findByTestId('repeater-dashboard')).toBeInTheDocument(); + expect(screen.queryByTestId('message-list')).not.toBeInTheDocument(); + }); + + it('renders chat chrome for normal channel conversations', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('chat-header')).toBeInTheDocument(); + expect(screen.getByTestId('message-list')).toBeInTheDocument(); + expect(screen.getByTestId('message-input')).toBeInTheDocument(); + }); + }); +}); From ec5b9663b2577138cfbcace02194a7e7bf7af186 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 20:11:13 -0700 Subject: [PATCH 12/27] Brief interlude -- fix corrupt packet message display --- frontend/src/components/ContactAvatar.tsx | 47 ++++++++- frontend/src/components/MessageList.tsx | 111 +++++++++++++++++++--- 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/ContactAvatar.tsx b/frontend/src/components/ContactAvatar.tsx index e5cff78..d41fbf6 100644 --- a/frontend/src/components/ContactAvatar.tsx +++ b/frontend/src/components/ContactAvatar.tsx @@ -6,6 +6,35 @@ interface ContactAvatarProps { size?: number; contactType?: number; clickable?: boolean; + variant?: 'default' | 'corrupt'; +} + +function CorruptAvatarGraphic({ size }: { size: number }) { + return ( + + ); } export function ContactAvatar({ @@ -14,14 +43,30 @@ export function ContactAvatar({ size = 28, contactType, clickable, + variant = 'default', }: ContactAvatarProps) { + if (variant === 'corrupt') { + return ( + + ); + } + const avatar = getContactAvatar(name, publicKey, contactType); return (
= 0 && code <= 8) || + code === 11 || + code === 12 || + (code >= 14 && code <= 31) || + code === 127 + ) { + return true; + } + } + return false; +} export function MessageList({ messages, @@ -400,6 +417,17 @@ export function MessageList({ return contacts.find((c) => c.name === name) || null; }; + const isCorruptUnnamedChannelMessage = (msg: Message, parsedSender: string | null): boolean => { + return ( + msg.type === 'CHAN' && + !msg.outgoing && + !msg.sender_name && + !msg.sender_key && + !parsedSender && + hasUnexpectedControlChars(msg.text) + ); + }; + // Build sender info for path modal const getSenderInfo = ( msg: Message, @@ -415,6 +443,32 @@ export function MessageList({ pathHashMode: contact.out_path_hash_mode, }; } + if (msg.type === 'CHAN') { + const senderName = msg.sender_name || parsedSender; + const senderContact = + (msg.sender_key + ? contacts.find((candidate) => candidate.public_key === msg.sender_key) + : null) || (senderName ? getContactByName(senderName) : null); + if (senderContact) { + return { + name: senderContact.name || senderName || senderContact.public_key.slice(0, 12), + publicKeyOrPrefix: senderContact.public_key, + lat: senderContact.lat, + lon: senderContact.lon, + pathHashMode: senderContact.out_path_hash_mode, + }; + } + if (senderName || msg.sender_key) { + return { + name: senderName || msg.sender_key || 'Unknown', + publicKeyOrPrefix: msg.sender_key || msg.conversation_key || '', + lat: null, + lon: null, + pathHashMode: null, + }; + } + } + // For channel messages, try to find contact by parsed sender name if (parsedSender) { const senderContact = getContactByName(parsedSender); @@ -455,10 +509,17 @@ export function MessageList({ } // Helper to get a unique sender key for grouping messages - const getSenderKey = (msg: Message, sender: string | null): string => { + const getSenderKey = ( + msg: Message, + senderName: string | null, + isCorruptChannelMessage: boolean + ): string => { if (msg.outgoing) return '__outgoing__'; if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key; - return sender || '__unknown__'; + if (msg.sender_key) return `key:${msg.sender_key}`; + if (senderName) return `name:${senderName}`; + if (isCorruptChannelMessage) return `corrupt:${msg.id}`; + return '__unknown__'; }; return ( @@ -487,17 +548,36 @@ export function MessageList({ const { sender, content } = isRepeater ? { sender: null, content: msg.text } : parseSenderFromText(msg.text); + const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null; + const channelSenderContact = + msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null; + const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender); const displaySender = msg.outgoing ? 'You' - : contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown'; + : contact?.name || + channelSenderName || + (isCorruptChannelMessage + ? CORRUPT_SENDER_LABEL + : msg.conversation_key?.slice(0, 8) || 'Unknown'); - const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown'; + const canClickSender = + !msg.outgoing && + onSenderClick && + displaySender !== 'Unknown' && + displaySender !== CORRUPT_SENDER_LABEL; // Determine if we should show avatar (first message in a chunk from same sender) - const currentSenderKey = getSenderKey(msg, sender); + const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage); const prevMsg = sortedMessages[index - 1]; + const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null; const prevSenderKey = prevMsg - ? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender) + ? getSenderKey( + prevMsg, + prevMsg.type === 'CHAN' + ? prevMsg.sender_name || prevParsedSender + : prevParsedSender, + isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender) + ) : null; const isFirstInGroup = currentSenderKey !== prevSenderKey; const showAvatar = !msg.outgoing && isFirstInGroup; @@ -506,16 +586,24 @@ export function MessageList({ // Get avatar info for incoming messages let avatarName: string | null = null; let avatarKey: string = ''; + let avatarVariant: 'default' | 'corrupt' = 'default'; if (!msg.outgoing) { if (msg.type === 'PRIV' && msg.conversation_key) { // DM: use conversation_key (sender's public key) avatarName = contact?.name || null; avatarKey = msg.conversation_key; - } else if (sender) { - // Channel message: try to find contact by name, or use sender name as pseudo-key - const senderContact = getContactByName(sender); - avatarName = sender; - avatarKey = senderContact?.public_key || `name:${sender}`; + } else if (isCorruptChannelMessage) { + avatarName = CORRUPT_SENDER_LABEL; + avatarKey = `corrupt:${msg.id}`; + avatarVariant = 'corrupt'; + } else { + // Channel message: use stored sender identity first, then parsed/fallback display name + avatarName = + channelSenderName || (displaySender !== 'Unknown' ? displaySender : null); + avatarKey = + msg.sender_key || + channelSenderContact?.public_key || + (avatarName ? `name:${avatarName}` : `message:${msg.id}`); } } @@ -547,6 +635,7 @@ export function MessageList({ publicKey={avatarKey} size={32} clickable={!!onOpenContactInfo} + variant={avatarVariant} /> )} From f107dce92095f7a812e0269b678e40a21f76ff8d Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 20:23:24 -0700 Subject: [PATCH 13/27] extract frontend app shell --- AGENTS.md | 2 +- frontend/AGENTS.md | 22 +- frontend/src/App.tsx | 509 +++++++--------------- frontend/src/components/AppShell.tsx | 292 +++++++++++++ frontend/src/components/CrackerPanel.tsx | 2 +- frontend/src/components/SearchView.tsx | 2 +- frontend/src/components/SettingsModal.tsx | 2 +- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useAppShell.ts | 82 ++++ frontend/src/test/messageList.test.tsx | 64 +++ frontend/src/test/useAppShell.test.ts | 47 ++ 11 files changed, 657 insertions(+), 368 deletions(-) create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/hooks/useAppShell.ts create mode 100644 frontend/src/test/messageList.test.tsx create mode 100644 frontend/src/test/useAppShell.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7e3e156..7247ef5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup ├── frontend/ # React frontend │ ├── AGENTS.md # Frontend documentation │ ├── src/ -│ │ ├── App.tsx # Main component +│ │ ├── App.tsx # Frontend composition entry (hooks → AppShell) │ │ ├── api.ts # REST client │ │ ├── useWebSocket.ts # WebSocket hook │ │ └── components/ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 81ab0eb..010d50d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -21,7 +21,7 @@ Keep it aligned with `frontend/src` source code. ```text frontend/src/ ├── main.tsx # React entry point (StrictMode, root render) -├── App.tsx # App shell and orchestration +├── App.tsx # Data/orchestration entry that wires hooks into AppShell ├── api.ts # Typed REST client ├── types.ts # Shared TS contracts ├── useWebSocket.ts # WS lifecycle + event dispatch @@ -40,12 +40,14 @@ frontend/src/ │ ├── useConversationTimeline.ts # Fetch, cache restore, jump-target loading, pagination, reconcile │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery +│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker) │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) │ ├── useRadioControl.ts # Radio health/config state, reconnection │ ├── useAppSettings.ts # Settings, favorites, preferences migration │ ├── useConversationRouter.ts # URL hash → active conversation routing │ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion ├── components/ +│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals │ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty) │ └── ... ├── utils/ @@ -143,6 +145,7 @@ frontend/src/ ├── searchView.test.tsx ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts + ├── useAppShell.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts ├── useRealtimeAppState.test.ts @@ -157,7 +160,16 @@ frontend/src/ ### State ownership -`App.tsx` orchestrates high-level state and delegates to hooks: +`App.tsx` is now a thin composition entrypoint over the hook layer. `AppShell.tsx` owns shell layout/composition: +- local label banner +- status bar +- desktop/mobile sidebar container +- search/settings surface switching +- global cracker mount/focus behavior +- new-message modal and info panes + +High-level state is delegated to hooks: +- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal, target message) - `useRadioControl`: radio health/config state, reconnect/reboot polling - `useAppSettings`: settings CRUD, favorites, preferences migration - `useContactsAndChannels`: contact/channel lists, creation, deletion @@ -181,7 +193,7 @@ frontend/src/ - Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads. - WebSocket: realtime deltas/events. -- On reconnect, `App.tsx` refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift. +- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift. - On WS connect, backend sends `health` only; contacts/channels still come from REST. ### New Message modal @@ -315,7 +327,7 @@ State: `useConversationActions` controls open/close via `infoPaneChannelKey`. Li ## Repeater Dashboard -For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput). +For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput). **Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. @@ -331,7 +343,7 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors: -- **State**: `targetMessageId` is shared between `App.tsx`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. +- **State**: `targetMessageId` is shared between `useAppShell`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. - **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page. - **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results. - **Jump-to-message**: `useConversationTimeline` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23a321b..de0e846 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useCallback, useRef, startTransition, lazy, Suspense } from 'react'; +import { useEffect, useCallback, useRef, useState } from 'react'; import { api } from './api'; import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { + useAppShell, useUnreadCounts, useConversationMessages, useRadioControl, @@ -12,55 +13,34 @@ import { useConversationActions, useRealtimeAppState, } from './hooks'; -import { StatusBar } from './components/StatusBar'; -import { Sidebar } from './components/Sidebar'; -import { ConversationPane } from './components/ConversationPane'; +import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; -import { NewMessageModal } from './components/NewMessageModal'; -import { - SETTINGS_SECTION_LABELS, - SETTINGS_SECTION_ORDER, - type SettingsSection, -} from './components/settings/settingsConstants'; -import { ContactInfoPane } from './components/ContactInfoPane'; -import { ChannelInfoPane } from './components/ChannelInfoPane'; -const SettingsModal = lazy(() => - import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal })) -); -const CrackerPanel = lazy(() => - import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel })) -); -const SearchView = lazy(() => - import('./components/SearchView').then((m) => ({ default: m.SearchView })) -); -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from './components/ui/sheet'; -import { Toaster } from './components/ui/sonner'; import { messageContainsMention } from './utils/messageParser'; -import { getLocalLabel, getContrastTextColor } from './utils/localLabel'; -import { cn } from '@/lib/utils'; import type { Conversation, RawPacket } from './types'; export function App() { const messageInputRef = useRef(null); const [rawPackets, setRawPackets] = useState([]); - const [showNewMessage, setShowNewMessage] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [settingsSection, setSettingsSection] = useState('radio'); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [showCracker, setShowCracker] = useState(false); - const [crackerRunning, setCrackerRunning] = useState(false); - const [localLabel, setLocalLabel] = useState(getLocalLabel); - const [targetMessageId, setTargetMessageId] = useState(null); - - // Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state) - const crackerMounted = useRef(false); - if (showCracker) crackerMounted.current = true; + const { + showNewMessage, + showSettings, + settingsSection, + sidebarOpen, + showCracker, + crackerRunning, + localLabel, + targetMessageId, + setSettingsSection, + setSidebarOpen, + setCrackerRunning, + setLocalLabel, + setTargetMessageId, + handleCloseSettingsView, + handleToggleSettingsView, + handleOpenNewMessage, + handleCloseNewMessage, + handleToggleCracker, + } = useAppShell(); // Shared refs between useConversationRouter and useContactsAndChannels const pendingDeleteFallbackRef = useRef(false); @@ -157,10 +137,6 @@ export function App() { // Wire up the ref bridge so useContactsAndChannels handlers reach the real setter setActiveConversationRef.current = setActiveConversation; - // Keep SearchView mounted after first open to preserve search state - const searchMounted = useRef(false); - if (activeConversation?.type === 'search') searchMounted.current = true; - // Custom hooks for conversation-specific functionality const { messages, @@ -271,319 +247,134 @@ export function App() { setContacts, setContactsLoaded, ]); - - const handleCloseSettingsView = useCallback(() => { - startTransition(() => setShowSettings(false)); - setSidebarOpen(false); - }, []); - - const handleToggleSettingsView = useCallback(() => { - startTransition(() => { - setShowSettings((prev) => !prev); - }); - setSidebarOpen(false); - }, []); - - const handleNewMessage = useCallback(() => { - setShowNewMessage(true); - setSidebarOpen(false); - }, []); - - const handleToggleCracker = useCallback(() => { - setShowCracker((prev) => !prev); - }, []); - - // Sidebar content (shared between desktop and mobile) - const sidebarContent = ( - setTargetMessageId(null), + onLoadNewer: fetchNewerMessages, + onJumpToBottom: jumpToBottom, + onSendMessage: handleSendMessage, + }} + searchProps={{ + contacts, + channels, + onNavigateToMessage: handleNavigateToMessage, + }} + settingsProps={{ + config, + health, + appSettings, + onSave: handleSaveConfig, + onSaveAppSettings: handleSaveAppSettings, + onSetPrivateKey: handleSetPrivateKey, + onReboot: handleReboot, + onAdvertise: handleAdvertise, + onHealthRefresh: handleHealthRefresh, + onRefreshAppSettings: fetchAppSettings, + blockedKeys: appSettings?.blocked_keys, + blockedNames: appSettings?.blocked_names, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + }} + crackerProps={{ + packets: rawPackets, + channels, + onChannelCreate: async (name, key) => { + const created = await api.createChannel(name, key); + const data = await api.getChannels(); + setChannels(data); + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + fetchUndecryptedCount(); + }, + }} + newMessageModalProps={{ + contacts, + undecryptedCount, + onSelectConversation: handleSelectConversationWithTargetReset, + onCreateContact: handleCreateContact, + onCreateChannel: handleCreateChannel, + onCreateHashtagChannel: handleCreateHashtagChannel, + }} + contactInfoPaneProps={{ + contactKey: infoPaneContactKey, + fromChannel: infoPaneFromChannel, + onClose: handleCloseContactInfo, + contacts, + config, + favorites, + onToggleFavorite: handleToggleFavorite, + onNavigateToChannel: handleNavigateToChannel, + blockedKeys: appSettings?.blocked_keys, + blockedNames: appSettings?.blocked_names, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + }} + channelInfoPaneProps={{ + channelKey: infoPaneChannelKey, + onClose: handleCloseChannelInfo, + channels, + favorites, + onToggleFavorite: handleToggleFavorite, + }} /> ); - - const settingsSidebarContent = ( - - ); - - const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent; - - return ( -
- - Skip to content - - {localLabel.text && ( -
- {localLabel.text} -
- )} - setSidebarOpen(true)} - /> - -
- {/* Desktop sidebar - hidden on mobile */} -
{activeSidebarContent}
- - {/* Mobile sidebar - Sheet that slides in */} - - - - Navigation - Sidebar navigation - -
{activeSidebarContent}
-
-
- -
-
- setTargetMessageId(null)} - onLoadNewer={fetchNewerMessages} - onJumpToBottom={jumpToBottom} - onSendMessage={handleSendMessage} - /> -
- - {searchMounted.current && ( -
- - Loading search... -
- } - > - - -
- )} - - {showSettings && ( -
-

- Radio & Settings - - {SETTINGS_SECTION_LABELS[settingsSection]} - -

-
- - Loading settings... -
- } - > - - -
-
- )} - -
- - {/* Global Cracker Panel - deferred until first opened, then kept mounted for state */} -
{ - // Focus the panel when it becomes visible - if (showCracker && el) { - const focusable = el.querySelector('input, button:not([disabled])'); - if (focusable) setTimeout(() => focusable.focus(), 210); - } - }} - className={cn( - 'border-t border-border bg-background transition-all duration-200 overflow-hidden', - showCracker ? 'h-[275px]' : 'h-0' - )} - > - {crackerMounted.current && ( - - Loading cracker... -
- } - > - { - const created = await api.createChannel(name, key); - const data = await api.getChannels(); - setChannels(data); - await api.decryptHistoricalPackets({ - key_type: 'channel', - channel_key: created.key, - }); - fetchUndecryptedCount(); - }} - onRunningChange={setCrackerRunning} - /> - - )} -
- - setShowNewMessage(false)} - onSelectConversation={(conv) => { - handleSelectConversationWithTargetReset(conv); - setShowNewMessage(false); - }} - onCreateContact={handleCreateContact} - onCreateChannel={handleCreateChannel} - onCreateHashtagChannel={handleCreateHashtagChannel} - /> - - - - - - -
- ); } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..558e3e1 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,292 @@ +import { lazy, Suspense, useRef, type ComponentProps } from 'react'; + +import { StatusBar } from './StatusBar'; +import { Sidebar } from './Sidebar'; +import { ConversationPane } from './ConversationPane'; +import { NewMessageModal } from './NewMessageModal'; +import { ContactInfoPane } from './ContactInfoPane'; +import { ChannelInfoPane } from './ChannelInfoPane'; +import { Toaster } from './ui/sonner'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; +import { + SETTINGS_SECTION_LABELS, + SETTINGS_SECTION_ORDER, + type SettingsSection, +} from './settings/settingsConstants'; +import { getContrastTextColor, type LocalLabel } from '../utils/localLabel'; +import type { CrackerPanelProps } from './CrackerPanel'; +import type { SearchViewProps } from './SearchView'; +import type { SettingsModalProps } from './SettingsModal'; +import { cn } from '@/lib/utils'; + +const SettingsModal = lazy(() => + import('./SettingsModal').then((m) => ({ default: m.SettingsModal })) +); +const CrackerPanel = lazy(() => + import('./CrackerPanel').then((m) => ({ default: m.CrackerPanel })) +); +const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.SearchView }))); + +type SidebarProps = ComponentProps; +type ConversationPaneProps = ComponentProps; +type NewMessageModalProps = Omit, 'open' | 'onClose'>; +type ContactInfoPaneProps = ComponentProps; +type ChannelInfoPaneProps = ComponentProps; + +interface AppShellProps { + localLabel: LocalLabel; + showNewMessage: boolean; + showSettings: boolean; + settingsSection: SettingsSection; + sidebarOpen: boolean; + showCracker: boolean; + onSettingsSectionChange: (section: SettingsSection) => void; + onSidebarOpenChange: (open: boolean) => void; + onCrackerRunningChange: (running: boolean) => void; + onToggleSettingsView: () => void; + onCloseSettingsView: () => void; + onCloseNewMessage: () => void; + onLocalLabelChange: (label: LocalLabel) => void; + statusProps: Pick, 'health' | 'config'>; + sidebarProps: SidebarProps; + conversationPaneProps: ConversationPaneProps; + searchProps: SearchViewProps; + settingsProps: Omit< + SettingsModalProps, + 'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange' + >; + crackerProps: Omit; + newMessageModalProps: NewMessageModalProps; + contactInfoPaneProps: ContactInfoPaneProps; + channelInfoPaneProps: ChannelInfoPaneProps; +} + +export function AppShell({ + localLabel, + showNewMessage, + showSettings, + settingsSection, + sidebarOpen, + showCracker, + onSettingsSectionChange, + onSidebarOpenChange, + onCrackerRunningChange, + onToggleSettingsView, + onCloseSettingsView, + onCloseNewMessage, + onLocalLabelChange, + statusProps, + sidebarProps, + conversationPaneProps, + searchProps, + settingsProps, + crackerProps, + newMessageModalProps, + contactInfoPaneProps, + channelInfoPaneProps, +}: AppShellProps) { + const searchMounted = useRef(false); + if (conversationPaneProps.activeConversation?.type === 'search') { + searchMounted.current = true; + } + + const crackerMounted = useRef(false); + if (showCracker) { + crackerMounted.current = true; + } + + const settingsSidebarContent = ( + + ); + + const activeSidebarContent = showSettings ? ( + settingsSidebarContent + ) : ( + + ); + + return ( +
+ + Skip to content + + {localLabel.text && ( +
+ {localLabel.text} +
+ )} + + onSidebarOpenChange(true)} + /> + +
+
{activeSidebarContent}
+ + + + + Navigation + Sidebar navigation + +
{activeSidebarContent}
+
+
+ +
+
+ +
+ + {searchMounted.current && ( +
+ + Loading search... +
+ } + > + + +
+ )} + + {showSettings && ( +
+

+ Radio & Settings + + {SETTINGS_SECTION_LABELS[settingsSection]} + +

+
+ + Loading settings... +
+ } + > + + +
+
+ )} + +
+ +
{ + if (showCracker && el) { + const focusable = el.querySelector('input, button:not([disabled])'); + if (focusable) { + setTimeout(() => focusable.focus(), 210); + } + } + }} + className={cn( + 'border-t border-border bg-background transition-all duration-200 overflow-hidden', + showCracker ? 'h-[275px]' : 'h-0' + )} + > + {crackerMounted.current && ( + + Loading cracker... +
+ } + > + + + )} +
+ + { + newMessageModalProps.onSelectConversation(conv); + onCloseNewMessage(); + }} + /> + + + + +
+ ); +} diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index 0e94a26..669df5a 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -22,7 +22,7 @@ interface QueueItem { status: 'pending' | 'cracking' | 'cracked' | 'failed'; } -interface CrackerPanelProps { +export interface CrackerPanelProps { packets: RawPacket[]; channels: Channel[]; onChannelCreate: (name: string, key: string) => Promise; diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx index bc07f96..f28f46a 100644 --- a/frontend/src/components/SearchView.tsx +++ b/frontend/src/components/SearchView.tsx @@ -26,7 +26,7 @@ export interface SearchNavigateTarget { conversation_name: string; } -interface SearchViewProps { +export interface SearchViewProps { contacts: Contact[]; channels: Channel[]; onNavigateToMessage: (target: SearchNavigateTarget) => void; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 99feb44..46c62be 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -37,7 +37,7 @@ interface SettingsModalBaseProps { onToggleBlockedName?: (name: string) => void; } -type SettingsModalProps = SettingsModalBaseProps & +export type SettingsModalProps = SettingsModalBaseProps & ( | { externalSidebarNav: true; desktopSection: SettingsSection } | { externalSidebarNav?: false; desktopSection?: never } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index a563ebc..f2bc570 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -2,6 +2,7 @@ export { useUnreadCounts } from './useUnreadCounts'; export { useConversationMessages, getMessageContentKey } from './useConversationMessages'; export { useRadioControl } from './useRadioControl'; export { useRepeaterDashboard } from './useRepeaterDashboard'; +export { useAppShell } from './useAppShell'; export { useAppSettings } from './useAppSettings'; export { useConversationRouter } from './useConversationRouter'; export { useContactsAndChannels } from './useContactsAndChannels'; diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts new file mode 100644 index 0000000..030b3cd --- /dev/null +++ b/frontend/src/hooks/useAppShell.ts @@ -0,0 +1,82 @@ +import { startTransition, useCallback, useState, type Dispatch, type SetStateAction } from 'react'; + +import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; +import type { SettingsSection } from '../components/settings/settingsConstants'; + +interface UseAppShellResult { + showNewMessage: boolean; + showSettings: boolean; + settingsSection: SettingsSection; + sidebarOpen: boolean; + showCracker: boolean; + crackerRunning: boolean; + localLabel: LocalLabel; + targetMessageId: number | null; + setSettingsSection: (section: SettingsSection) => void; + setSidebarOpen: (open: boolean) => void; + setCrackerRunning: (running: boolean) => void; + setLocalLabel: (label: LocalLabel) => void; + setTargetMessageId: Dispatch>; + handleCloseSettingsView: () => void; + handleToggleSettingsView: () => void; + handleOpenNewMessage: () => void; + handleCloseNewMessage: () => void; + handleToggleCracker: () => void; +} + +export function useAppShell(): UseAppShellResult { + const [showNewMessage, setShowNewMessage] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [settingsSection, setSettingsSection] = useState('radio'); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [showCracker, setShowCracker] = useState(false); + const [crackerRunning, setCrackerRunning] = useState(false); + const [localLabel, setLocalLabel] = useState(getLocalLabel); + const [targetMessageId, setTargetMessageId] = useState(null); + + const handleCloseSettingsView = useCallback(() => { + startTransition(() => setShowSettings(false)); + setSidebarOpen(false); + }, []); + + const handleToggleSettingsView = useCallback(() => { + startTransition(() => { + setShowSettings((prev) => !prev); + }); + setSidebarOpen(false); + }, []); + + const handleOpenNewMessage = useCallback(() => { + setShowNewMessage(true); + setSidebarOpen(false); + }, []); + + const handleCloseNewMessage = useCallback(() => { + setShowNewMessage(false); + }, []); + + const handleToggleCracker = useCallback(() => { + setShowCracker((prev) => !prev); + }, []); + + return { + showNewMessage, + showSettings, + settingsSection, + sidebarOpen, + showCracker, + crackerRunning, + localLabel, + targetMessageId, + setSettingsSection, + setSidebarOpen, + setCrackerRunning, + setLocalLabel, + setTargetMessageId, + handleCloseSettingsView, + handleToggleSettingsView, + handleOpenNewMessage, + handleCloseNewMessage, + handleToggleCracker, + }; +} diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx new file mode 100644 index 0000000..e418d6f --- /dev/null +++ b/frontend/src/test/messageList.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { MessageList } from '../components/MessageList'; +import type { Message } from '../types'; + +function createMessage(overrides: Partial = {}): Message { + return { + id: 1, + type: 'CHAN', + conversation_key: 'C3B889530D4F02DB5662EA13C417F530', + text: 'Alice: hello world', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: null, + outgoing: false, + acked: 0, + sender_name: null, + ...overrides, + }; +} + +describe('MessageList channel sender rendering', () => { + it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => { + render( + + ); + + expect(screen.getByText('')).toBeInTheDocument(); + expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument(); + }); + + it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => { + render( + + ); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/useAppShell.test.ts b/frontend/src/test/useAppShell.test.ts new file mode 100644 index 0000000..2cc1dca --- /dev/null +++ b/frontend/src/test/useAppShell.test.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useAppShell } from '../hooks/useAppShell'; + +describe('useAppShell', () => { + it('opens new-message modal and closes the sidebar', () => { + const { result } = renderHook(() => useAppShell()); + + act(() => { + result.current.setSidebarOpen(true); + result.current.handleOpenNewMessage(); + }); + + expect(result.current.showNewMessage).toBe(true); + expect(result.current.sidebarOpen).toBe(false); + }); + + it('toggles settings mode and closes the sidebar', () => { + const { result } = renderHook(() => useAppShell()); + + act(() => { + result.current.setSidebarOpen(true); + result.current.handleToggleSettingsView(); + }); + + expect(result.current.showSettings).toBe(true); + expect(result.current.sidebarOpen).toBe(false); + + act(() => { + result.current.handleCloseSettingsView(); + }); + + expect(result.current.showSettings).toBe(false); + }); + + it('supports React-style target message updates', () => { + const { result } = renderHook(() => useAppShell()); + + act(() => { + result.current.setTargetMessageId(10); + result.current.setTargetMessageId((prev) => (prev ?? 0) + 5); + }); + + expect(result.current.targetMessageId).toBe(15); + }); +}); From 319b84455bd5e37e15e8ad1bc94f611ea779aac9 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 20:59:52 -0700 Subject: [PATCH 14/27] extract conversation navigation state --- frontend/AGENTS.md | 15 ++- frontend/src/App.tsx | 34 +++--- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useAppShell.ts | 7 +- frontend/src/hooks/useConversationActions.ts | 101 +--------------- .../src/hooks/useConversationNavigation.ts | 112 ++++++++++++++++++ frontend/src/test/useAppShell.test.ts | 9 +- .../src/test/useConversationActions.test.ts | 55 ++------- .../test/useConversationNavigation.test.ts | 82 +++++++++++++ 9 files changed, 241 insertions(+), 175 deletions(-) create mode 100644 frontend/src/hooks/useConversationNavigation.ts create mode 100644 frontend/src/test/useConversationNavigation.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 010d50d..3859e9c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -35,7 +35,8 @@ frontend/src/ │ └── utils.ts # cn() — clsx + tailwind-merge helper ├── hooks/ │ ├── index.ts # Central re-export of all hooks -│ ├── useConversationActions.ts # Send/navigation/info-pane conversation actions +│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions +│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state │ ├── useConversationMessages.ts # Dedup/update helpers over the conversation timeline │ ├── useConversationTimeline.ts # Fetch, cache restore, jump-target loading, pagination, reconcile │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps @@ -145,6 +146,7 @@ frontend/src/ ├── searchView.test.tsx ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts + ├── useConversationNavigation.test.ts ├── useAppShell.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts @@ -169,12 +171,13 @@ frontend/src/ - new-message modal and info panes High-level state is delegated to hooks: -- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal, target message) +- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal) - `useRadioControl`: radio health/config state, reconnect/reboot polling - `useAppSettings`: settings CRUD, favorites, preferences migration - `useContactsAndChannels`: contact/channel lists, creation, deletion - `useConversationRouter`: URL hash → active conversation routing -- `useConversationActions`: send/resend/trace/navigation handlers and info-pane state +- `useConversationNavigation`: search target, conversation selection reset, and info-pane state +- `useConversationActions`: send/resend/trace/block handlers and channel override updates - `useConversationMessages`: dedup/update helpers and pending ACK buffering - `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps @@ -311,7 +314,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf - Nearest repeaters (resolved from first-hop path prefixes) - Recent advert paths -State: `useConversationActions` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot. +State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot. ## Channel Info Pane @@ -323,7 +326,7 @@ Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right d - First message date - Top senders in last 24h (name + count) -State: `useConversationActions` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot. +State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot. ## Repeater Dashboard @@ -343,7 +346,7 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors: -- **State**: `targetMessageId` is shared between `useAppShell`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. +- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. - **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page. - **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results. - **Jump-to-message**: `useConversationTimeline` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de0e846..d0ef71a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { useConversationRouter, useContactsAndChannels, useConversationActions, + useConversationNavigation, useRealtimeAppState, } from './hooks'; import { AppShell } from './components/AppShell'; @@ -29,12 +30,10 @@ export function App() { showCracker, crackerRunning, localLabel, - targetMessageId, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, - setTargetMessageId, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, @@ -137,6 +136,24 @@ export function App() { // Wire up the ref bridge so useContactsAndChannels handlers reach the real setter setActiveConversationRef.current = setActiveConversation; + const { + targetMessageId, + setTargetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + } = useConversationNavigation({ + channels, + handleSelectConversation, + }); + // Custom hooks for conversation-specific functionality const { messages, @@ -187,9 +204,6 @@ export function App() { updateMessageAck, }); const { - infoPaneContactKey, - infoPaneFromChannel, - infoPaneChannelKey, handleSendMessage, handleResendChannelMessage, handleSetChannelFloodScopeOverride, @@ -197,24 +211,14 @@ export function App() { handleTrace, handleBlockKey, handleBlockName, - handleOpenContactInfo, - handleCloseContactInfo, - handleOpenChannelInfo, - handleCloseChannelInfo, - handleSelectConversationWithTargetReset, - handleNavigateToChannel, - handleNavigateToMessage, } = useConversationActions({ activeConversation, activeConversationRef, - setTargetMessageId, - channels, setChannels, addMessageIfNew, jumpToBottom, handleToggleBlockedKey, handleToggleBlockedName, - handleSelectConversation, messageInputRef, }); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index f2bc570..d6d1c94 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -8,3 +8,4 @@ export { useConversationRouter } from './useConversationRouter'; export { useContactsAndChannels } from './useContactsAndChannels'; export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; +export { useConversationNavigation } from './useConversationNavigation'; diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index 030b3cd..8ab11c1 100644 --- a/frontend/src/hooks/useAppShell.ts +++ b/frontend/src/hooks/useAppShell.ts @@ -1,4 +1,4 @@ -import { startTransition, useCallback, useState, type Dispatch, type SetStateAction } from 'react'; +import { startTransition, useCallback, useState } from 'react'; import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; import type { SettingsSection } from '../components/settings/settingsConstants'; @@ -11,12 +11,10 @@ interface UseAppShellResult { showCracker: boolean; crackerRunning: boolean; localLabel: LocalLabel; - targetMessageId: number | null; setSettingsSection: (section: SettingsSection) => void; setSidebarOpen: (open: boolean) => void; setCrackerRunning: (running: boolean) => void; setLocalLabel: (label: LocalLabel) => void; - setTargetMessageId: Dispatch>; handleCloseSettingsView: () => void; handleToggleSettingsView: () => void; handleOpenNewMessage: () => void; @@ -32,7 +30,6 @@ export function useAppShell(): UseAppShellResult { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); - const [targetMessageId, setTargetMessageId] = useState(null); const handleCloseSettingsView = useCallback(() => { startTransition(() => setShowSettings(false)); @@ -67,12 +64,10 @@ export function useAppShell(): UseAppShellResult { showCracker, crackerRunning, localLabel, - targetMessageId, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, - setTargetMessageId, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, diff --git a/frontend/src/hooks/useConversationActions.ts b/frontend/src/hooks/useConversationActions.ts index 68cf6a7..0749b5a 100644 --- a/frontend/src/hooks/useConversationActions.ts +++ b/frontend/src/hooks/useConversationActions.ts @@ -1,36 +1,22 @@ -import { - useCallback, - useState, - type Dispatch, - type MutableRefObject, - type RefObject, - type SetStateAction, -} from 'react'; +import { useCallback, type MutableRefObject, type RefObject } from 'react'; import { api } from '../api'; import * as messageCache from '../messageCache'; import { toast } from '../components/ui/sonner'; import type { MessageInputHandle } from '../components/MessageInput'; -import type { SearchNavigateTarget } from '../components/SearchView'; import type { Channel, Conversation, Message } from '../types'; interface UseConversationActionsArgs { activeConversation: Conversation | null; activeConversationRef: MutableRefObject; - setTargetMessageId: Dispatch>; - channels: Channel[]; setChannels: React.Dispatch>; addMessageIfNew: (msg: Message) => boolean; jumpToBottom: () => void; handleToggleBlockedKey: (key: string) => Promise; handleToggleBlockedName: (name: string) => Promise; - handleSelectConversation: (conv: Conversation) => void; messageInputRef: RefObject; } interface UseConversationActionsResult { - infoPaneContactKey: string | null; - infoPaneFromChannel: boolean; - infoPaneChannelKey: string | null; handleSendMessage: (text: string) => Promise; handleResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; handleSetChannelFloodScopeOverride: ( @@ -41,35 +27,18 @@ interface UseConversationActionsResult { handleTrace: () => Promise; handleBlockKey: (key: string) => Promise; handleBlockName: (name: string) => Promise; - handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; - handleCloseContactInfo: () => void; - handleOpenChannelInfo: (channelKey: string) => void; - handleCloseChannelInfo: () => void; - handleSelectConversationWithTargetReset: ( - conv: Conversation, - options?: { preserveTarget?: boolean } - ) => void; - handleNavigateToChannel: (channelKey: string) => void; - handleNavigateToMessage: (target: SearchNavigateTarget) => void; } export function useConversationActions({ activeConversation, activeConversationRef, - setTargetMessageId, - channels, setChannels, addMessageIfNew, jumpToBottom, handleToggleBlockedKey, handleToggleBlockedName, - handleSelectConversation, messageInputRef, }: UseConversationActionsArgs): UseConversationActionsResult { - const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); - const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); - const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); - const mergeChannelIntoList = useCallback( (updated: Channel) => { setChannels((prev) => { @@ -175,68 +144,7 @@ export function useConversationActions({ [handleToggleBlockedName, jumpToBottom] ); - const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { - setInfoPaneContactKey(publicKey); - setInfoPaneFromChannel(fromChannel ?? false); - }, []); - - const handleCloseContactInfo = useCallback(() => { - setInfoPaneContactKey(null); - }, []); - - const handleOpenChannelInfo = useCallback((channelKey: string) => { - setInfoPaneChannelKey(channelKey); - }, []); - - const handleCloseChannelInfo = useCallback(() => { - setInfoPaneChannelKey(null); - }, []); - - const handleSelectConversationWithTargetReset = useCallback( - (conv: Conversation, options?: { preserveTarget?: boolean }) => { - if (conv.type !== 'search' && !options?.preserveTarget) { - setTargetMessageId(null); - } - handleSelectConversation(conv); - }, - [handleSelectConversation, setTargetMessageId] - ); - - const handleNavigateToChannel = useCallback( - (channelKey: string) => { - const channel = channels.find((c) => c.key === channelKey); - if (channel) { - handleSelectConversationWithTargetReset({ - type: 'channel', - id: channel.key, - name: channel.name, - }); - setInfoPaneContactKey(null); - } - }, - [channels, handleSelectConversationWithTargetReset] - ); - - const handleNavigateToMessage = useCallback( - (target: SearchNavigateTarget) => { - const convType = target.type === 'CHAN' ? 'channel' : 'contact'; - setTargetMessageId(target.id); - handleSelectConversationWithTargetReset( - { - type: convType, - id: target.conversation_key, - name: target.conversation_name, - }, - { preserveTarget: true } - ); - }, - [handleSelectConversationWithTargetReset, setTargetMessageId] - ); - return { - infoPaneContactKey, - infoPaneFromChannel, - infoPaneChannelKey, handleSendMessage, handleResendChannelMessage, handleSetChannelFloodScopeOverride, @@ -244,12 +152,5 @@ export function useConversationActions({ handleTrace, handleBlockKey, handleBlockName, - handleOpenContactInfo, - handleCloseContactInfo, - handleOpenChannelInfo, - handleCloseChannelInfo, - handleSelectConversationWithTargetReset, - handleNavigateToChannel, - handleNavigateToMessage, }; } diff --git a/frontend/src/hooks/useConversationNavigation.ts b/frontend/src/hooks/useConversationNavigation.ts new file mode 100644 index 0000000..9a19aa6 --- /dev/null +++ b/frontend/src/hooks/useConversationNavigation.ts @@ -0,0 +1,112 @@ +import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; + +import type { SearchNavigateTarget } from '../components/SearchView'; +import type { Channel, Conversation } from '../types'; + +interface UseConversationNavigationArgs { + channels: Channel[]; + handleSelectConversation: (conv: Conversation) => void; +} + +interface UseConversationNavigationResult { + targetMessageId: number | null; + setTargetMessageId: Dispatch>; + infoPaneContactKey: string | null; + infoPaneFromChannel: boolean; + infoPaneChannelKey: string | null; + handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; + handleCloseContactInfo: () => void; + handleOpenChannelInfo: (channelKey: string) => void; + handleCloseChannelInfo: () => void; + handleSelectConversationWithTargetReset: ( + conv: Conversation, + options?: { preserveTarget?: boolean } + ) => void; + handleNavigateToChannel: (channelKey: string) => void; + handleNavigateToMessage: (target: SearchNavigateTarget) => void; +} + +export function useConversationNavigation({ + channels, + handleSelectConversation, +}: UseConversationNavigationArgs): UseConversationNavigationResult { + const [targetMessageId, setTargetMessageId] = useState(null); + const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); + const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); + const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); + + const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { + setInfoPaneContactKey(publicKey); + setInfoPaneFromChannel(fromChannel ?? false); + }, []); + + const handleCloseContactInfo = useCallback(() => { + setInfoPaneContactKey(null); + }, []); + + const handleOpenChannelInfo = useCallback((channelKey: string) => { + setInfoPaneChannelKey(channelKey); + }, []); + + const handleCloseChannelInfo = useCallback(() => { + setInfoPaneChannelKey(null); + }, []); + + const handleSelectConversationWithTargetReset = useCallback( + (conv: Conversation, options?: { preserveTarget?: boolean }) => { + if (conv.type !== 'search' && !options?.preserveTarget) { + setTargetMessageId(null); + } + handleSelectConversation(conv); + }, + [handleSelectConversation] + ); + + const handleNavigateToChannel = useCallback( + (channelKey: string) => { + const channel = channels.find((item) => item.key === channelKey); + if (!channel) { + return; + } + + handleSelectConversationWithTargetReset({ + type: 'channel', + id: channel.key, + name: channel.name, + }); + setInfoPaneContactKey(null); + }, + [channels, handleSelectConversationWithTargetReset] + ); + + const handleNavigateToMessage = useCallback( + (target: SearchNavigateTarget) => { + const convType = target.type === 'CHAN' ? 'channel' : 'contact'; + setTargetMessageId(target.id); + handleSelectConversationWithTargetReset( + { + type: convType, + id: target.conversation_key, + name: target.conversation_name, + }, + { preserveTarget: true } + ); + }, + [handleSelectConversationWithTargetReset] + ); + + return { + targetMessageId, + setTargetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + }; +} diff --git a/frontend/src/test/useAppShell.test.ts b/frontend/src/test/useAppShell.test.ts index 2cc1dca..2366e06 100644 --- a/frontend/src/test/useAppShell.test.ts +++ b/frontend/src/test/useAppShell.test.ts @@ -34,14 +34,15 @@ describe('useAppShell', () => { expect(result.current.showSettings).toBe(false); }); - it('supports React-style target message updates', () => { + it('toggles the cracker shell without affecting sidebar state', () => { const { result } = renderHook(() => useAppShell()); act(() => { - result.current.setTargetMessageId(10); - result.current.setTargetMessageId((prev) => (prev ?? 0) + 5); + result.current.setSidebarOpen(true); + result.current.handleToggleCracker(); }); - expect(result.current.targetMessageId).toBe(15); + expect(result.current.showCracker).toBe(true); + expect(result.current.sidebarOpen).toBe(true); }); }); diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts index 5778166..a9fb4e6 100644 --- a/frontend/src/test/useConversationActions.test.ts +++ b/frontend/src/test/useConversationActions.test.ts @@ -65,14 +65,11 @@ function createArgs(overrides: Partial return { activeConversation, activeConversationRef: { current: activeConversation }, - setTargetMessageId: vi.fn(), - channels: [publicChannel], setChannels: vi.fn(), addMessageIfNew: vi.fn(() => true), jumpToBottom: vi.fn(), handleToggleBlockedKey: vi.fn(async () => {}), handleToggleBlockedName: vi.fn(async () => {}), - handleSelectConversation: vi.fn(), messageInputRef: { current: { appendText: vi.fn() } }, ...overrides, }; @@ -123,47 +120,6 @@ describe('useConversationActions', () => { expect(args.addMessageIfNew).not.toHaveBeenCalled(); }); - it('resets the jump target when switching to a normal conversation', () => { - const args = createArgs(); - const { result } = renderHook(() => useConversationActions(args)); - - act(() => { - result.current.handleSelectConversationWithTargetReset({ - type: 'contact', - id: 'bb'.repeat(32), - name: 'Bob', - }); - }); - - expect(args.setTargetMessageId).toHaveBeenCalledWith(null); - expect(args.handleSelectConversation).toHaveBeenCalledWith({ - type: 'contact', - id: 'bb'.repeat(32), - name: 'Bob', - }); - }); - - it('navigates search results into the target conversation and preserves the jump target', () => { - const args = createArgs(); - const { result } = renderHook(() => useConversationActions(args)); - - act(() => { - result.current.handleNavigateToMessage({ - id: 321, - type: 'CHAN', - conversation_key: publicChannel.key, - conversation_name: publicChannel.name, - }); - }); - - expect(args.setTargetMessageId).toHaveBeenCalledWith(321); - expect(args.handleSelectConversation).toHaveBeenCalledWith({ - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }); - }); - it('clears cached messages and jumps to the latest page after blocking a key', async () => { const args = createArgs(); const { result } = renderHook(() => useConversationActions(args)); @@ -176,4 +132,15 @@ describe('useConversationActions', () => { expect(mocks.messageCache.clear).toHaveBeenCalledTimes(1); expect(args.jumpToBottom).toHaveBeenCalledTimes(1); }); + + it('appends sender mentions into the message input', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + act(() => { + result.current.handleSenderClick('Alice'); + }); + + expect(args.messageInputRef.current?.appendText).toHaveBeenCalledWith('@[Alice] '); + }); }); diff --git a/frontend/src/test/useConversationNavigation.test.ts b/frontend/src/test/useConversationNavigation.test.ts new file mode 100644 index 0000000..d2cb953 --- /dev/null +++ b/frontend/src/test/useConversationNavigation.test.ts @@ -0,0 +1,82 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useConversationNavigation } from '../hooks/useConversationNavigation'; +import type { Channel } from '../types'; + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +function createArgs(overrides: Partial[0]> = {}) { + return { + channels: [publicChannel], + handleSelectConversation: vi.fn(), + ...overrides, + }; +} + +describe('useConversationNavigation', () => { + it('resets the jump target when switching to a non-search conversation', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationNavigation(args)); + + act(() => { + result.current.setTargetMessageId(10); + result.current.handleSelectConversationWithTargetReset({ + type: 'contact', + id: 'aa'.repeat(32), + name: 'Alice', + }); + }); + + expect(result.current.targetMessageId).toBeNull(); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'contact', + id: 'aa'.repeat(32), + name: 'Alice', + }); + }); + + it('preserves the jump target when navigating from search results', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationNavigation(args)); + + act(() => { + result.current.handleNavigateToMessage({ + id: 321, + type: 'CHAN', + conversation_key: publicChannel.key, + conversation_name: publicChannel.name, + }); + }); + + expect(result.current.targetMessageId).toBe(321); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }); + + it('closes the contact info pane when navigating to a channel', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationNavigation(args)); + + act(() => { + result.current.handleOpenContactInfo('bb'.repeat(32), true); + result.current.handleNavigateToChannel(publicChannel.key); + }); + + expect(result.current.infoPaneContactKey).toBeNull(); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }); +}); From 3316f002716c6cddbe6ed4720a2dd45caab91b38 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 21:07:56 -0700 Subject: [PATCH 15/27] extract app shell prop assembly --- frontend/AGENTS.md | 3 + frontend/src/App.tsx | 198 ++++++------- frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useAppShellProps.ts | 306 +++++++++++++++++++++ frontend/src/test/useAppShellProps.test.ts | 189 +++++++++++++ 5 files changed, 584 insertions(+), 113 deletions(-) create mode 100644 frontend/src/hooks/useAppShellProps.ts create mode 100644 frontend/src/test/useAppShellProps.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3859e9c..d6cc4ad 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -42,6 +42,7 @@ frontend/src/ │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery │ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker) +│ ├── useAppShellProps.ts # AppShell child prop assembly + cracker create/decrypt flow │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) │ ├── useRadioControl.ts # Radio health/config state, reconnection │ ├── useAppSettings.ts # Settings, favorites, preferences migration @@ -147,6 +148,7 @@ frontend/src/ ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts ├── useConversationNavigation.test.ts + ├── useAppShellProps.test.ts ├── useAppShell.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts @@ -178,6 +180,7 @@ High-level state is delegated to hooks: - `useConversationRouter`: URL hash → active conversation routing - `useConversationNavigation`: search target, conversation selection reset, and info-pane state - `useConversationActions`: send/resend/trace/block handlers and channel override updates +- `useAppShellProps`: assembles the prop bundles passed into `AppShell` children, including the cracker-created-channel historical decrypt flow - `useConversationMessages`: dedup/update helpers and pending ACK buffering - `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0ef71a..961e313 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { useAppShell, + useAppShellProps, useUnreadCounts, useConversationMessages, useRadioControl, @@ -222,6 +223,81 @@ export function App() { messageInputRef, }); + const { + statusProps, + sidebarProps, + conversationPaneProps, + searchProps, + settingsProps, + crackerProps, + newMessageModalProps, + contactInfoPaneProps, + channelInfoPaneProps, + } = useAppShellProps({ + contacts, + channels, + rawPackets, + undecryptedCount, + activeConversation, + config, + health, + favorites, + appSettings, + unreadCounts, + mentions, + lastMessageTimes, + showCracker, + crackerRunning, + messageInputRef, + targetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + messages, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + handleOpenNewMessage, + handleToggleCracker, + markAllRead, + handleSortOrderChange, + handleSelectConversationWithTargetReset, + handleNavigateToMessage, + handleSaveConfig, + handleSaveAppSettings, + handleSetPrivateKey, + handleReboot, + handleAdvertise, + handleHealthRefresh, + fetchAppSettings, + setChannels, + fetchUndecryptedCount, + handleCreateContact, + handleCreateChannel, + handleCreateHashtagChannel, + handleDeleteContact, + handleDeleteChannel, + handleToggleFavorite, + handleSetChannelFloodScopeOverride, + handleOpenContactInfo, + handleOpenChannelInfo, + handleCloseContactInfo, + handleCloseChannelInfo, + handleSenderClick, + handleResendChannelMessage, + handleTrace, + handleSendMessage, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + setTargetMessageId, + handleNavigateToChannel, + handleBlockKey, + handleBlockName, + }); + // Connect to WebSocket useWebSocket(wsHandlers); @@ -266,119 +342,15 @@ export function App() { onCloseSettingsView={handleCloseSettingsView} onCloseNewMessage={handleCloseNewMessage} onLocalLabelChange={setLocalLabel} - statusProps={{ health, config }} - sidebarProps={{ - contacts, - channels, - activeConversation, - onSelectConversation: handleSelectConversationWithTargetReset, - onNewMessage: handleOpenNewMessage, - lastMessageTimes, - unreadCounts, - mentions, - showCracker, - crackerRunning, - onToggleCracker: handleToggleCracker, - onMarkAllRead: markAllRead, - favorites, - sortOrder: appSettings?.sidebar_sort_order ?? 'recent', - onSortOrderChange: handleSortOrderChange, - }} - conversationPaneProps={{ - activeConversation, - contacts, - channels, - rawPackets, - config, - health, - favorites, - messages, - messagesLoading, - loadingOlder, - hasOlderMessages, - targetMessageId, - hasNewerMessages, - loadingNewer, - messageInputRef, - onTrace: handleTrace, - onToggleFavorite: handleToggleFavorite, - onDeleteContact: handleDeleteContact, - onDeleteChannel: handleDeleteChannel, - onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, - onOpenContactInfo: handleOpenContactInfo, - onOpenChannelInfo: handleOpenChannelInfo, - onSenderClick: handleSenderClick, - onLoadOlder: fetchOlderMessages, - onResendChannelMessage: handleResendChannelMessage, - onTargetReached: () => setTargetMessageId(null), - onLoadNewer: fetchNewerMessages, - onJumpToBottom: jumpToBottom, - onSendMessage: handleSendMessage, - }} - searchProps={{ - contacts, - channels, - onNavigateToMessage: handleNavigateToMessage, - }} - settingsProps={{ - config, - health, - appSettings, - onSave: handleSaveConfig, - onSaveAppSettings: handleSaveAppSettings, - onSetPrivateKey: handleSetPrivateKey, - onReboot: handleReboot, - onAdvertise: handleAdvertise, - onHealthRefresh: handleHealthRefresh, - onRefreshAppSettings: fetchAppSettings, - blockedKeys: appSettings?.blocked_keys, - blockedNames: appSettings?.blocked_names, - onToggleBlockedKey: handleBlockKey, - onToggleBlockedName: handleBlockName, - }} - crackerProps={{ - packets: rawPackets, - channels, - onChannelCreate: async (name, key) => { - const created = await api.createChannel(name, key); - const data = await api.getChannels(); - setChannels(data); - await api.decryptHistoricalPackets({ - key_type: 'channel', - channel_key: created.key, - }); - fetchUndecryptedCount(); - }, - }} - newMessageModalProps={{ - contacts, - undecryptedCount, - onSelectConversation: handleSelectConversationWithTargetReset, - onCreateContact: handleCreateContact, - onCreateChannel: handleCreateChannel, - onCreateHashtagChannel: handleCreateHashtagChannel, - }} - contactInfoPaneProps={{ - contactKey: infoPaneContactKey, - fromChannel: infoPaneFromChannel, - onClose: handleCloseContactInfo, - contacts, - config, - favorites, - onToggleFavorite: handleToggleFavorite, - onNavigateToChannel: handleNavigateToChannel, - blockedKeys: appSettings?.blocked_keys, - blockedNames: appSettings?.blocked_names, - onToggleBlockedKey: handleBlockKey, - onToggleBlockedName: handleBlockName, - }} - channelInfoPaneProps={{ - channelKey: infoPaneChannelKey, - onClose: handleCloseChannelInfo, - channels, - favorites, - onToggleFavorite: handleToggleFavorite, - }} + statusProps={statusProps} + sidebarProps={sidebarProps} + conversationPaneProps={conversationPaneProps} + searchProps={searchProps} + settingsProps={settingsProps} + crackerProps={crackerProps} + newMessageModalProps={newMessageModalProps} + contactInfoPaneProps={contactInfoPaneProps} + channelInfoPaneProps={channelInfoPaneProps} /> ); } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d6d1c94..d4386ad 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels'; export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; +export { useAppShellProps } from './useAppShellProps'; diff --git a/frontend/src/hooks/useAppShellProps.ts b/frontend/src/hooks/useAppShellProps.ts new file mode 100644 index 0000000..dd3eba5 --- /dev/null +++ b/frontend/src/hooks/useAppShellProps.ts @@ -0,0 +1,306 @@ +import { useCallback, type ComponentProps, type Dispatch, type SetStateAction } from 'react'; + +import { api } from '../api'; +import { ChannelInfoPane } from '../components/ChannelInfoPane'; +import { ContactInfoPane } from '../components/ContactInfoPane'; +import { ConversationPane } from '../components/ConversationPane'; +import { NewMessageModal } from '../components/NewMessageModal'; +import { SearchView } from '../components/SearchView'; +import { SettingsModal } from '../components/SettingsModal'; +import { Sidebar } from '../components/Sidebar'; +import { StatusBar } from '../components/StatusBar'; +import { CrackerPanel } from '../components/CrackerPanel'; +import type { + AppSettings, + Channel, + Contact, + Conversation, + Favorite, + HealthStatus, + Message, + RadioConfig, + RawPacket, +} from '../types'; + +type StatusProps = Pick, 'health' | 'config'>; +type SidebarProps = ComponentProps; +type ConversationPaneProps = ComponentProps; +type SearchProps = ComponentProps; +type SettingsProps = Omit< + ComponentProps, + 'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange' +>; +type CrackerProps = Omit, 'visible' | 'onRunningChange'>; +type NewMessageModalProps = Omit, 'open' | 'onClose'>; +type ContactInfoPaneProps = ComponentProps; +type ChannelInfoPaneProps = ComponentProps; + +interface UseAppShellPropsArgs { + contacts: Contact[]; + channels: Channel[]; + rawPackets: RawPacket[]; + undecryptedCount: number; + activeConversation: Conversation | null; + config: RadioConfig | null; + health: HealthStatus | null; + favorites: Favorite[]; + appSettings: AppSettings | null; + unreadCounts: Record; + mentions: Record; + lastMessageTimes: Record; + showCracker: boolean; + crackerRunning: boolean; + messageInputRef: ConversationPaneProps['messageInputRef']; + targetMessageId: number | null; + infoPaneContactKey: string | null; + infoPaneFromChannel: boolean; + infoPaneChannelKey: string | null; + messages: Message[]; + messagesLoading: boolean; + loadingOlder: boolean; + hasOlderMessages: boolean; + hasNewerMessages: boolean; + loadingNewer: boolean; + handleOpenNewMessage: () => void; + handleToggleCracker: () => void; + markAllRead: () => void; + handleSortOrderChange: (sortOrder: 'recent' | 'alpha') => Promise; + handleSelectConversationWithTargetReset: ( + conv: Conversation, + options?: { preserveTarget?: boolean } + ) => void; + handleNavigateToMessage: SearchProps['onNavigateToMessage']; + handleSaveConfig: SettingsProps['onSave']; + handleSaveAppSettings: SettingsProps['onSaveAppSettings']; + handleSetPrivateKey: SettingsProps['onSetPrivateKey']; + handleReboot: SettingsProps['onReboot']; + handleAdvertise: SettingsProps['onAdvertise']; + handleHealthRefresh: SettingsProps['onHealthRefresh']; + fetchAppSettings: () => Promise; + setChannels: Dispatch>; + fetchUndecryptedCount: () => Promise; + handleCreateContact: NewMessageModalProps['onCreateContact']; + handleCreateChannel: NewMessageModalProps['onCreateChannel']; + handleCreateHashtagChannel: NewMessageModalProps['onCreateHashtagChannel']; + handleDeleteContact: ConversationPaneProps['onDeleteContact']; + handleDeleteChannel: ConversationPaneProps['onDeleteChannel']; + handleToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; + handleSetChannelFloodScopeOverride: ConversationPaneProps['onSetChannelFloodScopeOverride']; + handleOpenContactInfo: ConversationPaneProps['onOpenContactInfo']; + handleOpenChannelInfo: ConversationPaneProps['onOpenChannelInfo']; + handleCloseContactInfo: () => void; + handleCloseChannelInfo: () => void; + handleSenderClick: NonNullable; + handleResendChannelMessage: NonNullable; + handleTrace: ConversationPaneProps['onTrace']; + handleSendMessage: ConversationPaneProps['onSendMessage']; + fetchOlderMessages: ConversationPaneProps['onLoadOlder']; + fetchNewerMessages: ConversationPaneProps['onLoadNewer']; + jumpToBottom: ConversationPaneProps['onJumpToBottom']; + setTargetMessageId: Dispatch>; + handleNavigateToChannel: ContactInfoPaneProps['onNavigateToChannel']; + handleBlockKey: NonNullable; + handleBlockName: NonNullable; +} + +interface UseAppShellPropsResult { + statusProps: StatusProps; + sidebarProps: SidebarProps; + conversationPaneProps: ConversationPaneProps; + searchProps: SearchProps; + settingsProps: SettingsProps; + crackerProps: CrackerProps; + newMessageModalProps: NewMessageModalProps; + contactInfoPaneProps: ContactInfoPaneProps; + channelInfoPaneProps: ChannelInfoPaneProps; +} + +export function useAppShellProps({ + contacts, + channels, + rawPackets, + undecryptedCount, + activeConversation, + config, + health, + favorites, + appSettings, + unreadCounts, + mentions, + lastMessageTimes, + showCracker, + crackerRunning, + messageInputRef, + targetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + messages, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + handleOpenNewMessage, + handleToggleCracker, + markAllRead, + handleSortOrderChange, + handleSelectConversationWithTargetReset, + handleNavigateToMessage, + handleSaveConfig, + handleSaveAppSettings, + handleSetPrivateKey, + handleReboot, + handleAdvertise, + handleHealthRefresh, + fetchAppSettings, + setChannels, + fetchUndecryptedCount, + handleCreateContact, + handleCreateChannel, + handleCreateHashtagChannel, + handleDeleteContact, + handleDeleteChannel, + handleToggleFavorite, + handleSetChannelFloodScopeOverride, + handleOpenContactInfo, + handleOpenChannelInfo, + handleCloseContactInfo, + handleCloseChannelInfo, + handleSenderClick, + handleResendChannelMessage, + handleTrace, + handleSendMessage, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + setTargetMessageId, + handleNavigateToChannel, + handleBlockKey, + handleBlockName, +}: UseAppShellPropsArgs): UseAppShellPropsResult { + const handleCreateCrackedChannel = useCallback( + async (name, key) => { + const created = await api.createChannel(name, key); + const updatedChannels = await api.getChannels(); + setChannels(updatedChannels); + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + await fetchUndecryptedCount(); + }, + [fetchUndecryptedCount, setChannels] + ); + + return { + statusProps: { health, config }, + sidebarProps: { + contacts, + channels, + activeConversation, + onSelectConversation: handleSelectConversationWithTargetReset, + onNewMessage: handleOpenNewMessage, + lastMessageTimes, + unreadCounts, + mentions, + showCracker, + crackerRunning, + onToggleCracker: handleToggleCracker, + onMarkAllRead: () => { + void markAllRead(); + }, + favorites, + sortOrder: appSettings?.sidebar_sort_order ?? 'recent', + onSortOrderChange: (sortOrder) => { + void handleSortOrderChange(sortOrder); + }, + }, + conversationPaneProps: { + activeConversation, + contacts, + channels, + rawPackets, + config, + health, + favorites, + messages, + messagesLoading, + loadingOlder, + hasOlderMessages, + targetMessageId, + hasNewerMessages, + loadingNewer, + messageInputRef, + onTrace: handleTrace, + onToggleFavorite: handleToggleFavorite, + onDeleteContact: handleDeleteContact, + onDeleteChannel: handleDeleteChannel, + onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, + onOpenContactInfo: handleOpenContactInfo, + onOpenChannelInfo: handleOpenChannelInfo, + onSenderClick: handleSenderClick, + onLoadOlder: fetchOlderMessages, + onResendChannelMessage: handleResendChannelMessage, + onTargetReached: () => setTargetMessageId(null), + onLoadNewer: fetchNewerMessages, + onJumpToBottom: jumpToBottom, + onSendMessage: handleSendMessage, + }, + searchProps: { + contacts, + channels, + onNavigateToMessage: handleNavigateToMessage, + }, + settingsProps: { + config, + health, + appSettings, + onSave: handleSaveConfig, + onSaveAppSettings: handleSaveAppSettings, + onSetPrivateKey: handleSetPrivateKey, + onReboot: handleReboot, + onAdvertise: handleAdvertise, + onHealthRefresh: handleHealthRefresh, + onRefreshAppSettings: fetchAppSettings, + blockedKeys: appSettings?.blocked_keys, + blockedNames: appSettings?.blocked_names, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + }, + crackerProps: { + packets: rawPackets, + channels, + onChannelCreate: handleCreateCrackedChannel, + }, + newMessageModalProps: { + contacts, + undecryptedCount, + onSelectConversation: handleSelectConversationWithTargetReset, + onCreateContact: handleCreateContact, + onCreateChannel: handleCreateChannel, + onCreateHashtagChannel: handleCreateHashtagChannel, + }, + contactInfoPaneProps: { + contactKey: infoPaneContactKey, + fromChannel: infoPaneFromChannel, + onClose: handleCloseContactInfo, + contacts, + config, + favorites, + onToggleFavorite: handleToggleFavorite, + onNavigateToChannel: handleNavigateToChannel, + blockedKeys: appSettings?.blocked_keys, + blockedNames: appSettings?.blocked_names, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + }, + channelInfoPaneProps: { + channelKey: infoPaneChannelKey, + onClose: handleCloseChannelInfo, + channels, + favorites, + onToggleFavorite: handleToggleFavorite, + }, + }; +} diff --git a/frontend/src/test/useAppShellProps.test.ts b/frontend/src/test/useAppShellProps.test.ts new file mode 100644 index 0000000..692052e --- /dev/null +++ b/frontend/src/test/useAppShellProps.test.ts @@ -0,0 +1,189 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { useAppShellProps } from '../hooks/useAppShellProps'; +import type { + AppSettings, + Channel, + Contact, + Conversation, + Favorite, + HealthStatus, + Message, + RadioConfig, + RawPacket, +} from '../types'; + +const mocks = vi.hoisted(() => ({ + api: { + createChannel: vi.fn(), + getChannels: vi.fn(), + decryptHistoricalPackets: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: mocks.api, +})); + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +const config: RadioConfig = { + public_key: 'aa'.repeat(32), + name: 'TestNode', + lat: 0, + lon: 0, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: false, +}; + +const health: HealthStatus = { + status: 'connected', + radio_connected: true, + radio_initializing: false, + connection_info: null, + database_size_mb: 1, + oldest_undecrypted_timestamp: null, + fanout_statuses: {}, + bots_disabled: false, +}; + +const appSettings: AppSettings = { + max_radio_contacts: 200, + favorites: [], + auto_decrypt_dm_on_advert: false, + sidebar_sort_order: 'recent', + last_message_times: {}, + preferences_migrated: true, + advert_interval: 0, + last_advert_time: 0, + flood_scope: '', + blocked_keys: [], + blocked_names: [], +}; + +function createArgs(overrides: Partial[0]> = {}) { + const activeConversation: Conversation = { + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }; + const contacts: Contact[] = []; + const channels: Channel[] = [publicChannel]; + const rawPackets: RawPacket[] = []; + const favorites: Favorite[] = []; + const messages: Message[] = []; + + return { + contacts, + channels, + rawPackets, + undecryptedCount: 0, + activeConversation, + config, + health, + favorites, + appSettings, + unreadCounts: {}, + mentions: {}, + lastMessageTimes: {}, + showCracker: false, + crackerRunning: false, + messageInputRef: { current: null }, + targetMessageId: null, + infoPaneContactKey: null, + infoPaneFromChannel: false, + infoPaneChannelKey: null, + messages, + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + hasNewerMessages: false, + loadingNewer: false, + handleOpenNewMessage: vi.fn(), + handleToggleCracker: vi.fn(), + markAllRead: vi.fn(async () => {}), + handleSortOrderChange: vi.fn(async () => {}), + handleSelectConversationWithTargetReset: vi.fn(), + handleNavigateToMessage: vi.fn(), + handleSaveConfig: vi.fn(async () => {}), + handleSaveAppSettings: vi.fn(async () => {}), + handleSetPrivateKey: vi.fn(async () => {}), + handleReboot: vi.fn(async () => {}), + handleAdvertise: vi.fn(async () => {}), + handleHealthRefresh: vi.fn(async () => {}), + fetchAppSettings: vi.fn(async () => {}), + setChannels: vi.fn(), + fetchUndecryptedCount: vi.fn(async () => {}), + handleCreateContact: vi.fn(async () => {}), + handleCreateChannel: vi.fn(async () => {}), + handleCreateHashtagChannel: vi.fn(async () => {}), + handleDeleteContact: vi.fn(async () => {}), + handleDeleteChannel: vi.fn(async () => {}), + handleToggleFavorite: vi.fn(async () => {}), + handleSetChannelFloodScopeOverride: vi.fn(async () => {}), + handleOpenContactInfo: vi.fn(), + handleOpenChannelInfo: vi.fn(), + handleCloseContactInfo: vi.fn(), + handleCloseChannelInfo: vi.fn(), + handleSenderClick: vi.fn(), + handleResendChannelMessage: vi.fn(async () => {}), + handleTrace: vi.fn(async () => {}), + handleSendMessage: vi.fn(async () => {}), + fetchOlderMessages: vi.fn(async () => {}), + fetchNewerMessages: vi.fn(async () => {}), + jumpToBottom: vi.fn(), + setTargetMessageId: vi.fn(), + handleNavigateToChannel: vi.fn(), + handleBlockKey: vi.fn(async () => {}), + handleBlockName: vi.fn(async () => {}), + ...overrides, + }; +} + +describe('useAppShellProps', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a cracked channel, refreshes channels, decrypts history, and refreshes undecrypted count', async () => { + mocks.api.createChannel.mockResolvedValue({ + key: '11'.repeat(16), + name: 'Found', + is_hashtag: false, + }); + mocks.api.getChannels.mockResolvedValue([ + publicChannel, + { ...publicChannel, key: '11'.repeat(16), name: 'Found' }, + ]); + mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 }); + + const args = createArgs(); + const { result } = renderHook(() => useAppShellProps(args)); + + await act(async () => { + await result.current.crackerProps.onChannelCreate('Found', '11'.repeat(16)); + }); + + expect(mocks.api.createChannel).toHaveBeenCalledWith('Found', '11'.repeat(16)); + expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); + expect(args.setChannels).toHaveBeenCalledWith([ + publicChannel, + { ...publicChannel, key: '11'.repeat(16), name: 'Found' }, + ]); + expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({ + key_type: 'channel', + channel_key: '11'.repeat(16), + }); + expect(args.fetchUndecryptedCount).toHaveBeenCalledTimes(1); + }); +}); From c3f1a43a8042283f99d794829941eb072da15dec Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 21:51:07 -0700 Subject: [PATCH 16/27] Be more gentle with frontend typing + go back to fire-and-forget for cracked room creation --- app/events.py | 16 ++++++++-- frontend/src/hooks/useAppShellProps.ts | 4 ++- frontend/src/test/useAppShellProps.test.ts | 36 ++++++++++++++++++++++ tests/test_websocket.py | 11 ++++--- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/app/events.py b/app/events.py index 59fb163..b396786 100644 --- a/app/events.py +++ b/app/events.py @@ -1,6 +1,7 @@ """Typed WebSocket event contracts and serialization helpers.""" import json +import logging from typing import Any, Literal from pydantic import TypeAdapter @@ -9,6 +10,8 @@ from typing_extensions import NotRequired, TypedDict from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast from app.routers.health import HealthResponse +logger = logging.getLogger(__name__) + WsEventType = Literal[ "health", "message", @@ -82,9 +85,16 @@ def dump_ws_event(event_type: str, data: Any) -> str: if adapter is None: return json.dumps({"type": event_type, "data": data}) - validated = adapter.validate_python(data) - payload = adapter.dump_python(validated, mode="json") - return json.dumps({"type": event_type, "data": payload}) + try: + validated = adapter.validate_python(data) + payload = adapter.dump_python(validated, mode="json") + return json.dumps({"type": event_type, "data": payload}) + except Exception: + logger.exception( + "Failed to validate WebSocket payload for event %s; falling back to raw JSON envelope", + event_type, + ) + return json.dumps({"type": event_type, "data": data}) def dump_ws_event_payload(event_type: str, data: Any) -> Any: diff --git a/frontend/src/hooks/useAppShellProps.ts b/frontend/src/hooks/useAppShellProps.ts index dd3eba5..46a4a9d 100644 --- a/frontend/src/hooks/useAppShellProps.ts +++ b/frontend/src/hooks/useAppShellProps.ts @@ -188,7 +188,9 @@ export function useAppShellProps({ key_type: 'channel', channel_key: created.key, }); - await fetchUndecryptedCount(); + void fetchUndecryptedCount().catch((error) => { + console.error('Failed to refresh undecrypted count after cracked channel create:', error); + }); }, [fetchUndecryptedCount, setChannels] ); diff --git a/frontend/src/test/useAppShellProps.test.ts b/frontend/src/test/useAppShellProps.test.ts index 692052e..c5d057e 100644 --- a/frontend/src/test/useAppShellProps.test.ts +++ b/frontend/src/test/useAppShellProps.test.ts @@ -186,4 +186,40 @@ describe('useAppShellProps', () => { }); expect(args.fetchUndecryptedCount).toHaveBeenCalledTimes(1); }); + + it('does not fail cracked channel creation when undecrypted count refresh rejects', async () => { + mocks.api.createChannel.mockResolvedValue({ + key: '22'.repeat(16), + name: 'Found', + is_hashtag: false, + }); + mocks.api.getChannels.mockResolvedValue([ + publicChannel, + { ...publicChannel, key: '22'.repeat(16), name: 'Found' }, + ]); + mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 }); + + const args = createArgs({ + fetchUndecryptedCount: vi.fn(async () => { + throw new Error('refresh failed'); + }), + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useAppShellProps(args)); + + await act(async () => { + await result.current.crackerProps.onChannelCreate('Found', '22'.repeat(16)); + }); + + expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({ + key_type: 'channel', + channel_key: '22'.repeat(16), + }); + expect(consoleError).toHaveBeenCalledWith( + 'Failed to refresh undecrypted count after cracked channel create:', + expect.any(Error) + ); + + consoleError.mockRestore(); + }); }); diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 49930ed..f18204f 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -5,7 +5,6 @@ import json from unittest.mock import AsyncMock, patch import pytest -from pydantic import ValidationError from app.websocket import SEND_TIMEOUT_SECONDS, WebSocketManager @@ -262,8 +261,12 @@ class TestTypedEventSerialization: "data": {"message_id": 7, "ack_count": 2}, } - def test_dump_ws_event_validates_supported_payloads(self): + def test_dump_ws_event_falls_back_to_raw_payload_when_validation_fails(self): from app.events import dump_ws_event - with pytest.raises(ValidationError): - dump_ws_event("message_acked", {"ack_count": 2}) + serialized = dump_ws_event("message_acked", {"ack_count": 2}) + + assert json.loads(serialized) == { + "type": "message_acked", + "data": {"ack_count": 2}, + } From 5e94b14b45b470bb1fe76ca4303a749adf0ad5fe Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 22:20:21 -0700 Subject: [PATCH 17/27] Refactor visualizer --- frontend/AGENTS.md | 7 + .../components/AGENTS_packet_visualizer.md | 21 +- .../src/components/PacketVisualizer3D.tsx | 1971 +---------------- .../visualizer/VisualizerControls.tsx | 317 +++ .../visualizer/VisualizerTooltip.tsx | 73 + frontend/src/components/visualizer/shared.ts | 83 + .../visualizer/useVisualizer3DScene.ts | 578 +++++ .../visualizer/useVisualizerData3D.ts | 922 ++++++++ frontend/src/test/visualizerTooltip.test.tsx | 78 + 9 files changed, 2122 insertions(+), 1928 deletions(-) create mode 100644 frontend/src/components/visualizer/VisualizerControls.tsx create mode 100644 frontend/src/components/visualizer/VisualizerTooltip.tsx create mode 100644 frontend/src/components/visualizer/shared.ts create mode 100644 frontend/src/components/visualizer/useVisualizer3DScene.ts create mode 100644 frontend/src/components/visualizer/useVisualizerData3D.ts create mode 100644 frontend/src/test/visualizerTooltip.test.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d6cc4ad..68f9381 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -51,6 +51,12 @@ frontend/src/ ├── components/ │ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals │ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty) +│ ├── visualizer/ +│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state +│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction +│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay +│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay +│ │ └── shared.ts # Graph node/link types and shared rendering helpers │ └── ... ├── utils/ │ ├── urlHash.ts # Hash parsing and encoding @@ -216,6 +222,7 @@ High-level state is delegated to hooks: ### Visualizer behavior - `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs). +- `PacketVisualizer3D.tsx` is now a thin composition shell over visualizer-specific hooks/components in `components/visualizer/`. - `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame. - Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets. - Raw-packet decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`. diff --git a/frontend/src/components/AGENTS_packet_visualizer.md b/frontend/src/components/AGENTS_packet_visualizer.md index fa4be89..d37d507 100644 --- a/frontend/src/components/AGENTS_packet_visualizer.md +++ b/frontend/src/components/AGENTS_packet_visualizer.md @@ -12,7 +12,7 @@ The visualizer displays: ## Architecture -### Data Layer (`useVisualizerData3D` hook) +### Data Layer (`components/visualizer/useVisualizerData3D.ts`) The custom hook manages all graph state and simulation logic: @@ -39,6 +39,8 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An ### Rendering Layer (Three.js) +Scene creation, render-loop updates, raycasting hover, and click-to-pin interaction live in `components/visualizer/useVisualizer3DScene.ts`. + - `THREE.WebGLRenderer` + `CSS2DRenderer` (text labels overlaid on 3D scene) - `OrbitControls` for camera interaction (orbit, pan, zoom) - `THREE.Mesh` with `SphereGeometry` per node + `CSS2DObject` labels @@ -46,15 +48,20 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An - `THREE.Points` with vertex colors for particles (persistent geometry + circular sprite texture) - `THREE.Raycaster` for hover/click detection on node spheres -### Shared Utilities (`utils/visualizerUtils.ts`) +### Shared Utilities -Types, constants, and pure functions shared across the codebase: +- `components/visualizer/shared.ts` + - Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData` + - Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers +- `utils/visualizerUtils.ts` + - Packet parsing, identity helpers, ambiguous repeater heuristics, constants shared across visualizer code -- Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis` -- Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS` -- Functions: `hashString` (from `utils/contactAvatar.ts`), `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation` +### UI Overlays -`GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`. +- `components/visualizer/VisualizerControls.tsx` + - Legends, settings toggles, repulsion/speed controls, reset/stretch actions +- `components/visualizer/VisualizerTooltip.tsx` + - Hovered/pinned node metadata and neighbor list ### Type Declarations (`types/d3-force-3d.d.ts`) diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx index 1025ce5..aa712a0 100644 --- a/frontend/src/components/PacketVisualizer3D.tsx +++ b/frontend/src/components/PacketVisualizer3D.tsx @@ -1,1048 +1,12 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import * as THREE from 'three'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; -import { - forceSimulation, - forceLink, - forceManyBody, - forceCenter, - forceX, - forceY, - forceZ, - type Simulation3D, - type SimulationNodeDatum3D, - type ForceLink3D, -} from 'd3-force-3d'; -import type { SimulationLinkDatum } from 'd3-force'; -import { PayloadType } from '@michaelhart/meshcore-decoder'; +import { useEffect, useRef, useState } from 'react'; + import { api } from '../api'; -import { - CONTACT_TYPE_REPEATER, - type Contact, - type RawPacket, - type RadioConfig, - type ContactAdvertPathSummary, -} from '../types'; -import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; +import type { Contact, ContactAdvertPathSummary, RadioConfig, RawPacket } from '../types'; import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings'; -import { Checkbox } from './ui/checkbox'; -import { - type NodeType, - type Particle, - type PendingPacket, - type RepeaterTrafficData, - buildAmbiguousRepeaterLabel, - buildAmbiguousRepeaterNodeId, - COLORS, - PARTICLE_COLOR_MAP, - PARTICLE_SPEED, - PACKET_LEGEND_ITEMS, - parsePacket, - getPacketLabel, - generatePacketKey, - getLinkId, - getNodeType, - dedupeConsecutive, - analyzeRepeaterTraffic, - recordTrafficObservation, -} from '../utils/visualizerUtils'; - -// ============================================================================= -// TYPES (local — extend d3-force-3d simulation datum types) -// ============================================================================= - -interface GraphNode extends SimulationNodeDatum3D { - id: string; - name: string | null; - type: NodeType; - isAmbiguous: boolean; - lastActivity: number; - lastActivityReason?: string; - lastSeen?: number | null; - probableIdentity?: string | null; - ambiguousNames?: string[]; -} - -interface GraphLink extends SimulationLinkDatum { - source: string | GraphNode; - target: string | GraphNode; - lastActivity: number; -} - -// ============================================================================= -// 3D NODE COLORS -// ============================================================================= - -const NODE_COLORS = { - self: 0x22c55e, // green - repeater: 0x3b82f6, // blue - client: 0xffffff, // white - ambiguous: 0x9ca3af, // gray -} as const; - -const NODE_LEGEND_ITEMS = [ - { color: '#22c55e', label: 'You', size: 14 }, - { color: '#3b82f6', label: 'Repeater', size: 10 }, - { color: '#ffffff', label: 'Node', size: 10 }, - { color: '#9ca3af', label: 'Ambiguous', size: 10 }, -] as const; - -function getBaseNodeColor(node: Pick): number { - if (node.type === 'self') return NODE_COLORS.self; - if (node.type === 'repeater') return NODE_COLORS.repeater; - return node.isAmbiguous ? NODE_COLORS.ambiguous : NODE_COLORS.client; -} - -function growFloat32Buffer( - current: Float32Array, - requiredLength: number -): Float32Array { - let nextLength = Math.max(12, current.length); - while (nextLength < requiredLength) { - nextLength *= 2; - } - return new Float32Array(nextLength); -} - -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -function formatRelativeTime(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 5) return 'just now'; - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - const secs = seconds % 60; - return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`; -} - -function normalizePacketTimestampMs(timestamp: number | null | undefined): number { - if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) { - return Date.now(); - } - const ts = Number(timestamp); - // Backend currently sends Unix seconds; tolerate millis if already provided. - return ts > 1_000_000_000_000 ? ts : ts * 1000; -} - -// ============================================================================= -// DATA LAYER HOOK (3D variant) -// ============================================================================= - -interface UseVisualizerData3DOptions { - packets: RawPacket[]; - contacts: Contact[]; - config: RadioConfig | null; - repeaterAdvertPaths: ContactAdvertPathSummary[]; - showAmbiguousPaths: boolean; - showAmbiguousNodes: boolean; - useAdvertPathHints: boolean; - splitAmbiguousByTraffic: boolean; - chargeStrength: number; - letEmDrift: boolean; - particleSpeedMultiplier: number; - observationWindowSec: number; - pruneStaleNodes: boolean; - pruneStaleMinutes: number; -} - -interface VisualizerData3D { - nodes: Map; - links: Map; - particles: Particle[]; - stats: { processed: number; animated: number; nodes: number; links: number }; - expandContract: () => void; - clearAndReset: () => void; -} - -function useVisualizerData3D({ - packets, - contacts, - config, - repeaterAdvertPaths, - showAmbiguousPaths, - showAmbiguousNodes, - useAdvertPathHints, - splitAmbiguousByTraffic, - chargeStrength, - letEmDrift, - particleSpeedMultiplier, - observationWindowSec, - pruneStaleNodes, - pruneStaleMinutes, -}: UseVisualizerData3DOptions): VisualizerData3D { - const nodesRef = useRef>(new Map()); - const linksRef = useRef>(new Map()); - const particlesRef = useRef([]); - const simulationRef = useRef | null>(null); - const processedRef = useRef>(new Set()); - const pendingRef = useRef>(new Map()); - const timersRef = useRef>>(new Map()); - const trafficPatternsRef = useRef>(new Map()); - const speedMultiplierRef = useRef(particleSpeedMultiplier); - const observationWindowRef = useRef(observationWindowSec * 1000); - const stretchRafRef = useRef(null); - const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 }); - - const contactIndex = useMemo(() => { - const byPrefix12 = new Map(); - const byName = new Map(); - const byPrefix = new Map(); - - for (const contact of contacts) { - const prefix12 = contact.public_key.slice(0, 12).toLowerCase(); - byPrefix12.set(prefix12, contact); - - if (contact.name && !byName.has(contact.name)) { - byName.set(contact.name, contact); - } - - for (let len = 1; len <= 12; len++) { - const prefix = prefix12.slice(0, len); - const matches = byPrefix.get(prefix); - if (matches) { - matches.push(contact); - } else { - byPrefix.set(prefix, [contact]); - } - } - } - - return { byPrefix12, byName, byPrefix }; - }, [contacts]); - - const advertPathIndex = useMemo(() => { - const byRepeater = new Map(); - for (const summary of repeaterAdvertPaths) { - const key = summary.public_key.slice(0, 12).toLowerCase(); - byRepeater.set(key, summary.paths); - } - return { byRepeater }; - }, [repeaterAdvertPaths]); - - // Keep refs in sync with props - useEffect(() => { - speedMultiplierRef.current = particleSpeedMultiplier; - }, [particleSpeedMultiplier]); - - useEffect(() => { - observationWindowRef.current = observationWindowSec * 1000; - }, [observationWindowSec]); - - // Initialize simulation (3D — centered at origin) - useEffect(() => { - const sim = forceSimulation([]) - .numDimensions(3) - .force( - 'link', - forceLink([]) - .id((d) => d.id) - .distance(120) - .strength(0.3) - ) - .force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? -1200 : -200)) - .distanceMax(800) - ) - .force('center', forceCenter(0, 0, 0)) - .force( - 'selfX', - forceX(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .force( - 'selfY', - forceY(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .force( - 'selfZ', - forceZ(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .alphaDecay(0.02) - .velocityDecay(0.5) - .alphaTarget(0.03); - - simulationRef.current = sim; - return () => { - sim.stop(); - }; - }, []); - - // Update simulation forces when charge changes - useEffect(() => { - const sim = simulationRef.current; - if (!sim) return; - - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)) - .distanceMax(800) - ); - sim.alpha(0.3).restart(); - }, [chargeStrength]); - - // Update alphaTarget when drift preference changes - useEffect(() => { - const sim = simulationRef.current; - if (!sim) return; - sim.alphaTarget(letEmDrift ? 0.05 : 0); - }, [letEmDrift]); - - // Ensure self node exists - useEffect(() => { - if (!nodesRef.current.has('self')) { - nodesRef.current.set('self', { - id: 'self', - name: config?.name || 'Me', - type: 'self', - isAmbiguous: false, - lastActivity: Date.now(), - x: 0, - y: 0, - z: 0, - }); - syncSimulation(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable - }, [config]); - - const syncSimulation = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - const nodes = Array.from(nodesRef.current.values()); - const links = Array.from(linksRef.current.values()); - - sim.nodes(nodes); - const linkForce = sim.force('link') as ForceLink3D | undefined; - linkForce?.links(links); - - sim.alpha(0.15).restart(); - - setStats((prev) => - prev.nodes === nodes.length && prev.links === links.length - ? prev - : { ...prev, nodes: nodes.length, links: links.length } - ); - }, []); - - // Reset on option changes - useEffect(() => { - processedRef.current.clear(); - const selfNode = nodesRef.current.get('self'); - nodesRef.current.clear(); - if (selfNode) nodesRef.current.set('self', selfNode); - linksRef.current.clear(); - particlesRef.current = []; - pendingRef.current.clear(); - timersRef.current.forEach((t) => clearTimeout(t)); - timersRef.current.clear(); - trafficPatternsRef.current.clear(); - setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); - syncSimulation(); - }, [ - showAmbiguousPaths, - showAmbiguousNodes, - useAdvertPathHints, - splitAmbiguousByTraffic, - syncSimulation, - ]); - - const addNode = useCallback( - ( - id: string, - name: string | null, - type: NodeType, - isAmbiguous: boolean, - probableIdentity?: string | null, - ambiguousNames?: string[], - lastSeen?: number | null, - activityAtMs?: number - ) => { - const activityAt = activityAtMs ?? Date.now(); - const existing = nodesRef.current.get(id); - if (existing) { - existing.lastActivity = Math.max(existing.lastActivity, activityAt); - if (name) existing.name = name; - if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity; - if (ambiguousNames) existing.ambiguousNames = ambiguousNames; - if (lastSeen !== undefined) existing.lastSeen = lastSeen; - } else { - // Initialize in 3D sphere around origin - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(2 * Math.random() - 1); - const r = 80 + Math.random() * 100; - nodesRef.current.set(id, { - id, - name, - type, - isAmbiguous, - lastActivity: activityAt, - probableIdentity, - lastSeen, - ambiguousNames, - x: r * Math.sin(phi) * Math.cos(theta), - y: r * Math.sin(phi) * Math.sin(theta), - z: r * Math.cos(phi), - }); - } - }, - [] - ); - - const addLink = useCallback((sourceId: string, targetId: string, activityAtMs?: number) => { - const activityAt = activityAtMs ?? Date.now(); - const key = [sourceId, targetId].sort().join('->'); - const existing = linksRef.current.get(key); - if (existing) { - existing.lastActivity = Math.max(existing.lastActivity, activityAt); - } else { - linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt }); - } - }, []); - - const publishPacket = useCallback((packetKey: string) => { - const pending = pendingRef.current.get(packetKey); - if (!pending) return; - - pendingRef.current.delete(packetKey); - timersRef.current.delete(packetKey); - - // Skip particle creation when tab is hidden — nobody is watching, and - // creating them now would cause a burst of animations when the tab - // becomes visible again (since rAF is paused while hidden). - if (document.hidden) return; - - for (const path of pending.paths) { - const dedupedPath = dedupeConsecutive(path.nodes); - if (dedupedPath.length < 2) continue; - - for (let i = 0; i < dedupedPath.length - 1; i++) { - particlesRef.current.push({ - linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'), - progress: -i, - speed: PARTICLE_SPEED * speedMultiplierRef.current, - color: PARTICLE_COLOR_MAP[pending.label], - label: pending.label, - fromNodeId: dedupedPath[i], - toNodeId: dedupedPath[i + 1], - }); - } - } - }, []); - - const pickLikelyRepeaterByAdvertPath = useCallback( - (candidates: Contact[], nextPrefix: string | null) => { - const nextHop = nextPrefix?.toLowerCase() ?? null; - const scored = candidates - .map((candidate) => { - const prefix12 = candidate.public_key.slice(0, 12).toLowerCase(); - const paths = advertPathIndex.byRepeater.get(prefix12) ?? []; - let matchScore = 0; - let totalScore = 0; - - for (const path of paths) { - totalScore += path.heard_count; - const pathNextHop = path.next_hop?.toLowerCase() ?? null; - if (pathNextHop === nextHop) { - matchScore += path.heard_count; - } - } - - return { candidate, matchScore, totalScore }; - }) - .filter((entry) => entry.totalScore > 0) - .sort( - (a, b) => - b.matchScore - a.matchScore || - b.totalScore - a.totalScore || - a.candidate.public_key.localeCompare(b.candidate.public_key) - ); - - if (scored.length === 0) return null; - - const top = scored[0]; - const second = scored[1] ?? null; - - // Require stronger-than-trivial evidence and a clear winner. - if (top.matchScore < 2) return null; - if (second && top.matchScore < second.matchScore * 2) return null; - - return top.candidate; - }, - [advertPathIndex] - ); - - const resolveNode = useCallback( - ( - source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, - isRepeater: boolean, - showAmbiguous: boolean, - myPrefix: string | null, - activityAtMs: number, - trafficContext?: { packetSource: string | null; nextPrefix: string | null } - ): string | null => { - if (source.type === 'pubkey') { - if (source.value.length < 12) return null; - const nodeId = source.value.slice(0, 12).toLowerCase(); - if (myPrefix && nodeId === myPrefix) return 'self'; - const contact = contactIndex.byPrefix12.get(nodeId); - addNode( - nodeId, - contact?.name || null, - getNodeType(contact), - false, - undefined, - undefined, - contact?.last_seen, - activityAtMs - ); - return nodeId; - } - - if (source.type === 'name') { - const contact = contactIndex.byName.get(source.value) ?? null; - if (contact) { - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - if (myPrefix && nodeId === myPrefix) return 'self'; - addNode( - nodeId, - contact.name, - getNodeType(contact), - false, - undefined, - undefined, - contact.last_seen, - activityAtMs - ); - return nodeId; - } - const nodeId = `name:${source.value}`; - addNode( - nodeId, - source.value, - 'client', - false, - undefined, - undefined, - undefined, - activityAtMs - ); - return nodeId; - } - - // type === 'prefix' - const lookupValue = source.value.toLowerCase(); - const matches = contactIndex.byPrefix.get(lookupValue) ?? []; - const contact = matches.length === 1 ? matches[0] : null; - if (contact) { - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - if (myPrefix && nodeId === myPrefix) return 'self'; - addNode( - nodeId, - contact.name, - getNodeType(contact), - false, - undefined, - undefined, - contact.last_seen, - activityAtMs - ); - return nodeId; - } - - if (showAmbiguous) { - const filtered = isRepeater - ? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER) - : matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER); - - if (filtered.length === 1) { - const c = filtered[0]; - const nodeId = c.public_key.slice(0, 12).toLowerCase(); - addNode( - nodeId, - c.name, - getNodeType(c), - false, - undefined, - undefined, - c.last_seen, - activityAtMs - ); - return nodeId; - } - - if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) { - const names = filtered.map((c) => c.name || c.public_key.slice(0, 8)); - const lastSeen = filtered.reduce( - (max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max), - null as number | null - ); - - let nodeId = buildAmbiguousRepeaterNodeId(lookupValue); - let displayName = buildAmbiguousRepeaterLabel(lookupValue); - let probableIdentity: string | null = null; - let ambiguousNames = names.length > 0 ? names : undefined; - - if (useAdvertPathHints && isRepeater && trafficContext) { - const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; - const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext); - if (likely) { - const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase(); - probableIdentity = likelyName; - displayName = likelyName; - ambiguousNames = filtered - .filter((c) => c.public_key !== likely.public_key) - .map((c) => c.name || c.public_key.slice(0, 8)); - } - } - - if (splitAmbiguousByTraffic && isRepeater && trafficContext) { - const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; - - if (trafficContext.packetSource) { - recordTrafficObservation( - trafficPatternsRef.current, - lookupValue, - trafficContext.packetSource, - normalizedNext - ); - } - - const trafficData = trafficPatternsRef.current.get(lookupValue); - if (trafficData) { - const analysis = analyzeRepeaterTraffic(trafficData); - if (analysis.shouldSplit && normalizedNext) { - nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext); - if (!probableIdentity) { - displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext); - } - } - } - } - - addNode( - nodeId, - displayName, - isRepeater ? 'repeater' : 'client', - true, - probableIdentity, - ambiguousNames, - lastSeen, - activityAtMs - ); - return nodeId; - } - } - - return null; - }, - [ - contactIndex, - addNode, - useAdvertPathHints, - pickLikelyRepeaterByAdvertPath, - splitAmbiguousByTraffic, - ] - ); - - const buildPath = useCallback( - ( - parsed: ReturnType, - packet: RawPacket, - myPrefix: string | null, - activityAtMs: number - ): string[] => { - if (!parsed) return []; - const path: string[] = []; - let packetSource: string | null = null; - - if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.advertPubkey }, - false, - false, - myPrefix, - activityAtMs - ); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.anonRequestPubkey }, - false, - false, - myPrefix, - activityAtMs - ); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { - if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { - path.push('self'); - packetSource = 'self'; - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.srcHash }, - false, - showAmbiguousNodes, - myPrefix, - activityAtMs - ); - if (nodeId) { - path.push(nodeId); - packetSource = nodeId; - } - } - } else if (parsed.payloadType === PayloadType.GroupText) { - const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; - if (senderName) { - const resolved = resolveNode( - { type: 'name', value: senderName }, - false, - false, - myPrefix, - activityAtMs - ); - if (resolved) { - path.push(resolved); - packetSource = resolved; - } - } - } - - for (let i = 0; i < parsed.pathBytes.length; i++) { - const hexPrefix = parsed.pathBytes[i]; - const nextPrefix = parsed.pathBytes[i + 1] || null; - const nodeId = resolveNode( - { type: 'prefix', value: hexPrefix }, - true, - showAmbiguousPaths, - myPrefix, - activityAtMs, - { packetSource, nextPrefix } - ); - if (nodeId) path.push(nodeId); - } - - if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { - if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { - path.push('self'); - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.dstHash }, - false, - showAmbiguousNodes, - myPrefix, - activityAtMs - ); - if (nodeId) path.push(nodeId); - else path.push('self'); - } - } else if (path.length > 0) { - path.push('self'); - } - - if (path.length > 0 && path[path.length - 1] !== 'self') { - path.push('self'); - } - - return dedupeConsecutive(path); - }, - [resolveNode, showAmbiguousPaths, showAmbiguousNodes] - ); - - // Process packets - useEffect(() => { - let newProcessed = 0; - let newAnimated = 0; - let needsUpdate = false; - const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; - - for (const packet of packets) { - const observationKey = getRawPacketObservationKey(packet); - if (processedRef.current.has(observationKey)) continue; - processedRef.current.add(observationKey); - newProcessed++; - - if (processedRef.current.size > 1000) { - processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); - } - - const parsed = parsePacket(packet.data); - if (!parsed) continue; - - const packetActivityAt = normalizePacketTimestampMs(packet.timestamp); - const path = buildPath(parsed, packet, myPrefix, packetActivityAt); - if (path.length < 2) continue; - - // Tag each node with why it's considered active - const label = getPacketLabel(parsed.payloadType); - for (let i = 0; i < path.length; i++) { - const n = nodesRef.current.get(path[i]); - if (n && n.id !== 'self') { - n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`; - } - } - - for (let i = 0; i < path.length - 1; i++) { - if (path[i] !== path[i + 1]) { - addLink(path[i], path[i + 1], packetActivityAt); - needsUpdate = true; - } - } - - const packetKey = generatePacketKey(parsed, packet); - const now = Date.now(); - const existing = pendingRef.current.get(packetKey); - - if (existing && now < existing.expiresAt) { - existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now }); - } else { - if (timersRef.current.has(packetKey)) { - clearTimeout(timersRef.current.get(packetKey)); - } - const windowMs = observationWindowRef.current; - pendingRef.current.set(packetKey, { - key: packetKey, - label: getPacketLabel(parsed.payloadType), - paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }], - firstSeen: now, - expiresAt: now + windowMs, - }); - timersRef.current.set( - packetKey, - setTimeout(() => publishPacket(packetKey), windowMs) - ); - } - - if (pendingRef.current.size > 100) { - const entries = Array.from(pendingRef.current.entries()) - .sort((a, b) => a[1].firstSeen - b[1].firstSeen) - .slice(0, 50); - for (const [key] of entries) { - clearTimeout(timersRef.current.get(key)); - timersRef.current.delete(key); - pendingRef.current.delete(key); - } - } - - newAnimated++; - } - - if (needsUpdate) syncSimulation(); - if (newProcessed > 0) { - setStats((prev) => ({ - ...prev, - processed: prev.processed + newProcessed, - animated: prev.animated + newAnimated, - })); - } - }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); - - const expandContract = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - if (stretchRafRef.current !== null) { - cancelAnimationFrame(stretchRafRef.current); - stretchRafRef.current = null; - } - - const startChargeStrength = chargeStrength; - const peakChargeStrength = -5000; - const startLinkStrength = 0.3; - const minLinkStrength = 0.02; - const expandDuration = 1000; - const holdDuration = 2000; - const contractDuration = 1000; - const startTime = performance.now(); - - const animate = (now: number) => { - const elapsed = now - startTime; - let currentChargeStrength: number; - let currentLinkStrength: number; - - if (elapsed < expandDuration) { - const t = elapsed / expandDuration; - currentChargeStrength = - startChargeStrength + (peakChargeStrength - startChargeStrength) * t; - currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t; - } else if (elapsed < expandDuration + holdDuration) { - currentChargeStrength = peakChargeStrength; - currentLinkStrength = minLinkStrength; - } else if (elapsed < expandDuration + holdDuration + contractDuration) { - const t = (elapsed - expandDuration - holdDuration) / contractDuration; - currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t; - currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t; - } else { - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength)) - .distanceMax(800) - ); - sim.force( - 'link', - forceLink(Array.from(linksRef.current.values())) - .id((d) => d.id) - .distance(120) - .strength(startLinkStrength) - ); - sim.alpha(0.3).restart(); - stretchRafRef.current = null; - return; - } - - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength)) - .distanceMax(800) - ); - sim.force( - 'link', - forceLink(Array.from(linksRef.current.values())) - .id((d) => d.id) - .distance(120) - .strength(currentLinkStrength) - ); - sim.alpha(0.5).restart(); - - stretchRafRef.current = requestAnimationFrame(animate); - }; - - stretchRafRef.current = requestAnimationFrame(animate); - }, [chargeStrength]); - - const clearAndReset = useCallback(() => { - if (stretchRafRef.current !== null) { - cancelAnimationFrame(stretchRafRef.current); - stretchRafRef.current = null; - } - - for (const timer of timersRef.current.values()) { - clearTimeout(timer); - } - timersRef.current.clear(); - pendingRef.current.clear(); - processedRef.current.clear(); - trafficPatternsRef.current.clear(); - particlesRef.current.length = 0; - linksRef.current.clear(); - - const selfNode = nodesRef.current.get('self'); - nodesRef.current.clear(); - if (selfNode) { - selfNode.x = 0; - selfNode.y = 0; - selfNode.z = 0; - selfNode.vx = 0; - selfNode.vy = 0; - selfNode.vz = 0; - selfNode.lastActivity = Date.now(); - nodesRef.current.set('self', selfNode); - } - - const sim = simulationRef.current; - if (sim) { - sim.nodes(Array.from(nodesRef.current.values())); - const linkForce = sim.force('link') as ForceLink3D | undefined; - linkForce?.links([]); - sim.alpha(0.3).restart(); - } - - setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); - }, []); - - useEffect(() => { - const stretchRaf = stretchRafRef; - const timers = timersRef.current; - const pending = pendingRef.current; - return () => { - if (stretchRaf.current !== null) { - cancelAnimationFrame(stretchRaf.current); - } - for (const timer of timers.values()) { - clearTimeout(timer); - } - timers.clear(); - pending.clear(); - }; - }, []); - - // Prune nodes with no recent activity - useEffect(() => { - if (!pruneStaleNodes) return; - - const STALE_MS = pruneStaleMinutes * 60 * 1000; - const PRUNE_INTERVAL_MS = 1_000; - - const interval = setInterval(() => { - const cutoff = Date.now() - STALE_MS; - let pruned = false; - - for (const [id, node] of nodesRef.current) { - if (id === 'self') continue; - if (node.lastActivity < cutoff) { - nodesRef.current.delete(id); - pruned = true; - } - } - - if (pruned) { - // Remove links that reference pruned nodes - for (const [key, link] of linksRef.current) { - const { sourceId, targetId } = getLinkId(link); - if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) { - linksRef.current.delete(key); - } - } - syncSimulation(); - } - }, PRUNE_INTERVAL_MS); - - return () => clearInterval(interval); - }, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]); - - return useMemo( - () => ({ - nodes: nodesRef.current, - links: linksRef.current, - particles: particlesRef.current, - stats, - expandContract, - clearAndReset, - }), - [stats, expandContract, clearAndReset] - ); -} - -// ============================================================================= -// THREE.JS SCENE MANAGEMENT -// ============================================================================= - -interface NodeMeshData { - mesh: THREE.Mesh; - label: CSS2DObject; - labelDiv: HTMLDivElement; -} - -// ============================================================================= -// MAIN COMPONENT -// ============================================================================= +import { VisualizerControls } from './visualizer/VisualizerControls'; +import { VisualizerTooltip } from './visualizer/VisualizerTooltip'; +import { useVisualizerData3D } from './visualizer/useVisualizerData3D'; +import { useVisualizer3DScene } from './visualizer/useVisualizer3DScene'; interface PacketVisualizer3DProps { packets: RawPacket[]; @@ -1060,25 +24,7 @@ export function PacketVisualizer3D({ onFullScreenChange, }: PacketVisualizer3DProps) { const containerRef = useRef(null); - const rendererRef = useRef(null); - const cssRendererRef = useRef(null); - const sceneRef = useRef(null); - const cameraRef = useRef(null); - const controlsRef = useRef(null); - const nodeMeshesRef = useRef>(new Map()); - const raycastTargetsRef = useRef([]); - const linkLineRef = useRef(null); - const highlightLineRef = useRef(null); - const particlePointsRef = useRef(null); - const particleTextureRef = useRef(null); - const linkPositionBufferRef = useRef>(new Float32Array(0)); - const highlightPositionBufferRef = useRef>(new Float32Array(0)); - const particlePositionBufferRef = useRef>(new Float32Array(0)); - const particleColorBufferRef = useRef>(new Float32Array(0)); - const raycasterRef = useRef(new THREE.Raycaster()); - const mouseRef = useRef(new THREE.Vector2()); - // Options const [savedSettings] = useState(getVisualizerSettings); const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(savedSettings.showAmbiguousPaths); const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(savedSettings.showAmbiguousNodes); @@ -1100,7 +46,6 @@ export function PacketVisualizer3D({ const [pruneStaleMinutes, setPruneStaleMinutes] = useState(savedSettings.pruneStaleMinutes); const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState([]); - // Persist visualizer controls to localStorage on change useEffect(() => { saveVisualizerSettings({ ...getVisualizerSettings(), @@ -1143,7 +88,6 @@ export function PacketVisualizer3D({ } } catch (error) { if (!cancelled) { - // Best-effort hinting; keep visualizer fully functional without this data. console.debug('Failed to load repeater advert path hints', error); setRepeaterAdvertPaths([]); } @@ -1156,15 +100,6 @@ export function PacketVisualizer3D({ }; }, [contacts.length]); - // Hover & click-to-pin - const [hoveredNodeId, setHoveredNodeId] = useState(null); - const hoveredNodeIdRef = useRef(null); - const [hoveredNeighborIds, setHoveredNeighborIds] = useState([]); - const hoveredNeighborIdsRef = useRef([]); - const pinnedNodeIdRef = useRef(null); - const [pinnedNodeId, setPinnedNodeId] = useState(null); - - // Data layer const data = useVisualizerData3D({ packets, contacts, @@ -1181,549 +116,14 @@ export function PacketVisualizer3D({ pruneStaleNodes, pruneStaleMinutes, }); - const dataRef = useRef(data); - useEffect(() => { - dataRef.current = data; - }, [data]); - // Initialize Three.js scene - useEffect(() => { - const container = containerRef.current; - if (!container) return; + const { hoveredNodeId, hoveredNeighborIds, pinnedNodeId } = useVisualizer3DScene({ + containerRef, + data, + autoOrbit, + }); - // Scene - const scene = new THREE.Scene(); - scene.background = new THREE.Color(COLORS.background); - sceneRef.current = scene; - - // Camera - const camera = new THREE.PerspectiveCamera(60, 1, 1, 5000); - camera.position.set(0, 0, 400); - cameraRef.current = camera; - - // WebGL renderer - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setPixelRatio(window.devicePixelRatio); - container.appendChild(renderer.domElement); - rendererRef.current = renderer; - - // Circular particle sprite texture (so particles render as circles, not squares) - const texSize = 64; - const texCanvas = document.createElement('canvas'); - texCanvas.width = texSize; - texCanvas.height = texSize; - const texCtx = texCanvas.getContext('2d')!; - const gradient = texCtx.createRadialGradient( - texSize / 2, - texSize / 2, - 0, - texSize / 2, - texSize / 2, - texSize / 2 - ); - gradient.addColorStop(0, 'rgba(255,255,255,1)'); - gradient.addColorStop(0.5, 'rgba(255,255,255,0.8)'); - gradient.addColorStop(1, 'rgba(255,255,255,0)'); - texCtx.fillStyle = gradient; - texCtx.fillRect(0, 0, texSize, texSize); - const particleTexture = new THREE.CanvasTexture(texCanvas); - particleTextureRef.current = particleTexture; - - // CSS2D renderer for text labels - const cssRenderer = new CSS2DRenderer(); - cssRenderer.domElement.style.position = 'absolute'; - cssRenderer.domElement.style.top = '0'; - cssRenderer.domElement.style.left = '0'; - cssRenderer.domElement.style.pointerEvents = 'none'; - cssRenderer.domElement.style.zIndex = '1'; - container.appendChild(cssRenderer.domElement); - cssRendererRef.current = cssRenderer; - - // OrbitControls - const controls = new OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.1; - controls.minDistance = 50; - controls.maxDistance = 2000; - controlsRef.current = controls; - - // Persistent line meshes (their buffers are updated in-place each frame) - const linkGeometry = new THREE.BufferGeometry(); - const linkMaterial = new THREE.LineBasicMaterial({ - color: COLORS.link, - transparent: true, - opacity: 0.6, - }); - const linkSegments = new THREE.LineSegments(linkGeometry, linkMaterial); - linkSegments.visible = false; - scene.add(linkSegments); - linkLineRef.current = linkSegments; - - const highlightGeometry = new THREE.BufferGeometry(); - const highlightMaterial = new THREE.LineBasicMaterial({ - color: 0xffd700, - transparent: true, - opacity: 1.0, - linewidth: 2, - }); - const highlightSegments = new THREE.LineSegments(highlightGeometry, highlightMaterial); - highlightSegments.visible = false; - scene.add(highlightSegments); - highlightLineRef.current = highlightSegments; - - const particleGeometry = new THREE.BufferGeometry(); - const particleMaterial = new THREE.PointsMaterial({ - size: 20, - map: particleTexture, - vertexColors: true, - sizeAttenuation: true, - transparent: true, - opacity: 0.9, - depthWrite: false, - }); - const particlePoints = new THREE.Points(particleGeometry, particleMaterial); - particlePoints.visible = false; - scene.add(particlePoints); - particlePointsRef.current = particlePoints; - - // Initial sizing - const rect = container.getBoundingClientRect(); - renderer.setSize(rect.width, rect.height); - cssRenderer.setSize(rect.width, rect.height); - camera.aspect = rect.width / rect.height; - camera.updateProjectionMatrix(); - - // Resize observer - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - if (width === 0 || height === 0) continue; - renderer.setSize(width, height); - cssRenderer.setSize(width, height); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - } - }); - observer.observe(container); - - const nodeMeshes = nodeMeshesRef.current; - return () => { - observer.disconnect(); - controls.dispose(); - renderer.dispose(); - // Remove renderer DOM elements - if (renderer.domElement.parentNode) { - renderer.domElement.parentNode.removeChild(renderer.domElement); - } - if (cssRenderer.domElement.parentNode) { - cssRenderer.domElement.parentNode.removeChild(cssRenderer.domElement); - } - // Clean up node meshes and their CSS2D label DOM elements - for (const nd of nodeMeshes.values()) { - nd.mesh.remove(nd.label); - nd.labelDiv.remove(); - scene.remove(nd.mesh); - nd.mesh.geometry.dispose(); - (nd.mesh.material as THREE.Material).dispose(); - } - nodeMeshes.clear(); - raycastTargetsRef.current = []; - - if (linkLineRef.current) { - scene.remove(linkLineRef.current); - linkLineRef.current.geometry.dispose(); - (linkLineRef.current.material as THREE.Material).dispose(); - linkLineRef.current = null; - } - if (highlightLineRef.current) { - scene.remove(highlightLineRef.current); - highlightLineRef.current.geometry.dispose(); - (highlightLineRef.current.material as THREE.Material).dispose(); - highlightLineRef.current = null; - } - if (particlePointsRef.current) { - scene.remove(particlePointsRef.current); - particlePointsRef.current.geometry.dispose(); - (particlePointsRef.current.material as THREE.Material).dispose(); - particlePointsRef.current = null; - } - particleTexture.dispose(); - particleTextureRef.current = null; - linkPositionBufferRef.current = new Float32Array(0); - highlightPositionBufferRef.current = new Float32Array(0); - particlePositionBufferRef.current = new Float32Array(0); - particleColorBufferRef.current = new Float32Array(0); - sceneRef.current = null; - cameraRef.current = null; - rendererRef.current = null; - cssRendererRef.current = null; - controlsRef.current = null; - }; - }, []); - - // Sync auto-orbit with OrbitControls - useEffect(() => { - const controls = controlsRef.current; - if (!controls) return; - controls.autoRotate = autoOrbit; - controls.autoRotateSpeed = -0.5; // negative = clockwise from above - }, [autoOrbit]); - - // Mouse handlers for raycasting and click-to-pin - useEffect(() => { - const renderer = rendererRef.current; - const camera = cameraRef.current; - if (!renderer || !camera) return; - - const onMouseMove = (event: MouseEvent) => { - const rect = renderer.domElement.getBoundingClientRect(); - mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - }; - - let mouseDownPos = { x: 0, y: 0 }; - - const onMouseDown = (event: MouseEvent) => { - mouseDownPos = { x: event.clientX, y: event.clientY }; - }; - - const onMouseUp = (event: MouseEvent) => { - // Only count as click if mouse didn't move much (not a drag/orbit) - const dx = event.clientX - mouseDownPos.x; - const dy = event.clientY - mouseDownPos.y; - if (dx * dx + dy * dy > 25) return; - - const rect = renderer.domElement.getBoundingClientRect(); - const clickMouse = new THREE.Vector2( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1 - ); - - const raycaster = raycasterRef.current; - raycaster.setFromCamera(clickMouse, camera); - const intersects = raycaster.intersectObjects(raycastTargetsRef.current, false); - const clickedObject = intersects[0]?.object as THREE.Mesh | undefined; - const clickedId = (clickedObject?.userData?.nodeId as string | undefined) ?? null; - - if (clickedId === pinnedNodeIdRef.current) { - // Unpin - pinnedNodeIdRef.current = null; - setPinnedNodeId(null); - } else if (clickedId) { - // Pin this node - pinnedNodeIdRef.current = clickedId; - setPinnedNodeId(clickedId); - } else { - // Clicked empty space — unpin - pinnedNodeIdRef.current = null; - setPinnedNodeId(null); - } - }; - - renderer.domElement.addEventListener('mousemove', onMouseMove); - renderer.domElement.addEventListener('mousedown', onMouseDown); - renderer.domElement.addEventListener('mouseup', onMouseUp); - return () => { - renderer.domElement.removeEventListener('mousemove', onMouseMove); - renderer.domElement.removeEventListener('mousedown', onMouseDown); - renderer.domElement.removeEventListener('mouseup', onMouseUp); - }; - }, []); - - // Animation loop - useEffect(() => { - const scene = sceneRef.current; - const camera = cameraRef.current; - const renderer = rendererRef.current; - const cssRenderer = cssRendererRef.current; - const controls = controlsRef.current; - if (!scene || !camera || !renderer || !cssRenderer || !controls) return; - - let running = true; - - const animate = () => { - if (!running) return; - requestAnimationFrame(animate); - - controls.update(); - - const { nodes, links, particles } = dataRef.current; - - // --- Sync node meshes --- - const currentNodeIds = new Set(); - - for (const node of nodes.values()) { - currentNodeIds.add(node.id); - - let nd = nodeMeshesRef.current.get(node.id); - if (!nd) { - const isSelf = node.type === 'self'; - const radius = isSelf ? 12 : 6; - const geometry = new THREE.SphereGeometry(radius, 16, 12); - const material = new THREE.MeshBasicMaterial({ color: getBaseNodeColor(node) }); - const mesh = new THREE.Mesh(geometry, material); - mesh.userData.nodeId = node.id; - scene.add(mesh); - - const labelDiv = document.createElement('div'); - labelDiv.style.color = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; - labelDiv.style.fontSize = '11px'; - labelDiv.style.fontFamily = 'sans-serif'; - labelDiv.style.textAlign = 'center'; - labelDiv.style.whiteSpace = 'nowrap'; - labelDiv.style.textShadow = '0 0 4px #000, 0 0 2px #000'; - const label = new CSS2DObject(labelDiv); - label.position.set(0, -(radius + 6), 0); - mesh.add(label); - - nd = { mesh, label, labelDiv }; - nodeMeshesRef.current.set(node.id, nd); - raycastTargetsRef.current.push(mesh); - } - - nd.mesh.position.set(node.x ?? 0, node.y ?? 0, node.z ?? 0); - const labelColor = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; - if (nd.labelDiv.style.color !== labelColor) { - nd.labelDiv.style.color = labelColor; - } - const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); - if (nd.labelDiv.textContent !== labelText) { - nd.labelDiv.textContent = labelText; - } - } - - for (const [id, nd] of nodeMeshesRef.current) { - if (!currentNodeIds.has(id)) { - nd.mesh.remove(nd.label); - nd.labelDiv.remove(); - scene.remove(nd.mesh); - nd.mesh.geometry.dispose(); - (nd.mesh.material as THREE.Material).dispose(); - const meshIdx = raycastTargetsRef.current.indexOf(nd.mesh); - if (meshIdx >= 0) raycastTargetsRef.current.splice(meshIdx, 1); - nodeMeshesRef.current.delete(id); - } - } - - // --- Raycasting for hover --- - raycasterRef.current.setFromCamera(mouseRef.current, camera); - const intersects = raycasterRef.current.intersectObjects(raycastTargetsRef.current, false); - const hitObject = intersects[0]?.object as THREE.Mesh | undefined; - const hitId = (hitObject?.userData?.nodeId as string | undefined) ?? null; - if (hitId !== hoveredNodeIdRef.current) { - hoveredNodeIdRef.current = hitId; - setHoveredNodeId(hitId); - } - const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current; - - // --- Sync links (buffers updated in-place) --- - const visibleLinks: GraphLink[] = []; - for (const link of links.values()) { - const { sourceId, targetId } = getLinkId(link); - if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) { - visibleLinks.push(link); - } - } - - const connectedIds = activeId ? new Set([activeId]) : null; - - const linkLine = linkLineRef.current; - if (linkLine) { - const geometry = linkLine.geometry as THREE.BufferGeometry; - const requiredLength = visibleLinks.length * 6; - if (linkPositionBufferRef.current.length < requiredLength) { - linkPositionBufferRef.current = growFloat32Buffer( - linkPositionBufferRef.current, - requiredLength - ); - geometry.setAttribute( - 'position', - new THREE.BufferAttribute(linkPositionBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - - const highlightLine = highlightLineRef.current; - if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) { - highlightPositionBufferRef.current = growFloat32Buffer( - highlightPositionBufferRef.current, - requiredLength - ); - (highlightLine.geometry as THREE.BufferGeometry).setAttribute( - 'position', - new THREE.BufferAttribute(highlightPositionBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - - const positions = linkPositionBufferRef.current; - const hlPositions = highlightPositionBufferRef.current; - let idx = 0; - let hlIdx = 0; - - for (const link of visibleLinks) { - const { sourceId, targetId } = getLinkId(link); - const sNode = nodes.get(sourceId); - const tNode = nodes.get(targetId); - if (!sNode || !tNode) continue; - - const sx = sNode.x ?? 0; - const sy = sNode.y ?? 0; - const sz = sNode.z ?? 0; - const tx = tNode.x ?? 0; - const ty = tNode.y ?? 0; - const tz = tNode.z ?? 0; - - positions[idx++] = sx; - positions[idx++] = sy; - positions[idx++] = sz; - positions[idx++] = tx; - positions[idx++] = ty; - positions[idx++] = tz; - - if (activeId && (sourceId === activeId || targetId === activeId)) { - connectedIds?.add(sourceId === activeId ? targetId : sourceId); - hlPositions[hlIdx++] = sx; - hlPositions[hlIdx++] = sy; - hlPositions[hlIdx++] = sz; - hlPositions[hlIdx++] = tx; - hlPositions[hlIdx++] = ty; - hlPositions[hlIdx++] = tz; - } - } - - const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; - if (positionAttr) { - positionAttr.needsUpdate = true; - } - geometry.setDrawRange(0, idx / 3); - linkLine.visible = idx > 0; - - if (highlightLine) { - const hlGeometry = highlightLine.geometry as THREE.BufferGeometry; - const hlAttr = hlGeometry.getAttribute('position') as THREE.BufferAttribute | undefined; - if (hlAttr) { - hlAttr.needsUpdate = true; - } - hlGeometry.setDrawRange(0, hlIdx / 3); - highlightLine.visible = hlIdx > 0; - } - } - - // --- Sync particles (buffers updated in-place) --- - let writeIdx = 0; - for (let readIdx = 0; readIdx < particles.length; readIdx++) { - const particle = particles[readIdx]; - particle.progress += particle.speed; - if (particle.progress <= 1) { - particles[writeIdx++] = particle; - } - } - particles.length = writeIdx; - - const particlePoints = particlePointsRef.current; - if (particlePoints) { - const geometry = particlePoints.geometry as THREE.BufferGeometry; - const requiredLength = particles.length * 3; - - if (particlePositionBufferRef.current.length < requiredLength) { - particlePositionBufferRef.current = growFloat32Buffer( - particlePositionBufferRef.current, - requiredLength - ); - geometry.setAttribute( - 'position', - new THREE.BufferAttribute(particlePositionBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - if (particleColorBufferRef.current.length < requiredLength) { - particleColorBufferRef.current = growFloat32Buffer( - particleColorBufferRef.current, - requiredLength - ); - geometry.setAttribute( - 'color', - new THREE.BufferAttribute(particleColorBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - - const pPositions = particlePositionBufferRef.current; - const pColors = particleColorBufferRef.current; - const color = new THREE.Color(); - let visibleCount = 0; - - for (const p of particles) { - if (p.progress < 0) continue; - if (!currentNodeIds.has(p.fromNodeId) || !currentNodeIds.has(p.toNodeId)) continue; - - const fromNode = nodes.get(p.fromNodeId); - const toNode = nodes.get(p.toNodeId); - if (!fromNode || !toNode) continue; - - const t = p.progress; - const x = (fromNode.x ?? 0) + ((toNode.x ?? 0) - (fromNode.x ?? 0)) * t; - const y = (fromNode.y ?? 0) + ((toNode.y ?? 0) - (fromNode.y ?? 0)) * t; - const z = (fromNode.z ?? 0) + ((toNode.z ?? 0) - (fromNode.z ?? 0)) * t; - - pPositions[visibleCount * 3] = x; - pPositions[visibleCount * 3 + 1] = y; - pPositions[visibleCount * 3 + 2] = z; - - color.set(p.color); - pColors[visibleCount * 3] = color.r; - pColors[visibleCount * 3 + 1] = color.g; - pColors[visibleCount * 3 + 2] = color.b; - visibleCount++; - } - - const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; - const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute | undefined; - if (posAttr) posAttr.needsUpdate = true; - if (colorAttr) colorAttr.needsUpdate = true; - geometry.setDrawRange(0, visibleCount); - particlePoints.visible = visibleCount > 0; - } - - // Sync neighbor info only when changed to avoid re-rendering every frame. - const nextNeighbors = connectedIds - ? Array.from(connectedIds) - .filter((id) => id !== activeId) - .sort() - : []; - if (!arraysEqual(hoveredNeighborIdsRef.current, nextNeighbors)) { - hoveredNeighborIdsRef.current = nextNeighbors; - setHoveredNeighborIds(nextNeighbors); - } - - // Highlight active node and neighbors - for (const [id, nd] of nodeMeshesRef.current) { - const node = nodes.get(id); - if (!node) continue; - const mat = nd.mesh.material as THREE.MeshBasicMaterial; - if (id === activeId) { - mat.color.set(0xffd700); - } else if (connectedIds?.has(id)) { - mat.color.set(0xfff0b3); - } else { - mat.color.set(getBaseNodeColor(node)); - } - } - - renderer.render(scene, camera); - cssRenderer.render(scene, camera); - }; - - animate(); - return () => { - running = false; - }; - }, []); + const tooltipNodeId = pinnedNodeId ?? hoveredNodeId; return (
- {/* Legend */} - {showControls && ( -
-
-
-
Packets
- {PACKET_LEGEND_ITEMS.map((item) => ( -
-
- {item.label} -
- {item.description} -
- ))} -
-
-
Nodes
- {NODE_LEGEND_ITEMS.map((item) => ( -
-
- {item.label} -
- ))} -
-
-
- )} + - {/* Options */} -
-
-
- - {onFullScreenChange && ( - - )} -
- {showControls && ( - <> -
- - - - -
- - - setObservationWindowSec( - Math.max(1, Math.min(60, parseInt(e.target.value) || 1)) - ) - } - className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" - /> - sec -
-
- - {pruneStaleNodes && ( -
- - { - const v = parseInt(e.target.value, 10); - if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v); - }} - className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm" - /> - -
- )} - - -
- - setChargeStrength(-parseInt(e.target.value))} - className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" - /> -
-
- - setParticleSpeedMultiplier(parseFloat(e.target.value))} - className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" - /> -
-
- - -
-
-
Nodes: {data.stats.nodes}
-
Links: {data.stats.links}
-
- - )} -
-
- - {/* Hovered/pinned node tooltip */} - {(pinnedNodeId ?? hoveredNodeId) && ( -
- {(() => { - const tooltipNodeId = pinnedNodeId ?? hoveredNodeId; - const node = tooltipNodeId ? data.nodes.get(tooltipNodeId) : null; - if (!node) return null; - const neighbors = hoveredNeighborIds - .map((nid) => { - const n = data.nodes.get(nid); - if (!n) return null; - const displayName = n.name || (n.type === 'self' ? 'Me' : n.id.slice(0, 8)); - return { id: nid, name: displayName, ambiguousNames: n.ambiguousNames }; - }) - .filter(Boolean); - return ( -
-
- {node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8))} -
-
ID: {node.id}
-
- Type: {node.type} - {node.isAmbiguous ? ' (ambiguous)' : ''} -
- {node.probableIdentity && ( -
Probably: {node.probableIdentity}
- )} - {node.ambiguousNames && node.ambiguousNames.length > 0 && ( -
- {node.probableIdentity ? 'Other possible: ' : 'Possible: '} - {node.ambiguousNames.join(', ')} -
- )} - {node.type !== 'self' && ( -
-
Last active: {formatRelativeTime(node.lastActivity)}
- {node.lastActivityReason &&
Reason: {node.lastActivityReason}
} -
- )} - {neighbors.length > 0 && ( -
-
Traffic exchanged with:
-
    - {neighbors.map((nb) => ( -
  • - {nb!.name} - {nb!.ambiguousNames && nb!.ambiguousNames.length > 0 && ( - - {' '} - ({nb!.ambiguousNames.join(', ')}) - - )} -
  • - ))} -
-
- )} -
- ); - })()} -
- )} +
); } diff --git a/frontend/src/components/visualizer/VisualizerControls.tsx b/frontend/src/components/visualizer/VisualizerControls.tsx new file mode 100644 index 0000000..0c475fc --- /dev/null +++ b/frontend/src/components/visualizer/VisualizerControls.tsx @@ -0,0 +1,317 @@ +import { Checkbox } from '../ui/checkbox'; +import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils'; +import { NODE_LEGEND_ITEMS } from './shared'; + +interface VisualizerControlsProps { + showControls: boolean; + setShowControls: (value: boolean) => void; + fullScreen?: boolean; + onFullScreenChange?: (fullScreen: boolean) => void; + showAmbiguousPaths: boolean; + setShowAmbiguousPaths: (value: boolean) => void; + showAmbiguousNodes: boolean; + setShowAmbiguousNodes: (value: boolean) => void; + useAdvertPathHints: boolean; + setUseAdvertPathHints: (value: boolean) => void; + splitAmbiguousByTraffic: boolean; + setSplitAmbiguousByTraffic: (value: boolean) => void; + observationWindowSec: number; + setObservationWindowSec: (value: number) => void; + pruneStaleNodes: boolean; + setPruneStaleNodes: (value: boolean) => void; + pruneStaleMinutes: number; + setPruneStaleMinutes: (value: number) => void; + letEmDrift: boolean; + setLetEmDrift: (value: boolean) => void; + autoOrbit: boolean; + setAutoOrbit: (value: boolean) => void; + chargeStrength: number; + setChargeStrength: (value: number) => void; + particleSpeedMultiplier: number; + setParticleSpeedMultiplier: (value: number) => void; + nodeCount: number; + linkCount: number; + onExpandContract: () => void; + onClearAndReset: () => void; +} + +export function VisualizerControls({ + showControls, + setShowControls, + fullScreen, + onFullScreenChange, + showAmbiguousPaths, + setShowAmbiguousPaths, + showAmbiguousNodes, + setShowAmbiguousNodes, + useAdvertPathHints, + setUseAdvertPathHints, + splitAmbiguousByTraffic, + setSplitAmbiguousByTraffic, + observationWindowSec, + setObservationWindowSec, + pruneStaleNodes, + setPruneStaleNodes, + pruneStaleMinutes, + setPruneStaleMinutes, + letEmDrift, + setLetEmDrift, + autoOrbit, + setAutoOrbit, + chargeStrength, + setChargeStrength, + particleSpeedMultiplier, + setParticleSpeedMultiplier, + nodeCount, + linkCount, + onExpandContract, + onClearAndReset, +}: VisualizerControlsProps) { + return ( + <> + {showControls && ( +
+
+
+
Packets
+ {PACKET_LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ {item.description} +
+ ))} +
+
+
Nodes
+ {NODE_LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ ))} +
+
+
+ )} + +
+
+
+ + {onFullScreenChange && ( + + )} +
+ {showControls && ( + <> +
+ + + + +
+ + + setObservationWindowSec( + Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1)) + ) + } + className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" + /> + sec +
+
+ + {pruneStaleNodes && ( +
+ + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v); + }} + className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm" + /> + +
+ )} + + +
+ + setChargeStrength(-parseInt(e.target.value, 10))} + className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ + setParticleSpeedMultiplier(parseFloat(e.target.value))} + className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ + +
+
+
Nodes: {nodeCount}
+
Links: {linkCount}
+
+ + )} +
+
+ + ); +} diff --git a/frontend/src/components/visualizer/VisualizerTooltip.tsx b/frontend/src/components/visualizer/VisualizerTooltip.tsx new file mode 100644 index 0000000..0482a4a --- /dev/null +++ b/frontend/src/components/visualizer/VisualizerTooltip.tsx @@ -0,0 +1,73 @@ +import type { GraphNode } from './shared'; +import { formatRelativeTime } from './shared'; + +interface VisualizerTooltipProps { + activeNodeId: string | null; + nodes: Map; + neighborIds: string[]; +} + +export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) { + if (!activeNodeId) return null; + + const node = nodes.get(activeNodeId); + if (!node) return null; + + const neighbors = neighborIds + .map((nid) => { + const neighbor = nodes.get(nid); + if (!neighbor) return null; + const displayName = + neighbor.name || (neighbor.type === 'self' ? 'Me' : neighbor.id.slice(0, 8)); + return { id: nid, name: displayName, ambiguousNames: neighbor.ambiguousNames }; + }) + .filter((neighbor): neighbor is NonNullable => neighbor !== null); + + return ( +
+
+
+ {node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8))} +
+
ID: {node.id}
+
+ Type: {node.type} + {node.isAmbiguous ? ' (ambiguous)' : ''} +
+ {node.probableIdentity && ( +
Probably: {node.probableIdentity}
+ )} + {node.ambiguousNames && node.ambiguousNames.length > 0 && ( +
+ {node.probableIdentity ? 'Other possible: ' : 'Possible: '} + {node.ambiguousNames.join(', ')} +
+ )} + {node.type !== 'self' && ( +
+
Last active: {formatRelativeTime(node.lastActivity)}
+ {node.lastActivityReason &&
Reason: {node.lastActivityReason}
} +
+ )} + {neighbors.length > 0 && ( +
+
Traffic exchanged with:
+
    + {neighbors.map((neighbor) => ( +
  • + {neighbor.name} + {neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && ( + + {' '} + ({neighbor.ambiguousNames.join(', ')}) + + )} +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/visualizer/shared.ts b/frontend/src/components/visualizer/shared.ts new file mode 100644 index 0000000..39d153b --- /dev/null +++ b/frontend/src/components/visualizer/shared.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three'; +import type { SimulationLinkDatum } from 'd3-force'; +import type { SimulationNodeDatum3D } from 'd3-force-3d'; +import type { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + +import type { NodeType } from '../../utils/visualizerUtils'; + +export interface GraphNode extends SimulationNodeDatum3D { + id: string; + name: string | null; + type: NodeType; + isAmbiguous: boolean; + lastActivity: number; + lastActivityReason?: string; + lastSeen?: number | null; + probableIdentity?: string | null; + ambiguousNames?: string[]; +} + +export interface GraphLink extends SimulationLinkDatum { + source: string | GraphNode; + target: string | GraphNode; + lastActivity: number; +} + +export interface NodeMeshData { + mesh: THREE.Mesh; + label: CSS2DObject; + labelDiv: HTMLDivElement; +} + +export const NODE_COLORS = { + self: 0x22c55e, + repeater: 0x3b82f6, + client: 0xffffff, + ambiguous: 0x9ca3af, +} as const; + +export const NODE_LEGEND_ITEMS = [ + { color: '#22c55e', label: 'You', size: 14 }, + { color: '#3b82f6', label: 'Repeater', size: 10 }, + { color: '#ffffff', label: 'Node', size: 10 }, + { color: '#9ca3af', label: 'Ambiguous', size: 10 }, +] as const; + +export function getBaseNodeColor(node: Pick): number { + if (node.type === 'self') return NODE_COLORS.self; + if (node.type === 'repeater') return NODE_COLORS.repeater; + return node.isAmbiguous ? NODE_COLORS.ambiguous : NODE_COLORS.client; +} + +export function growFloat32Buffer(current: Float32Array, requiredLength: number): Float32Array { + let nextLength = Math.max(12, current.length); + while (nextLength < requiredLength) { + nextLength *= 2; + } + return new Float32Array(nextLength); +} + +export function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`; +} + +export function normalizePacketTimestampMs(timestamp: number | null | undefined): number { + if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) { + return Date.now(); + } + const ts = Number(timestamp); + return ts > 1_000_000_000_000 ? ts : ts * 1000; +} diff --git a/frontend/src/components/visualizer/useVisualizer3DScene.ts b/frontend/src/components/visualizer/useVisualizer3DScene.ts new file mode 100644 index 0000000..6bcab6e --- /dev/null +++ b/frontend/src/components/visualizer/useVisualizer3DScene.ts @@ -0,0 +1,578 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + +import { COLORS, getLinkId } from '../../utils/visualizerUtils'; +import type { VisualizerData3D } from './useVisualizerData3D'; +import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared'; + +interface UseVisualizer3DSceneArgs { + containerRef: RefObject; + data: VisualizerData3D; + autoOrbit: boolean; +} + +interface UseVisualizer3DSceneResult { + hoveredNodeId: string | null; + hoveredNeighborIds: string[]; + pinnedNodeId: string | null; +} + +export function useVisualizer3DScene({ + containerRef, + data, + autoOrbit, +}: UseVisualizer3DSceneArgs): UseVisualizer3DSceneResult { + const rendererRef = useRef(null); + const cssRendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const nodeMeshesRef = useRef>(new Map()); + const raycastTargetsRef = useRef([]); + const linkLineRef = useRef(null); + const highlightLineRef = useRef(null); + const particlePointsRef = useRef(null); + const particleTextureRef = useRef(null); + const linkPositionBufferRef = useRef(new Float32Array(0)); + const highlightPositionBufferRef = useRef(new Float32Array(0)); + const particlePositionBufferRef = useRef(new Float32Array(0)); + const particleColorBufferRef = useRef(new Float32Array(0)); + const raycasterRef = useRef(new THREE.Raycaster()); + const mouseRef = useRef(new THREE.Vector2()); + const dataRef = useRef(data); + + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const hoveredNodeIdRef = useRef(null); + const [hoveredNeighborIds, setHoveredNeighborIds] = useState([]); + const hoveredNeighborIdsRef = useRef([]); + const pinnedNodeIdRef = useRef(null); + const [pinnedNodeId, setPinnedNodeId] = useState(null); + + useEffect(() => { + dataRef.current = data; + }, [data]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(COLORS.background); + sceneRef.current = scene; + + const camera = new THREE.PerspectiveCamera(60, 1, 1, 5000); + camera.position.set(0, 0, 400); + cameraRef.current = camera; + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + container.appendChild(renderer.domElement); + rendererRef.current = renderer; + + const texSize = 64; + const texCanvas = document.createElement('canvas'); + texCanvas.width = texSize; + texCanvas.height = texSize; + const texCtx = texCanvas.getContext('2d'); + if (!texCtx) { + renderer.dispose(); + if (renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + return; + } + const gradient = texCtx.createRadialGradient( + texSize / 2, + texSize / 2, + 0, + texSize / 2, + texSize / 2, + texSize / 2 + ); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(0.5, 'rgba(255,255,255,0.8)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + texCtx.fillStyle = gradient; + texCtx.fillRect(0, 0, texSize, texSize); + const particleTexture = new THREE.CanvasTexture(texCanvas); + particleTextureRef.current = particleTexture; + + const cssRenderer = new CSS2DRenderer(); + cssRenderer.domElement.style.position = 'absolute'; + cssRenderer.domElement.style.top = '0'; + cssRenderer.domElement.style.left = '0'; + cssRenderer.domElement.style.pointerEvents = 'none'; + cssRenderer.domElement.style.zIndex = '1'; + container.appendChild(cssRenderer.domElement); + cssRendererRef.current = cssRenderer; + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.minDistance = 50; + controls.maxDistance = 2000; + controlsRef.current = controls; + + const linkGeometry = new THREE.BufferGeometry(); + const linkMaterial = new THREE.LineBasicMaterial({ + color: COLORS.link, + transparent: true, + opacity: 0.6, + }); + const linkSegments = new THREE.LineSegments(linkGeometry, linkMaterial); + linkSegments.visible = false; + scene.add(linkSegments); + linkLineRef.current = linkSegments; + + const highlightGeometry = new THREE.BufferGeometry(); + const highlightMaterial = new THREE.LineBasicMaterial({ + color: 0xffd700, + transparent: true, + opacity: 1, + linewidth: 2, + }); + const highlightSegments = new THREE.LineSegments(highlightGeometry, highlightMaterial); + highlightSegments.visible = false; + scene.add(highlightSegments); + highlightLineRef.current = highlightSegments; + + const particleGeometry = new THREE.BufferGeometry(); + const particleMaterial = new THREE.PointsMaterial({ + size: 20, + map: particleTexture, + vertexColors: true, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + }); + const particlePoints = new THREE.Points(particleGeometry, particleMaterial); + particlePoints.visible = false; + scene.add(particlePoints); + particlePointsRef.current = particlePoints; + + const rect = container.getBoundingClientRect(); + renderer.setSize(rect.width, rect.height); + cssRenderer.setSize(rect.width, rect.height); + camera.aspect = rect.width / rect.height; + camera.updateProjectionMatrix(); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width === 0 || height === 0) continue; + renderer.setSize(width, height); + cssRenderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } + }); + observer.observe(container); + + const nodeMeshes = nodeMeshesRef.current; + return () => { + observer.disconnect(); + controls.dispose(); + renderer.dispose(); + if (renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + if (cssRenderer.domElement.parentNode) { + cssRenderer.domElement.parentNode.removeChild(cssRenderer.domElement); + } + for (const nd of nodeMeshes.values()) { + nd.mesh.remove(nd.label); + nd.labelDiv.remove(); + scene.remove(nd.mesh); + nd.mesh.geometry.dispose(); + (nd.mesh.material as THREE.Material).dispose(); + } + nodeMeshes.clear(); + raycastTargetsRef.current = []; + + if (linkLineRef.current) { + scene.remove(linkLineRef.current); + linkLineRef.current.geometry.dispose(); + (linkLineRef.current.material as THREE.Material).dispose(); + linkLineRef.current = null; + } + if (highlightLineRef.current) { + scene.remove(highlightLineRef.current); + highlightLineRef.current.geometry.dispose(); + (highlightLineRef.current.material as THREE.Material).dispose(); + highlightLineRef.current = null; + } + if (particlePointsRef.current) { + scene.remove(particlePointsRef.current); + particlePointsRef.current.geometry.dispose(); + (particlePointsRef.current.material as THREE.Material).dispose(); + particlePointsRef.current = null; + } + particleTexture.dispose(); + particleTextureRef.current = null; + linkPositionBufferRef.current = new Float32Array(0); + highlightPositionBufferRef.current = new Float32Array(0); + particlePositionBufferRef.current = new Float32Array(0); + particleColorBufferRef.current = new Float32Array(0); + sceneRef.current = null; + cameraRef.current = null; + rendererRef.current = null; + cssRendererRef.current = null; + controlsRef.current = null; + }; + }, [containerRef]); + + useEffect(() => { + const controls = controlsRef.current; + if (!controls) return; + controls.autoRotate = autoOrbit; + controls.autoRotateSpeed = -0.5; + }, [autoOrbit]); + + useEffect(() => { + const renderer = rendererRef.current; + const camera = cameraRef.current; + if (!renderer || !camera) return; + + const onMouseMove = (event: MouseEvent) => { + const rect = renderer.domElement.getBoundingClientRect(); + mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + }; + + let mouseDownPos = { x: 0, y: 0 }; + + const onMouseDown = (event: MouseEvent) => { + mouseDownPos = { x: event.clientX, y: event.clientY }; + }; + + const onMouseUp = (event: MouseEvent) => { + const dx = event.clientX - mouseDownPos.x; + const dy = event.clientY - mouseDownPos.y; + if (dx * dx + dy * dy > 25) return; + + const rect = renderer.domElement.getBoundingClientRect(); + const clickMouse = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + + const raycaster = raycasterRef.current; + raycaster.setFromCamera(clickMouse, camera); + const intersects = raycaster.intersectObjects(raycastTargetsRef.current, false); + const clickedObject = intersects[0]?.object as THREE.Mesh | undefined; + const clickedId = (clickedObject?.userData?.nodeId as string | undefined) ?? null; + + if (clickedId === pinnedNodeIdRef.current) { + pinnedNodeIdRef.current = null; + setPinnedNodeId(null); + } else if (clickedId) { + pinnedNodeIdRef.current = clickedId; + setPinnedNodeId(clickedId); + } else { + pinnedNodeIdRef.current = null; + setPinnedNodeId(null); + } + }; + + renderer.domElement.addEventListener('mousemove', onMouseMove); + renderer.domElement.addEventListener('mousedown', onMouseDown); + renderer.domElement.addEventListener('mouseup', onMouseUp); + return () => { + renderer.domElement.removeEventListener('mousemove', onMouseMove); + renderer.domElement.removeEventListener('mousedown', onMouseDown); + renderer.domElement.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + useEffect(() => { + const scene = sceneRef.current; + const camera = cameraRef.current; + const renderer = rendererRef.current; + const cssRenderer = cssRendererRef.current; + const controls = controlsRef.current; + if (!scene || !camera || !renderer || !cssRenderer || !controls) return; + + let running = true; + + const animate = () => { + if (!running) return; + requestAnimationFrame(animate); + + controls.update(); + + const { nodes, links, particles } = dataRef.current; + const currentNodeIds = new Set(); + + for (const node of nodes.values()) { + currentNodeIds.add(node.id); + + let nd = nodeMeshesRef.current.get(node.id); + if (!nd) { + const isSelf = node.type === 'self'; + const radius = isSelf ? 12 : 6; + const geometry = new THREE.SphereGeometry(radius, 16, 12); + const material = new THREE.MeshBasicMaterial({ color: getBaseNodeColor(node) }); + const mesh = new THREE.Mesh(geometry, material); + mesh.userData.nodeId = node.id; + scene.add(mesh); + + const labelDiv = document.createElement('div'); + labelDiv.style.color = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; + labelDiv.style.fontSize = '11px'; + labelDiv.style.fontFamily = 'sans-serif'; + labelDiv.style.textAlign = 'center'; + labelDiv.style.whiteSpace = 'nowrap'; + labelDiv.style.textShadow = '0 0 4px #000, 0 0 2px #000'; + const label = new CSS2DObject(labelDiv); + label.position.set(0, -(radius + 6), 0); + mesh.add(label); + + nd = { mesh, label, labelDiv }; + nodeMeshesRef.current.set(node.id, nd); + raycastTargetsRef.current.push(mesh); + } + + nd.mesh.position.set(node.x ?? 0, node.y ?? 0, node.z ?? 0); + const labelColor = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; + if (nd.labelDiv.style.color !== labelColor) { + nd.labelDiv.style.color = labelColor; + } + const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); + if (nd.labelDiv.textContent !== labelText) { + nd.labelDiv.textContent = labelText; + } + } + + for (const [id, nd] of nodeMeshesRef.current) { + if (!currentNodeIds.has(id)) { + nd.mesh.remove(nd.label); + nd.labelDiv.remove(); + scene.remove(nd.mesh); + nd.mesh.geometry.dispose(); + (nd.mesh.material as THREE.Material).dispose(); + const meshIdx = raycastTargetsRef.current.indexOf(nd.mesh); + if (meshIdx >= 0) raycastTargetsRef.current.splice(meshIdx, 1); + nodeMeshesRef.current.delete(id); + } + } + + raycasterRef.current.setFromCamera(mouseRef.current, camera); + const intersects = raycasterRef.current.intersectObjects(raycastTargetsRef.current, false); + const hitObject = intersects[0]?.object as THREE.Mesh | undefined; + const hitId = (hitObject?.userData?.nodeId as string | undefined) ?? null; + if (hitId !== hoveredNodeIdRef.current) { + hoveredNodeIdRef.current = hitId; + setHoveredNodeId(hitId); + } + const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current; + + const visibleLinks = []; + for (const link of links.values()) { + const { sourceId, targetId } = getLinkId(link); + if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) { + visibleLinks.push(link); + } + } + + const connectedIds = activeId ? new Set([activeId]) : null; + + const linkLine = linkLineRef.current; + if (linkLine) { + const geometry = linkLine.geometry as THREE.BufferGeometry; + const requiredLength = visibleLinks.length * 6; + if (linkPositionBufferRef.current.length < requiredLength) { + linkPositionBufferRef.current = growFloat32Buffer( + linkPositionBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(linkPositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const highlightLine = highlightLineRef.current; + if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) { + highlightPositionBufferRef.current = growFloat32Buffer( + highlightPositionBufferRef.current, + requiredLength + ); + (highlightLine.geometry as THREE.BufferGeometry).setAttribute( + 'position', + new THREE.BufferAttribute(highlightPositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const positions = linkPositionBufferRef.current; + const hlPositions = highlightPositionBufferRef.current; + let idx = 0; + let hlIdx = 0; + + for (const link of visibleLinks) { + const { sourceId, targetId } = getLinkId(link); + const sNode = nodes.get(sourceId); + const tNode = nodes.get(targetId); + if (!sNode || !tNode) continue; + + const sx = sNode.x ?? 0; + const sy = sNode.y ?? 0; + const sz = sNode.z ?? 0; + const tx = tNode.x ?? 0; + const ty = tNode.y ?? 0; + const tz = tNode.z ?? 0; + + positions[idx++] = sx; + positions[idx++] = sy; + positions[idx++] = sz; + positions[idx++] = tx; + positions[idx++] = ty; + positions[idx++] = tz; + + if (activeId && (sourceId === activeId || targetId === activeId)) { + connectedIds?.add(sourceId === activeId ? targetId : sourceId); + hlPositions[hlIdx++] = sx; + hlPositions[hlIdx++] = sy; + hlPositions[hlIdx++] = sz; + hlPositions[hlIdx++] = tx; + hlPositions[hlIdx++] = ty; + hlPositions[hlIdx++] = tz; + } + } + + const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; + if (positionAttr) { + positionAttr.needsUpdate = true; + } + geometry.setDrawRange(0, idx / 3); + linkLine.visible = idx > 0; + + if (highlightLine) { + const hlGeometry = highlightLine.geometry as THREE.BufferGeometry; + const hlAttr = hlGeometry.getAttribute('position') as THREE.BufferAttribute | undefined; + if (hlAttr) { + hlAttr.needsUpdate = true; + } + hlGeometry.setDrawRange(0, hlIdx / 3); + highlightLine.visible = hlIdx > 0; + } + } + + let writeIdx = 0; + for (let readIdx = 0; readIdx < particles.length; readIdx++) { + const particle = particles[readIdx]; + particle.progress += particle.speed; + if (particle.progress <= 1) { + particles[writeIdx++] = particle; + } + } + particles.length = writeIdx; + + const particlePoints = particlePointsRef.current; + if (particlePoints) { + const geometry = particlePoints.geometry as THREE.BufferGeometry; + const requiredLength = particles.length * 3; + + if (particlePositionBufferRef.current.length < requiredLength) { + particlePositionBufferRef.current = growFloat32Buffer( + particlePositionBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(particlePositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + if (particleColorBufferRef.current.length < requiredLength) { + particleColorBufferRef.current = growFloat32Buffer( + particleColorBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'color', + new THREE.BufferAttribute(particleColorBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const pPositions = particlePositionBufferRef.current; + const pColors = particleColorBufferRef.current; + const color = new THREE.Color(); + let visibleCount = 0; + + for (const p of particles) { + if (p.progress < 0) continue; + if (!currentNodeIds.has(p.fromNodeId) || !currentNodeIds.has(p.toNodeId)) continue; + + const fromNode = nodes.get(p.fromNodeId); + const toNode = nodes.get(p.toNodeId); + if (!fromNode || !toNode) continue; + + const t = p.progress; + const x = (fromNode.x ?? 0) + ((toNode.x ?? 0) - (fromNode.x ?? 0)) * t; + const y = (fromNode.y ?? 0) + ((toNode.y ?? 0) - (fromNode.y ?? 0)) * t; + const z = (fromNode.z ?? 0) + ((toNode.z ?? 0) - (fromNode.z ?? 0)) * t; + + pPositions[visibleCount * 3] = x; + pPositions[visibleCount * 3 + 1] = y; + pPositions[visibleCount * 3 + 2] = z; + + color.set(p.color); + pColors[visibleCount * 3] = color.r; + pColors[visibleCount * 3 + 1] = color.g; + pColors[visibleCount * 3 + 2] = color.b; + visibleCount++; + } + + const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; + const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute | undefined; + if (posAttr) posAttr.needsUpdate = true; + if (colorAttr) colorAttr.needsUpdate = true; + geometry.setDrawRange(0, visibleCount); + particlePoints.visible = visibleCount > 0; + } + + const nextNeighbors = connectedIds + ? Array.from(connectedIds) + .filter((id) => id !== activeId) + .sort() + : []; + if (!arraysEqual(hoveredNeighborIdsRef.current, nextNeighbors)) { + hoveredNeighborIdsRef.current = nextNeighbors; + setHoveredNeighborIds(nextNeighbors); + } + + for (const [id, nd] of nodeMeshesRef.current) { + const node = nodes.get(id); + if (!node) continue; + const mat = nd.mesh.material as THREE.MeshBasicMaterial; + if (id === activeId) { + mat.color.set(0xffd700); + } else if (connectedIds?.has(id)) { + mat.color.set(0xfff0b3); + } else { + mat.color.set(getBaseNodeColor(node)); + } + } + + renderer.render(scene, camera); + cssRenderer.render(scene, camera); + }; + + animate(); + return () => { + running = false; + }; + }, []); + + return { hoveredNodeId, hoveredNeighborIds, pinnedNodeId }; +} diff --git a/frontend/src/components/visualizer/useVisualizerData3D.ts b/frontend/src/components/visualizer/useVisualizerData3D.ts new file mode 100644 index 0000000..8f415e2 --- /dev/null +++ b/frontend/src/components/visualizer/useVisualizerData3D.ts @@ -0,0 +1,922 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + forceCenter, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + forceZ, + type ForceLink3D, + type Simulation3D, +} from 'd3-force-3d'; +import { PayloadType } from '@michaelhart/meshcore-decoder'; + +import { + CONTACT_TYPE_REPEATER, + type Contact, + type ContactAdvertPathSummary, + type RadioConfig, + type RawPacket, +} from '../../types'; +import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity'; +import { + type Particle, + type PendingPacket, + type RepeaterTrafficData, + PARTICLE_COLOR_MAP, + PARTICLE_SPEED, + analyzeRepeaterTraffic, + buildAmbiguousRepeaterLabel, + buildAmbiguousRepeaterNodeId, + dedupeConsecutive, + generatePacketKey, + getNodeType, + getPacketLabel, + parsePacket, + recordTrafficObservation, +} from '../../utils/visualizerUtils'; +import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared'; + +export interface UseVisualizerData3DOptions { + packets: RawPacket[]; + contacts: Contact[]; + config: RadioConfig | null; + repeaterAdvertPaths: ContactAdvertPathSummary[]; + showAmbiguousPaths: boolean; + showAmbiguousNodes: boolean; + useAdvertPathHints: boolean; + splitAmbiguousByTraffic: boolean; + chargeStrength: number; + letEmDrift: boolean; + particleSpeedMultiplier: number; + observationWindowSec: number; + pruneStaleNodes: boolean; + pruneStaleMinutes: number; +} + +export interface VisualizerData3D { + nodes: Map; + links: Map; + particles: Particle[]; + stats: { processed: number; animated: number; nodes: number; links: number }; + expandContract: () => void; + clearAndReset: () => void; +} + +export function useVisualizerData3D({ + packets, + contacts, + config, + repeaterAdvertPaths, + showAmbiguousPaths, + showAmbiguousNodes, + useAdvertPathHints, + splitAmbiguousByTraffic, + chargeStrength, + letEmDrift, + particleSpeedMultiplier, + observationWindowSec, + pruneStaleNodes, + pruneStaleMinutes, +}: UseVisualizerData3DOptions): VisualizerData3D { + const nodesRef = useRef>(new Map()); + const linksRef = useRef>(new Map()); + const particlesRef = useRef([]); + const simulationRef = useRef | null>(null); + const processedRef = useRef>(new Set()); + const pendingRef = useRef>(new Map()); + const timersRef = useRef>>(new Map()); + const trafficPatternsRef = useRef>(new Map()); + const speedMultiplierRef = useRef(particleSpeedMultiplier); + const observationWindowRef = useRef(observationWindowSec * 1000); + const stretchRafRef = useRef(null); + const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 }); + + const contactIndex = useMemo(() => { + const byPrefix12 = new Map(); + const byName = new Map(); + const byPrefix = new Map(); + + for (const contact of contacts) { + const prefix12 = contact.public_key.slice(0, 12).toLowerCase(); + byPrefix12.set(prefix12, contact); + + if (contact.name && !byName.has(contact.name)) { + byName.set(contact.name, contact); + } + + for (let len = 1; len <= 12; len++) { + const prefix = prefix12.slice(0, len); + const matches = byPrefix.get(prefix); + if (matches) { + matches.push(contact); + } else { + byPrefix.set(prefix, [contact]); + } + } + } + + return { byPrefix12, byName, byPrefix }; + }, [contacts]); + + const advertPathIndex = useMemo(() => { + const byRepeater = new Map(); + for (const summary of repeaterAdvertPaths) { + const key = summary.public_key.slice(0, 12).toLowerCase(); + byRepeater.set(key, summary.paths); + } + return { byRepeater }; + }, [repeaterAdvertPaths]); + + useEffect(() => { + speedMultiplierRef.current = particleSpeedMultiplier; + }, [particleSpeedMultiplier]); + + useEffect(() => { + observationWindowRef.current = observationWindowSec * 1000; + }, [observationWindowSec]); + + useEffect(() => { + const sim = forceSimulation([]) + .numDimensions(3) + .force( + 'link', + forceLink([]) + .id((d) => d.id) + .distance(120) + .strength(0.3) + ) + .force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? -1200 : -200)) + .distanceMax(800) + ) + .force('center', forceCenter(0, 0, 0)) + .force( + 'selfX', + forceX(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .force( + 'selfY', + forceY(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .force( + 'selfZ', + forceZ(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .alphaDecay(0.02) + .velocityDecay(0.5) + .alphaTarget(0.03); + + simulationRef.current = sim; + return () => { + sim.stop(); + }; + }, []); + + useEffect(() => { + const sim = simulationRef.current; + if (!sim) return; + + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)) + .distanceMax(800) + ); + sim.alpha(0.3).restart(); + }, [chargeStrength]); + + useEffect(() => { + const sim = simulationRef.current; + if (!sim) return; + sim.alphaTarget(letEmDrift ? 0.05 : 0); + }, [letEmDrift]); + + const syncSimulation = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + const nodes = Array.from(nodesRef.current.values()); + const links = Array.from(linksRef.current.values()); + + sim.nodes(nodes); + const linkForce = sim.force('link') as ForceLink3D | undefined; + linkForce?.links(links); + + sim.alpha(0.15).restart(); + + setStats((prev) => + prev.nodes === nodes.length && prev.links === links.length + ? prev + : { ...prev, nodes: nodes.length, links: links.length } + ); + }, []); + + useEffect(() => { + if (!nodesRef.current.has('self')) { + nodesRef.current.set('self', { + id: 'self', + name: config?.name || 'Me', + type: 'self', + isAmbiguous: false, + lastActivity: Date.now(), + x: 0, + y: 0, + z: 0, + }); + syncSimulation(); + } + }, [config, syncSimulation]); + + useEffect(() => { + processedRef.current.clear(); + const selfNode = nodesRef.current.get('self'); + nodesRef.current.clear(); + if (selfNode) nodesRef.current.set('self', selfNode); + linksRef.current.clear(); + particlesRef.current = []; + pendingRef.current.clear(); + timersRef.current.forEach((t) => clearTimeout(t)); + timersRef.current.clear(); + trafficPatternsRef.current.clear(); + setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); + syncSimulation(); + }, [ + showAmbiguousPaths, + showAmbiguousNodes, + useAdvertPathHints, + splitAmbiguousByTraffic, + syncSimulation, + ]); + + const addNode = useCallback( + ( + id: string, + name: string | null, + type: GraphNode['type'], + isAmbiguous: boolean, + probableIdentity?: string | null, + ambiguousNames?: string[], + lastSeen?: number | null, + activityAtMs?: number + ) => { + const activityAt = activityAtMs ?? Date.now(); + const existing = nodesRef.current.get(id); + if (existing) { + existing.lastActivity = Math.max(existing.lastActivity, activityAt); + if (name) existing.name = name; + if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity; + if (ambiguousNames) existing.ambiguousNames = ambiguousNames; + if (lastSeen !== undefined) existing.lastSeen = lastSeen; + } else { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = 80 + Math.random() * 100; + nodesRef.current.set(id, { + id, + name, + type, + isAmbiguous, + lastActivity: activityAt, + probableIdentity, + lastSeen, + ambiguousNames, + x: r * Math.sin(phi) * Math.cos(theta), + y: r * Math.sin(phi) * Math.sin(theta), + z: r * Math.cos(phi), + }); + } + }, + [] + ); + + const addLink = useCallback((sourceId: string, targetId: string, activityAtMs?: number) => { + const activityAt = activityAtMs ?? Date.now(); + const key = [sourceId, targetId].sort().join('->'); + const existing = linksRef.current.get(key); + if (existing) { + existing.lastActivity = Math.max(existing.lastActivity, activityAt); + } else { + linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt }); + } + }, []); + + const publishPacket = useCallback((packetKey: string) => { + const pending = pendingRef.current.get(packetKey); + if (!pending) return; + + pendingRef.current.delete(packetKey); + timersRef.current.delete(packetKey); + + if (document.hidden) return; + + for (const path of pending.paths) { + const dedupedPath = dedupeConsecutive(path.nodes); + if (dedupedPath.length < 2) continue; + + for (let i = 0; i < dedupedPath.length - 1; i++) { + particlesRef.current.push({ + linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'), + progress: -i, + speed: PARTICLE_SPEED * speedMultiplierRef.current, + color: PARTICLE_COLOR_MAP[pending.label], + label: pending.label, + fromNodeId: dedupedPath[i], + toNodeId: dedupedPath[i + 1], + }); + } + } + }, []); + + const pickLikelyRepeaterByAdvertPath = useCallback( + (candidates: Contact[], nextPrefix: string | null) => { + const nextHop = nextPrefix?.toLowerCase() ?? null; + const scored = candidates + .map((candidate) => { + const prefix12 = candidate.public_key.slice(0, 12).toLowerCase(); + const paths = advertPathIndex.byRepeater.get(prefix12) ?? []; + let matchScore = 0; + let totalScore = 0; + + for (const path of paths) { + totalScore += path.heard_count; + const pathNextHop = path.next_hop?.toLowerCase() ?? null; + if (pathNextHop === nextHop) { + matchScore += path.heard_count; + } + } + + return { candidate, matchScore, totalScore }; + }) + .filter((entry) => entry.totalScore > 0) + .sort( + (a, b) => + b.matchScore - a.matchScore || + b.totalScore - a.totalScore || + a.candidate.public_key.localeCompare(b.candidate.public_key) + ); + + if (scored.length === 0) return null; + + const top = scored[0]; + const second = scored[1] ?? null; + + if (top.matchScore < 2) return null; + if (second && top.matchScore < second.matchScore * 2) return null; + + return top.candidate; + }, + [advertPathIndex] + ); + + const resolveNode = useCallback( + ( + source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, + isRepeater: boolean, + showAmbiguous: boolean, + myPrefix: string | null, + activityAtMs: number, + trafficContext?: { packetSource: string | null; nextPrefix: string | null } + ): string | null => { + if (source.type === 'pubkey') { + if (source.value.length < 12) return null; + const nodeId = source.value.slice(0, 12).toLowerCase(); + if (myPrefix && nodeId === myPrefix) return 'self'; + const contact = contactIndex.byPrefix12.get(nodeId); + addNode( + nodeId, + contact?.name || null, + getNodeType(contact), + false, + undefined, + undefined, + contact?.last_seen, + activityAtMs + ); + return nodeId; + } + + if (source.type === 'name') { + const contact = contactIndex.byName.get(source.value) ?? null; + if (contact) { + const nodeId = contact.public_key.slice(0, 12).toLowerCase(); + if (myPrefix && nodeId === myPrefix) return 'self'; + addNode( + nodeId, + contact.name, + getNodeType(contact), + false, + undefined, + undefined, + contact.last_seen, + activityAtMs + ); + return nodeId; + } + const nodeId = `name:${source.value}`; + addNode( + nodeId, + source.value, + 'client', + false, + undefined, + undefined, + undefined, + activityAtMs + ); + return nodeId; + } + + const lookupValue = source.value.toLowerCase(); + const matches = contactIndex.byPrefix.get(lookupValue) ?? []; + const contact = matches.length === 1 ? matches[0] : null; + if (contact) { + const nodeId = contact.public_key.slice(0, 12).toLowerCase(); + if (myPrefix && nodeId === myPrefix) return 'self'; + addNode( + nodeId, + contact.name, + getNodeType(contact), + false, + undefined, + undefined, + contact.last_seen, + activityAtMs + ); + return nodeId; + } + + if (showAmbiguous) { + const filtered = isRepeater + ? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER) + : matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER); + + if (filtered.length === 1) { + const c = filtered[0]; + const nodeId = c.public_key.slice(0, 12).toLowerCase(); + addNode( + nodeId, + c.name, + getNodeType(c), + false, + undefined, + undefined, + c.last_seen, + activityAtMs + ); + return nodeId; + } + + if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) { + const names = filtered.map((c) => c.name || c.public_key.slice(0, 8)); + const lastSeen = filtered.reduce( + (max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max), + null as number | null + ); + + let nodeId = buildAmbiguousRepeaterNodeId(lookupValue); + let displayName = buildAmbiguousRepeaterLabel(lookupValue); + let probableIdentity: string | null = null; + let ambiguousNames = names.length > 0 ? names : undefined; + + if (useAdvertPathHints && isRepeater && trafficContext) { + const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; + const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext); + if (likely) { + const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase(); + probableIdentity = likelyName; + displayName = likelyName; + ambiguousNames = filtered + .filter((c) => c.public_key !== likely.public_key) + .map((c) => c.name || c.public_key.slice(0, 8)); + } + } + + if (splitAmbiguousByTraffic && isRepeater && trafficContext) { + const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; + + if (trafficContext.packetSource) { + recordTrafficObservation( + trafficPatternsRef.current, + lookupValue, + trafficContext.packetSource, + normalizedNext + ); + } + + const trafficData = trafficPatternsRef.current.get(lookupValue); + if (trafficData) { + const analysis = analyzeRepeaterTraffic(trafficData); + if (analysis.shouldSplit && normalizedNext) { + nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext); + if (!probableIdentity) { + displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext); + } + } + } + } + + addNode( + nodeId, + displayName, + isRepeater ? 'repeater' : 'client', + true, + probableIdentity, + ambiguousNames, + lastSeen, + activityAtMs + ); + return nodeId; + } + } + + return null; + }, + [ + contactIndex, + addNode, + useAdvertPathHints, + pickLikelyRepeaterByAdvertPath, + splitAmbiguousByTraffic, + ] + ); + + const buildPath = useCallback( + ( + parsed: ReturnType, + packet: RawPacket, + myPrefix: string | null, + activityAtMs: number + ): string[] => { + if (!parsed) return []; + const path: string[] = []; + let packetSource: string | null = null; + + if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { + const nodeId = resolveNode( + { type: 'pubkey', value: parsed.advertPubkey }, + false, + false, + myPrefix, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { + const nodeId = resolveNode( + { type: 'pubkey', value: parsed.anonRequestPubkey }, + false, + false, + myPrefix, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { + if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { + path.push('self'); + packetSource = 'self'; + } else { + const nodeId = resolveNode( + { type: 'prefix', value: parsed.srcHash }, + false, + showAmbiguousNodes, + myPrefix, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } + } else if (parsed.payloadType === PayloadType.GroupText) { + const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; + if (senderName) { + const resolved = resolveNode( + { type: 'name', value: senderName }, + false, + false, + myPrefix, + activityAtMs + ); + if (resolved) { + path.push(resolved); + packetSource = resolved; + } + } + } + + for (let i = 0; i < parsed.pathBytes.length; i++) { + const hexPrefix = parsed.pathBytes[i]; + const nextPrefix = parsed.pathBytes[i + 1] || null; + const nodeId = resolveNode( + { type: 'prefix', value: hexPrefix }, + true, + showAmbiguousPaths, + myPrefix, + activityAtMs, + { packetSource, nextPrefix } + ); + if (nodeId) path.push(nodeId); + } + + if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { + if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { + path.push('self'); + } else { + const nodeId = resolveNode( + { type: 'prefix', value: parsed.dstHash }, + false, + showAmbiguousNodes, + myPrefix, + activityAtMs + ); + if (nodeId) path.push(nodeId); + else path.push('self'); + } + } else if (path.length > 0) { + path.push('self'); + } + + if (path.length > 0 && path[path.length - 1] !== 'self') { + path.push('self'); + } + + return dedupeConsecutive(path); + }, + [resolveNode, showAmbiguousPaths, showAmbiguousNodes] + ); + + useEffect(() => { + let newProcessed = 0; + let newAnimated = 0; + let needsUpdate = false; + const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; + + for (const packet of packets) { + const observationKey = getRawPacketObservationKey(packet); + if (processedRef.current.has(observationKey)) continue; + processedRef.current.add(observationKey); + newProcessed++; + + if (processedRef.current.size > 1000) { + processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); + } + + const parsed = parsePacket(packet.data); + if (!parsed) continue; + + const packetActivityAt = normalizePacketTimestampMs(packet.timestamp); + const path = buildPath(parsed, packet, myPrefix, packetActivityAt); + if (path.length < 2) continue; + + const label = getPacketLabel(parsed.payloadType); + for (let i = 0; i < path.length; i++) { + const n = nodesRef.current.get(path[i]); + if (n && n.id !== 'self') { + n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`; + } + } + + for (let i = 0; i < path.length - 1; i++) { + if (path[i] !== path[i + 1]) { + addLink(path[i], path[i + 1], packetActivityAt); + needsUpdate = true; + } + } + + const packetKey = generatePacketKey(parsed, packet); + const now = Date.now(); + const existing = pendingRef.current.get(packetKey); + + if (existing && now < existing.expiresAt) { + existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now }); + } else { + const existingTimer = timersRef.current.get(packetKey); + if (existingTimer) { + clearTimeout(existingTimer); + } + const windowMs = observationWindowRef.current; + pendingRef.current.set(packetKey, { + key: packetKey, + label: getPacketLabel(parsed.payloadType), + paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }], + firstSeen: now, + expiresAt: now + windowMs, + }); + timersRef.current.set( + packetKey, + setTimeout(() => publishPacket(packetKey), windowMs) + ); + } + + if (pendingRef.current.size > 100) { + const entries = Array.from(pendingRef.current.entries()) + .sort((a, b) => a[1].firstSeen - b[1].firstSeen) + .slice(0, 50); + for (const [key] of entries) { + const timer = timersRef.current.get(key); + if (timer) { + clearTimeout(timer); + } + timersRef.current.delete(key); + pendingRef.current.delete(key); + } + } + + newAnimated++; + } + + if (needsUpdate) syncSimulation(); + if (newProcessed > 0) { + setStats((prev) => ({ + ...prev, + processed: prev.processed + newProcessed, + animated: prev.animated + newAnimated, + })); + } + }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); + + const expandContract = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + stretchRafRef.current = null; + } + + const startChargeStrength = chargeStrength; + const peakChargeStrength = -5000; + const startLinkStrength = 0.3; + const minLinkStrength = 0.02; + const expandDuration = 1000; + const holdDuration = 2000; + const contractDuration = 1000; + const startTime = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startTime; + let currentChargeStrength: number; + let currentLinkStrength: number; + + if (elapsed < expandDuration) { + const t = elapsed / expandDuration; + currentChargeStrength = + startChargeStrength + (peakChargeStrength - startChargeStrength) * t; + currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t; + } else if (elapsed < expandDuration + holdDuration) { + currentChargeStrength = peakChargeStrength; + currentLinkStrength = minLinkStrength; + } else if (elapsed < expandDuration + holdDuration + contractDuration) { + const t = (elapsed - expandDuration - holdDuration) / contractDuration; + currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t; + currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t; + } else { + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength)) + .distanceMax(800) + ); + sim.force( + 'link', + forceLink(Array.from(linksRef.current.values())) + .id((d) => d.id) + .distance(120) + .strength(startLinkStrength) + ); + sim.alpha(0.3).restart(); + stretchRafRef.current = null; + return; + } + + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength)) + .distanceMax(800) + ); + sim.force( + 'link', + forceLink(Array.from(linksRef.current.values())) + .id((d) => d.id) + .distance(120) + .strength(currentLinkStrength) + ); + sim.alpha(0.5).restart(); + + stretchRafRef.current = requestAnimationFrame(animate); + }; + + stretchRafRef.current = requestAnimationFrame(animate); + }, [chargeStrength]); + + const clearAndReset = useCallback(() => { + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + stretchRafRef.current = null; + } + + for (const timer of timersRef.current.values()) { + clearTimeout(timer); + } + timersRef.current.clear(); + pendingRef.current.clear(); + processedRef.current.clear(); + trafficPatternsRef.current.clear(); + particlesRef.current.length = 0; + linksRef.current.clear(); + + const selfNode = nodesRef.current.get('self'); + nodesRef.current.clear(); + if (selfNode) { + selfNode.x = 0; + selfNode.y = 0; + selfNode.z = 0; + selfNode.vx = 0; + selfNode.vy = 0; + selfNode.vz = 0; + selfNode.lastActivity = Date.now(); + nodesRef.current.set('self', selfNode); + } + + const sim = simulationRef.current; + if (sim) { + sim.nodes(Array.from(nodesRef.current.values())); + const linkForce = sim.force('link') as ForceLink3D | undefined; + linkForce?.links([]); + sim.alpha(0.3).restart(); + } + + setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); + }, []); + + useEffect(() => { + const stretchRaf = stretchRafRef; + const timers = timersRef.current; + const pending = pendingRef.current; + return () => { + if (stretchRaf.current !== null) { + cancelAnimationFrame(stretchRaf.current); + } + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + pending.clear(); + }; + }, []); + + useEffect(() => { + if (!pruneStaleNodes) return; + + const staleMs = pruneStaleMinutes * 60 * 1000; + const pruneIntervalMs = 1000; + + const interval = setInterval(() => { + const cutoff = Date.now() - staleMs; + let pruned = false; + + for (const [id, node] of nodesRef.current) { + if (id === 'self') continue; + if (node.lastActivity < cutoff) { + nodesRef.current.delete(id); + pruned = true; + } + } + + if (pruned) { + for (const [key, link] of linksRef.current) { + const sourceId = typeof link.source === 'string' ? link.source : link.source.id; + const targetId = typeof link.target === 'string' ? link.target : link.target.id; + if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) { + linksRef.current.delete(key); + } + } + syncSimulation(); + } + }, pruneIntervalMs); + + return () => clearInterval(interval); + }, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]); + + return useMemo( + () => ({ + nodes: nodesRef.current, + links: linksRef.current, + particles: particlesRef.current, + stats, + expandContract, + clearAndReset, + }), + [stats, expandContract, clearAndReset] + ); +} diff --git a/frontend/src/test/visualizerTooltip.test.tsx b/frontend/src/test/visualizerTooltip.test.tsx new file mode 100644 index 0000000..ed45851 --- /dev/null +++ b/frontend/src/test/visualizerTooltip.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { VisualizerTooltip } from '../components/visualizer/VisualizerTooltip'; +import type { GraphNode } from '../components/visualizer/shared'; + +function createNode(overrides: Partial & Pick): GraphNode { + return { + id: overrides.id, + type: overrides.type, + name: overrides.name ?? null, + isAmbiguous: overrides.isAmbiguous ?? false, + lastActivity: overrides.lastActivity ?? Date.now(), + x: overrides.x ?? 0, + y: overrides.y ?? 0, + z: overrides.z ?? 0, + probableIdentity: overrides.probableIdentity, + ambiguousNames: overrides.ambiguousNames, + lastActivityReason: overrides.lastActivityReason, + }; +} + +describe('VisualizerTooltip', () => { + it('renders nothing without an active node', () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders ambiguous node details and neighbors', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-10T22:00:00Z')); + + const node = createNode({ + id: '?32', + type: 'repeater', + name: 'Likely Relay', + isAmbiguous: true, + probableIdentity: 'Likely Relay', + ambiguousNames: ['Relay A', 'Relay B'], + lastActivity: new Date('2026-03-10T21:58:30Z').getTime(), + lastActivityReason: 'Relayed GT', + }); + const neighbor = createNode({ + id: 'abcd1234ef56', + type: 'client', + name: 'Neighbor Node', + ambiguousNames: ['Alt Neighbor'], + }); + + render( + + ); + + expect(screen.getByText('Likely Relay')).toBeInTheDocument(); + expect(screen.getByText('ID: ?32')).toBeInTheDocument(); + expect(screen.getByText('Type: repeater (ambiguous)')).toBeInTheDocument(); + expect(screen.getByText('Probably: Likely Relay')).toBeInTheDocument(); + expect(screen.getByText('Other possible: Relay A, Relay B')).toBeInTheDocument(); + expect(screen.getByText('Last active: 1m 30s ago')).toBeInTheDocument(); + expect(screen.getByText('Reason: Relayed GT')).toBeInTheDocument(); + expect(screen.getByText('Neighbor Node')).toBeInTheDocument(); + expect(screen.getByText('(Alt Neighbor)')).toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); From 81bdfe09fa6fde1ee416fee4cd2cbb4f0a7d6207 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:07:34 -0700 Subject: [PATCH 18/27] extract radio runtime seam --- AGENTS.md | 7 +- app/AGENTS.md | 4 +- app/dependencies.py | 7 +- app/routers/channels.py | 2 +- app/routers/contacts.py | 2 +- app/routers/health.py | 4 +- app/routers/messages.py | 2 +- app/routers/radio.py | 21 ++++-- app/routers/read_state.py | 2 +- app/routers/repeaters.py | 2 +- app/routers/settings.py | 2 +- app/routers/ws.py | 2 +- app/services/radio_runtime.py | 100 ++++++++++++++++++++++++++++ tests/test_radio_runtime_service.py | 75 +++++++++++++++++++++ 14 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 app/services/radio_runtime.py create mode 100644 tests/test_radio_runtime_service.py diff --git a/AGENTS.md b/AGENTS.md index 7247ef5..1b02d8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,8 +48,9 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific │ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │ │ ↓ │ ┌───────────┐ │ │ ┌──────────────────────────┐ └──────────────→ │ WebSocket │ │ -│ │ RadioManager + lifecycle │ │ Manager │ │ -│ │ / event adapters │ └───────────┘ │ +│ │ Radio runtime seam + │ │ Manager │ │ +│ │ RadioManager lifecycle │ └───────────┘ │ +│ │ / event adapters │ │ │ └──────────────────────────┘ │ └───────────────────────────┼──────────────────────────────────────┘ │ Serial / TCP / BLE @@ -163,7 +164,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ ├── AGENTS.md # Backend documentation │ ├── main.py # App entry, lifespan │ ├── routers/ # API endpoints -│ ├── services/ # Shared backend orchestration/domain services +│ ├── services/ # Shared backend orchestration/domain services, including radio_runtime access seam │ ├── packet_processor.py # Raw packet pipeline, dedup, path handling │ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout) │ ├── event_handlers.py # Radio events diff --git a/app/AGENTS.md b/app/AGENTS.md index 6455aae..435ea22 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -27,7 +27,8 @@ app/ │ ├── dm_ack_tracker.py # Pending DM ACK state │ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring │ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers -│ └── radio_commands.py # Radio config/private-key command workflows +│ ├── radio_commands.py # Radio config/private-key command workflows +│ └── radio_runtime.py # Router/dependency seam over the global RadioManager ├── radio.py # RadioManager transport/session state + lock management ├── radio_sync.py # Polling, sync, periodic advertisement loop ├── decoder.py # Packet parsing/decryption @@ -76,6 +77,7 @@ app/ - `RadioManager.start_connection_monitor()` checks health every 5s. - `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`. +- Routers and shared dependencies should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. - Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state. - Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks. diff --git a/app/dependencies.py b/app/dependencies.py index b6af89d..a0bbcd2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -2,7 +2,7 @@ from fastapi import HTTPException -from app.radio import radio_manager +from app.services.radio_runtime import radio_runtime as radio_manager def require_connected(): @@ -12,6 +12,7 @@ def require_connected(): """ if getattr(radio_manager, "is_setup_in_progress", False) is True: raise HTTPException(status_code=503, detail="Radio is initializing") - if not radio_manager.is_connected or radio_manager.meshcore is None: + mc = getattr(radio_manager, "meshcore", None) + if not getattr(radio_manager, "is_connected", False) or mc is None: raise HTTPException(status_code=503, detail="Radio not connected") - return radio_manager.meshcore + return mc diff --git a/app/routers/channels.py b/app/routers/channels.py index f9ea589..807318a 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -7,10 +7,10 @@ from pydantic import BaseModel, Field from app.dependencies import require_connected from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender -from app.radio import radio_manager from app.radio_sync import upsert_channel_from_radio_slot from app.region_scope import normalize_region_scope from app.repository import ChannelRepository, MessageRepository +from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_event logger = logging.getLogger(__name__) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index ad1de33..c62f16f 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -18,7 +18,6 @@ from app.models import ( ) from app.packet_processor import start_historical_dm_decryption from app.path_utils import parse_explicit_hop_route -from app.radio import radio_manager from app.repository import ( AmbiguousPublicKeyPrefixError, ContactAdvertPathRepository, @@ -27,6 +26,7 @@ from app.repository import ( MessageRepository, ) from app.services.contact_reconciliation import reconcile_contact_messages +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) diff --git a/app/routers/health.py b/app/routers/health.py index 30901a7..e757e3f 100644 --- a/app/routers/health.py +++ b/app/routers/health.py @@ -5,8 +5,8 @@ from fastapi import APIRouter from pydantic import BaseModel from app.config import settings -from app.radio import radio_manager from app.repository import RawPacketRepository +from app.services.radio_runtime import radio_runtime as radio_manager router = APIRouter(tags=["health"]) @@ -53,6 +53,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None) setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected) if not isinstance(setup_complete, bool): setup_complete = radio_connected + if not radio_connected: + setup_complete = False radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete)) diff --git a/app/routers/messages.py b/app/routers/messages.py index 588d064..11ea1cf 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -11,13 +11,13 @@ from app.models import ( SendChannelMessageRequest, SendDirectMessageRequest, ) -from app.radio import radio_manager from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository from app.services.message_send import ( resend_channel_message_record, send_channel_message_to_channel, send_direct_message_to_contact, ) +from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_error, broadcast_event logger = logging.getLogger(__name__) diff --git a/app/routers/radio.py b/app/routers/radio.py index c58ea9c..0914cf0 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.dependencies import require_connected -from app.radio import radio_manager from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import sync_radio_time from app.services.radio_commands import ( @@ -14,11 +13,20 @@ from app.services.radio_commands import ( apply_radio_config_update, import_private_key_and_refresh_keystore, ) +from app.services.radio_lifecycle import prepare_connected_radio, reconnect_and_prepare_radio +from app.services.radio_runtime import RadioRuntime +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) +def _unwrap_radio_manager(): + if isinstance(radio_manager, RadioRuntime): + return radio_manager.manager + return radio_manager + + class RadioSettings(BaseModel): freq: float = Field(description="Frequency in MHz") bw: float = Field(description="Bandwidth in kHz") @@ -160,8 +168,6 @@ async def send_advertisement() -> dict: async def _attempt_reconnect() -> dict: """Shared reconnection logic for reboot and reconnect endpoints.""" - from app.services.radio_lifecycle import reconnect_and_prepare_radio - if radio_manager.is_reconnecting: return { "status": "pending", @@ -171,7 +177,7 @@ async def _attempt_reconnect() -> dict: try: success = await reconnect_and_prepare_radio( - radio_manager, + _unwrap_radio_manager(), broadcast_on_success=True, ) except Exception as e: @@ -217,15 +223,16 @@ async def reconnect_radio() -> dict: if no specific port is configured. Useful when the radio has been disconnected or power-cycled. """ - from app.services.radio_lifecycle import prepare_connected_radio - if radio_manager.is_connected: if radio_manager.is_setup_complete: return {"status": "ok", "message": "Already connected", "connected": True} logger.info("Radio connected but setup incomplete, retrying setup") try: - await prepare_connected_radio(radio_manager, broadcast_on_success=True) + await prepare_connected_radio( + _unwrap_radio_manager(), + broadcast_on_success=True, + ) return {"status": "ok", "message": "Setup completed", "connected": True} except Exception as e: logger.exception("Post-connect setup failed") diff --git a/app/routers/read_state.py b/app/routers/read_state.py index 28af10c..c13f8a4 100644 --- a/app/routers/read_state.py +++ b/app/routers/read_state.py @@ -6,13 +6,13 @@ import time from fastapi import APIRouter from app.models import UnreadCounts -from app.radio import radio_manager from app.repository import ( AppSettingsRepository, ChannelRepository, ContactRepository, MessageRepository, ) +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/read-state", tags=["read-state"]) diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index bbb43ae..666cd03 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -25,9 +25,9 @@ from app.models import ( RepeaterRadioSettingsResponse, RepeaterStatusResponse, ) -from app.radio import radio_manager from app.repository import ContactRepository from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 +from app.services.radio_runtime import radio_runtime as radio_manager if TYPE_CHECKING: from meshcore.events import Event diff --git a/app/routers/settings.py b/app/routers/settings.py index a5e053c..2aed40a 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -132,7 +132,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: # Apply flood scope to radio immediately if changed if flood_scope_changed: - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager if radio_manager.is_connected: try: diff --git a/app/routers/ws.py b/app/routers/ws.py index c9eafdb..2494098 100644 --- a/app/routers/ws.py +++ b/app/routers/ws.py @@ -4,8 +4,8 @@ import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from app.radio import radio_manager from app.routers.health import build_health_data +from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import ws_manager logger = logging.getLogger(__name__) diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py new file mode 100644 index 0000000..7061753 --- /dev/null +++ b/app/services/radio_runtime.py @@ -0,0 +1,100 @@ +"""Shared access seam over the global RadioManager instance. + +This module deliberately keeps behavior thin and forwarding-only. The goal is +to reduce direct `app.radio.radio_manager` imports across routers and helpers +without changing radio lifecycle, lock, or connection semantics. +""" + +from collections.abc import Callable +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import HTTPException + +import app.radio as radio_module + + +class RadioRuntime: + """Thin wrapper around the process-global RadioManager.""" + + def __init__(self, manager_or_getter=None): + if manager_or_getter is None: + self._manager_getter: Callable[[], Any] = lambda: radio_module.radio_manager + elif callable(manager_or_getter): + self._manager_getter = manager_or_getter + else: + self._manager_getter = lambda: manager_or_getter + + @property + def manager(self) -> Any: + return self._manager_getter() + + @property + def meshcore(self): + return self.manager.meshcore + + @property + def connection_info(self) -> str | None: + return self.manager.connection_info + + @property + def is_connected(self) -> bool: + return self.manager.is_connected + + @property + def is_reconnecting(self) -> bool: + return self.manager.is_reconnecting + + @property + def is_setup_in_progress(self) -> bool: + return self.manager.is_setup_in_progress + + @property + def is_setup_complete(self) -> bool: + return self.manager.is_setup_complete + + @property + def path_hash_mode(self) -> int: + return self.manager.path_hash_mode + + @path_hash_mode.setter + def path_hash_mode(self, mode: int) -> None: + self.manager.path_hash_mode = mode + + @property + def path_hash_mode_supported(self) -> bool: + return self.manager.path_hash_mode_supported + + @path_hash_mode_supported.setter + def path_hash_mode_supported(self, supported: bool) -> None: + self.manager.path_hash_mode_supported = supported + + def require_connected(self): + """Return MeshCore when available, mirroring existing HTTP semantics.""" + if self.is_setup_in_progress: + raise HTTPException(status_code=503, detail="Radio is initializing") + mc = self.meshcore + if not self.is_connected or mc is None: + raise HTTPException(status_code=503, detail="Radio not connected") + return mc + + @asynccontextmanager + async def radio_operation(self, name: str, **kwargs): + async with self.manager.radio_operation(name, **kwargs) as mc: + yield mc + + async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None: + from app.services.radio_lifecycle import prepare_connected_radio + + await prepare_connected_radio(self.manager, broadcast_on_success=broadcast_on_success) + + async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool: + from app.services.radio_lifecycle import reconnect_and_prepare_radio + + return await reconnect_and_prepare_radio( + self.manager, + broadcast_on_success=broadcast_on_success, + ) + + +radio_runtime = RadioRuntime() diff --git a/tests/test_radio_runtime_service.py b/tests/test_radio_runtime_service.py new file mode 100644 index 0000000..aa53082 --- /dev/null +++ b/tests/test_radio_runtime_service.py @@ -0,0 +1,75 @@ +from contextlib import asynccontextmanager + +import pytest +from fastapi import HTTPException + +from app.services.radio_runtime import RadioRuntime + + +class _Manager: + def __init__( + self, + *, + meshcore=None, + is_connected=False, + is_reconnecting=False, + is_setup_in_progress=False, + is_setup_complete=False, + connection_info=None, + path_hash_mode=0, + path_hash_mode_supported=False, + ): + self.meshcore = meshcore + self.is_connected = is_connected + self.is_reconnecting = is_reconnecting + self.is_setup_in_progress = is_setup_in_progress + self.is_setup_complete = is_setup_complete + self.connection_info = connection_info + self.path_hash_mode = path_hash_mode + self.path_hash_mode_supported = path_hash_mode_supported + self.calls: list[tuple[str, dict]] = [] + + @asynccontextmanager + async def radio_operation(self, name: str, **kwargs): + self.calls.append((name, kwargs)) + yield self.meshcore + + +def test_uses_latest_manager_from_getter(): + first = _Manager(meshcore="mc1", is_connected=True, connection_info="first") + second = _Manager(meshcore="mc2", is_connected=True, connection_info="second") + current = {"manager": first} + runtime = RadioRuntime(lambda: current["manager"]) + + assert runtime.connection_info == "first" + assert runtime.require_connected() == "mc1" + + current["manager"] = second + + assert runtime.connection_info == "second" + assert runtime.require_connected() == "mc2" + + +def test_require_connected_preserves_http_semantics(): + runtime = RadioRuntime( + _Manager(meshcore=None, is_connected=True, is_setup_in_progress=True), + ) + with pytest.raises(HTTPException, match="Radio is initializing") as exc: + runtime.require_connected() + assert exc.value.status_code == 503 + + runtime = RadioRuntime(_Manager(meshcore=None, is_connected=False, is_setup_in_progress=False)) + with pytest.raises(HTTPException, match="Radio not connected") as exc: + runtime.require_connected() + assert exc.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_radio_operation_delegates_to_current_manager(): + manager = _Manager(meshcore="meshcore", is_connected=True) + runtime = RadioRuntime(manager) + + async with runtime.radio_operation("sync_contacts", pause_polling=True) as mc: + assert mc == "meshcore" + + assert manager.calls == [("sync_contacts", {"pause_polling": True})] From 9388e1f50669c6f2f763fd273ed0245f2bf31be9 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:11:57 -0700 Subject: [PATCH 19/27] route startup and fanout through radio runtime --- app/AGENTS.md | 2 +- app/fanout/community_mqtt.py | 12 +++++++----- app/fanout/mqtt_base.py | 2 +- app/fanout/mqtt_community.py | 2 +- app/main.py | 10 +++------- app/services/radio_runtime.py | 9 +++++++++ tests/test_radio_runtime_service.py | 18 ++++++++++++++++++ 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/AGENTS.md b/app/AGENTS.md index 435ea22..d681ef9 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -77,7 +77,7 @@ app/ - `RadioManager.start_connection_monitor()` checks health every 5s. - `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`. -- Routers and shared dependencies should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. +- Routers, startup/lifespan code, and fanout helpers should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. - Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state. - Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks. diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index 67f901f..b4ba38f 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -244,7 +244,7 @@ def _build_radio_info() -> str: Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw values). Falls back to ``"0,0,0,0"`` when unavailable. """ - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager try: if radio_manager.meshcore and radio_manager.meshcore.self_info: @@ -329,7 +329,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): def _build_client_kwargs(self, settings: object) -> dict[str, Any]: s: CommunityMqttSettings = settings # type: ignore[assignment] from app.keystore import get_private_key, get_public_key - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager private_key = get_private_key() public_key = get_public_key() @@ -401,7 +401,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): if self._cached_device_info is not None: return self._cached_device_info - from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager + from app.radio import RadioDisconnectedError, RadioOperationBusyError + from app.services.radio_runtime import radio_runtime as radio_manager fallback = {"model": "unknown", "firmware_version": "unknown"} try: @@ -448,7 +449,8 @@ class CommunityMqttPublisher(BaseMqttPublisher): ) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None: return self._cached_stats - from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager + from app.radio import RadioDisconnectedError, RadioOperationBusyError + from app.services.radio_runtime import radio_runtime as radio_manager try: async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc: @@ -489,7 +491,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): ) -> None: """Build and publish the enriched retained status message.""" from app.keystore import get_public_key - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager public_key = get_public_key() if public_key is None: diff --git a/app/fanout/mqtt_base.py b/app/fanout/mqtt_base.py index 9588839..db8ea6f 100644 --- a/app/fanout/mqtt_base.py +++ b/app/fanout/mqtt_base.py @@ -25,7 +25,7 @@ _BACKOFF_MIN = 5 def _broadcast_health() -> None: """Push updated health (including MQTT status) to all WS clients.""" - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_health broadcast_health(radio_manager.is_connected, radio_manager.connection_info) diff --git a/app/fanout/mqtt_community.py b/app/fanout/mqtt_community.py index da6a0a8..0fea530 100644 --- a/app/fanout/mqtt_community.py +++ b/app/fanout/mqtt_community.py @@ -109,7 +109,7 @@ async def _publish_community_packet( """Format and publish a raw packet to the community broker.""" try: from app.keystore import get_public_key - from app.radio import radio_manager + from app.services.radio_runtime import radio_runtime as radio_manager public_key = get_public_key() if public_key is None: diff --git a/app/main.py b/app/main.py index e174f0c..55f7e80 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse from app.config import setup_logging from app.database import db from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes -from app.radio import RadioDisconnectedError, radio_manager +from app.radio import RadioDisconnectedError from app.radio_sync import ( stop_message_polling, stop_periodic_advert, @@ -30,6 +30,7 @@ from app.routers import ( statistics, ws, ) +from app.services.radio_runtime import radio_runtime as radio_manager setup_logging() logger = logging.getLogger(__name__) @@ -37,13 +38,8 @@ logger = logging.getLogger(__name__) async def _startup_radio_connect_and_setup() -> None: """Connect/setup the radio in the background so HTTP serving can start immediately.""" - from app.services.radio_lifecycle import reconnect_and_prepare_radio - try: - connected = await reconnect_and_prepare_radio( - radio_manager, - broadcast_on_success=True, - ) + connected = await radio_manager.reconnect_and_prepare(broadcast_on_success=True) if connected: logger.info("Connected to radio") else: diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index 7061753..eb10251 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -83,6 +83,15 @@ class RadioRuntime: async with self.manager.radio_operation(name, **kwargs) as mc: yield mc + async def start_connection_monitor(self) -> None: + await self.manager.start_connection_monitor() + + async def stop_connection_monitor(self) -> None: + await self.manager.stop_connection_monitor() + + async def disconnect(self) -> None: + await self.manager.disconnect() + async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None: from app.services.radio_lifecycle import prepare_connected_radio diff --git a/tests/test_radio_runtime_service.py b/tests/test_radio_runtime_service.py index aa53082..8af1c84 100644 --- a/tests/test_radio_runtime_service.py +++ b/tests/test_radio_runtime_service.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +from unittest.mock import AsyncMock import pytest from fastapi import HTTPException @@ -73,3 +74,20 @@ async def test_radio_operation_delegates_to_current_manager(): assert mc == "meshcore" assert manager.calls == [("sync_contacts", {"pause_polling": True})] + + +@pytest.mark.asyncio +async def test_lifecycle_passthrough_methods_delegate_to_current_manager(): + manager = _Manager(meshcore="meshcore", is_connected=True) + manager.start_connection_monitor = AsyncMock() + manager.stop_connection_monitor = AsyncMock() + manager.disconnect = AsyncMock() + runtime = RadioRuntime(manager) + + await runtime.start_connection_monitor() + await runtime.stop_connection_monitor() + await runtime.disconnect() + + manager.start_connection_monitor.assert_awaited_once() + manager.stop_connection_monitor.assert_awaited_once() + manager.disconnect.assert_awaited_once() From def7c8e29e24b5e8ba8168b297e7c7f800d794a2 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:16:17 -0700 Subject: [PATCH 20/27] route radio sync through radio runtime --- app/AGENTS.md | 2 +- app/radio_sync.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/AGENTS.md b/app/AGENTS.md index d681ef9..e44d0f5 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -77,7 +77,7 @@ app/ - `RadioManager.start_connection_monitor()` checks health every 5s. - `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`. -- Routers, startup/lifespan code, and fanout helpers should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. +- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly. - Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state. - Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks. diff --git a/app/radio_sync.py b/app/radio_sync.py index b7b4a47..1fb20cc 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -18,7 +18,7 @@ from meshcore import EventType, MeshCore from app.event_handlers import cleanup_expired_acks from app.models import Contact -from app.radio import RadioOperationBusyError, radio_manager +from app.radio import RadioOperationBusyError from app.repository import ( AmbiguousPublicKeyPrefixError, AppSettingsRepository, @@ -26,6 +26,7 @@ from app.repository import ( ContactRepository, ) from app.services.contact_reconciliation import reconcile_contact_messages +from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) @@ -744,6 +745,7 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None # If caller provided a MeshCore instance, use it directly (caller holds the lock) if mc is not None: _last_contact_sync = now + assert mc is not None return await _sync_contacts_to_radio_inner(mc) if not radio_manager.is_connected or radio_manager.meshcore is None: @@ -756,6 +758,7 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None blocking=False, ) as mc: _last_contact_sync = now + assert mc is not None return await _sync_contacts_to_radio_inner(mc) except RadioOperationBusyError: logger.debug("Skipping contact sync to radio: radio busy") From a000fc88a5fcaf3fbafc77671900380b86e6e10c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:22:56 -0700 Subject: [PATCH 21/27] make radio router use runtime seam only --- app/dependencies.py | 3 +++ app/routers/radio.py | 24 ++++++++++-------------- tests/test_radio_router.py | 19 +++++++++++++------ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index a0bbcd2..af0a8b0 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -2,6 +2,7 @@ from fastapi import HTTPException +from app.services.radio_runtime import RadioRuntime from app.services.radio_runtime import radio_runtime as radio_manager @@ -10,6 +11,8 @@ def require_connected(): Raises HTTPException 503 if radio is not connected. """ + if isinstance(radio_manager, RadioRuntime): + return radio_manager.require_connected() if getattr(radio_manager, "is_setup_in_progress", False) is True: raise HTTPException(status_code=503, detail="Radio is initializing") mc = getattr(radio_manager, "meshcore", None) diff --git a/app/routers/radio.py b/app/routers/radio.py index 0914cf0..4e128a4 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -13,18 +13,20 @@ from app.services.radio_commands import ( apply_radio_config_update, import_private_key_and_refresh_keystore, ) -from app.services.radio_lifecycle import prepare_connected_radio, reconnect_and_prepare_radio -from app.services.radio_runtime import RadioRuntime from app.services.radio_runtime import radio_runtime as radio_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/radio", tags=["radio"]) -def _unwrap_radio_manager(): - if isinstance(radio_manager, RadioRuntime): - return radio_manager.manager - return radio_manager +async def _prepare_connected(*, broadcast_on_success: bool) -> None: + await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success) + + +async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool: + return await radio_manager.reconnect_and_prepare( + broadcast_on_success=broadcast_on_success, + ) class RadioSettings(BaseModel): @@ -176,10 +178,7 @@ async def _attempt_reconnect() -> dict: } try: - success = await reconnect_and_prepare_radio( - _unwrap_radio_manager(), - broadcast_on_success=True, - ) + success = await _reconnect_and_prepare(broadcast_on_success=True) except Exception as e: logger.exception("Post-connect setup failed after reconnect") raise HTTPException( @@ -229,10 +228,7 @@ async def reconnect_radio() -> dict: logger.info("Radio connected but setup incomplete, retrying setup") try: - await prepare_connected_radio( - _unwrap_radio_manager(), - broadcast_on_success=True, - ) + await _prepare_connected(broadcast_on_success=True) return {"status": "ok", "message": "Setup completed", "connected": True} except Exception as e: logger.exception("Post-connect setup failed") diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 095e3d6..9fbbc55 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -22,6 +22,7 @@ from app.routers.radio import ( set_private_key, update_radio_config, ) +from app.services.radio_runtime import RadioRuntime def _radio_result(event_type=EventType.OK, payload=None): @@ -41,6 +42,10 @@ def _noop_radio_operation(mc=None): return _ctx +def _runtime(manager): + return RadioRuntime(lambda: manager) + + @pytest.fixture(autouse=True) def _reset_radio_state(): """Save/restore radio_manager state so tests don't leak.""" @@ -301,7 +306,7 @@ class TestAdvertise: isolated_manager._meshcore = MagicMock() with ( patch("app.routers.radio.require_connected"), - patch("app.routers.radio.radio_manager", isolated_manager), + patch("app.routers.radio.radio_manager", _runtime(isolated_manager)), patch( "app.routers.radio.do_send_advertisement", new_callable=AsyncMock, @@ -324,7 +329,7 @@ class TestRebootAndReconnect: mock_rm.meshcore = mock_mc mock_rm.radio_operation = _noop_radio_operation(mock_mc) - with patch("app.routers.radio.radio_manager", mock_rm): + with patch("app.routers.radio.radio_manager", _runtime(mock_rm)): result = await reboot_radio() assert result["status"] == "ok" @@ -338,7 +343,7 @@ class TestRebootAndReconnect: mock_rm.is_reconnecting = True mock_rm.radio_operation = _noop_radio_operation() - with patch("app.routers.radio.radio_manager", mock_rm): + with patch("app.routers.radio.radio_manager", _runtime(mock_rm)): result = await reboot_radio() assert result["status"] == "pending" @@ -353,8 +358,9 @@ class TestRebootAndReconnect: mock_rm.reconnect = AsyncMock(return_value=True) mock_rm.post_connect_setup = AsyncMock() mock_rm.radio_operation = _noop_radio_operation() + mock_rm.connection_info = "TCP: test:4000" - with patch("app.routers.radio.radio_manager", mock_rm): + with patch("app.routers.radio.radio_manager", _runtime(mock_rm)): result = await reboot_radio() assert result["status"] == "ok" @@ -367,8 +373,9 @@ class TestRebootAndReconnect: mock_rm = MagicMock() mock_rm.is_connected = True mock_rm.radio_operation = _noop_radio_operation() + mock_rm.is_setup_complete = True - with patch("app.routers.radio.radio_manager", mock_rm): + with patch("app.routers.radio.radio_manager", _runtime(mock_rm)): result = await reconnect_radio() assert result["status"] == "ok" @@ -382,7 +389,7 @@ class TestRebootAndReconnect: mock_rm.reconnect = AsyncMock(return_value=False) mock_rm.radio_operation = _noop_radio_operation() - with patch("app.routers.radio.radio_manager", mock_rm): + with patch("app.routers.radio.radio_manager", _runtime(mock_rm)): with pytest.raises(HTTPException) as exc: await reconnect_radio() From 3e941a5b20a19f482f48c70ef147b2e3b48ca0df Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:29:25 -0700 Subject: [PATCH 22/27] remove radio dependency fallback shim --- app/dependencies.py | 17 ++------- tests/test_api.py | 68 ++++++++++++----------------------- tests/test_channels_router.py | 39 +++++++++----------- tests/test_contacts_router.py | 55 ++++++++++------------------ tests/test_radio_operation.py | 35 ++++++++++-------- 5 files changed, 80 insertions(+), 134 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index af0a8b0..d0f6cc2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,21 +1,8 @@ """Shared dependencies for FastAPI routers.""" -from fastapi import HTTPException - -from app.services.radio_runtime import RadioRuntime from app.services.radio_runtime import radio_runtime as radio_manager def require_connected(): - """Dependency that ensures radio is connected and returns meshcore instance. - - Raises HTTPException 503 if radio is not connected. - """ - if isinstance(radio_manager, RadioRuntime): - return radio_manager.require_connected() - if getattr(radio_manager, "is_setup_in_progress", False) is True: - raise HTTPException(status_code=503, detail="Radio is initializing") - mc = getattr(radio_manager, "meshcore", None) - if not getattr(radio_manager, "is_connected", False) or mc is None: - raise HTTPException(status_code=503, detail="Radio not connected") - return mc + """Dependency that ensures radio is connected and returns meshcore instance.""" + return radio_manager.require_connected() diff --git a/tests/test_api.py b/tests/test_api.py index 40ad309..c3dd3c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,6 +9,7 @@ import time from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from app.radio import radio_manager from app.repository import ( @@ -29,6 +30,15 @@ def _reset_radio_state(): radio_manager._operation_lock = prev_lock +def _patch_require_connected(mc=None, *, detail="Radio not connected"): + if mc is None: + return patch( + "app.dependencies.radio_manager.require_connected", + side_effect=HTTPException(status_code=503, detail=detail), + ) + return patch("app.dependencies.radio_manager.require_connected", return_value=mc) + + async def _insert_contact(public_key, name="Alice", **overrides): """Insert a contact into the test database.""" data = { @@ -102,10 +112,7 @@ class TestRadioDisconnectedHandler: # require_connected() passes, but _meshcore is None when radio_operation() checks radio_manager._meshcore = None - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = MagicMock() - + with _patch_require_connected(MagicMock()): response = await client.post( "/api/messages/direct", json={"destination": pub_key, "text": "Hi"} ) @@ -120,10 +127,7 @@ class TestMessagesEndpoint: @pytest.mark.asyncio async def test_send_direct_message_requires_connection(self, test_db, client): """Sending message when disconnected returns 503.""" - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - + with _patch_require_connected(): response = await client.post( "/api/messages/direct", json={"destination": "abc123", "text": "Hello"} ) @@ -134,10 +138,7 @@ class TestMessagesEndpoint: @pytest.mark.asyncio async def test_send_channel_message_requires_connection(self, test_db, client): """Sending channel message when disconnected returns 503.""" - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - + with _patch_require_connected(): response = await client.post( "/api/messages/channel", json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"}, @@ -164,12 +165,9 @@ class TestMessagesEndpoint: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_rm, + _patch_require_connected(mock_mc), patch("app.routers.messages.broadcast_event") as mock_broadcast, ): - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - response = await client.post( "/api/messages/direct", json={"destination": pub_key, "text": "Hello"}, @@ -202,13 +200,10 @@ class TestMessagesEndpoint: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_rm, + _patch_require_connected(mock_mc), patch("app.decoder.calculate_channel_hash", return_value="abcd"), patch("app.routers.messages.broadcast_event") as mock_broadcast, ): - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - response = await client.post( "/api/messages/channel", json={"channel_key": chan_key, "text": "Hello room"}, @@ -226,10 +221,7 @@ class TestMessagesEndpoint: mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix.return_value = None - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post( "/api/messages/direct", json={"destination": "nonexistent", "text": "Hello"} ) @@ -257,11 +249,9 @@ class TestMessagesEndpoint: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_rm, + _patch_require_connected(mock_mc), patch("app.routers.messages.MessageRepository") as mock_msg_repo, ): - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc # Simulate duplicate - create returns None mock_msg_repo.create = AsyncMock(return_value=None) @@ -294,11 +284,9 @@ class TestMessagesEndpoint: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_rm, + _patch_require_connected(mock_mc), patch("app.routers.messages.MessageRepository") as mock_msg_repo, ): - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc # Simulate duplicate - create returns None mock_msg_repo.create = AsyncMock(return_value=None) @@ -315,10 +303,7 @@ class TestMessagesEndpoint: @pytest.mark.asyncio async def test_resend_channel_message_requires_connection(self, test_db, client): """Resend endpoint returns 503 when radio is disconnected.""" - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - + with _patch_require_connected(): response = await client.post("/api/messages/channel/1/resend") assert response.status_code == 503 @@ -353,10 +338,7 @@ class TestMessagesEndpoint: ) radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/messages/channel/{msg_id}/resend") assert response.status_code == 200 @@ -394,10 +376,7 @@ class TestMessagesEndpoint: mock_mc.commands.set_channel = AsyncMock() mock_mc.commands.send_chan_msg = AsyncMock() - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/messages/channel/{msg_id}/resend") assert response.status_code == 400 @@ -414,10 +393,7 @@ class TestMessagesEndpoint: mock_mc.commands.set_channel = AsyncMock() mock_mc.commands.send_chan_msg = AsyncMock() - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post("/api/messages/channel/999999/resend") assert response.status_code == 404 diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index 02aca10..382985c 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -9,6 +9,7 @@ from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from meshcore import EventType from app.radio import radio_manager @@ -55,6 +56,15 @@ def _make_error_response(): return result +def _patch_require_connected(mc=None, *, detail="Radio not connected"): + if mc is None: + return patch( + "app.dependencies.radio_manager.require_connected", + side_effect=HTTPException(status_code=503, detail=detail), + ) + return patch("app.dependencies.radio_manager.require_connected", return_value=mc) + + @asynccontextmanager async def _noop_radio_operation(mc): """No-op radio_operation context manager that yields mc.""" @@ -83,11 +93,9 @@ class TestSyncChannelsFromRadio: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_dep_rm, + _patch_require_connected(mock_mc), patch("app.routers.channels.radio_manager") as mock_ch_rm, ): - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) response = await client.post("/api/channels/sync?max_channels=5") @@ -119,11 +127,9 @@ class TestSyncChannelsFromRadio: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_dep_rm, + _patch_require_connected(mock_mc), patch("app.routers.channels.radio_manager") as mock_ch_rm, ): - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) response = await client.post("/api/channels/sync?max_channels=5") @@ -146,11 +152,9 @@ class TestSyncChannelsFromRadio: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_dep_rm, + _patch_require_connected(mock_mc), patch("app.routers.channels.radio_manager") as mock_ch_rm, ): - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) response = await client.post("/api/channels/sync?max_channels=3") @@ -178,11 +182,9 @@ class TestSyncChannelsFromRadio: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_dep_rm, + _patch_require_connected(mock_mc), patch("app.routers.channels.radio_manager") as mock_ch_rm, ): - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) await client.post("/api/channels/sync?max_channels=3") @@ -193,10 +195,7 @@ class TestSyncChannelsFromRadio: @pytest.mark.asyncio async def test_sync_requires_connection(self, test_db, client): """Sync returns 503 when radio is not connected.""" - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - + with _patch_require_connected(): response = await client.post("/api/channels/sync") assert response.status_code == 503 @@ -216,11 +215,9 @@ class TestSyncChannelsFromRadio: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_dep_rm, + _patch_require_connected(mock_mc), patch("app.routers.channels.radio_manager") as mock_ch_rm, ): - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) await client.post("/api/channels/sync?max_channels=3") @@ -246,11 +243,9 @@ class TestSyncChannelsFromRadio: radio_manager._meshcore = mock_mc with ( - patch("app.dependencies.radio_manager") as mock_dep_rm, + _patch_require_connected(mock_mc), patch("app.routers.channels.radio_manager") as mock_ch_rm, ): - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) response = await client.post("/api/channels/sync?max_channels=3") diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 12d3453..bc5e543 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -10,6 +10,7 @@ from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from meshcore import EventType from app.radio import radio_manager @@ -31,6 +32,15 @@ def _noop_radio_operation(mc=None): return _ctx +def _patch_require_connected(mc=None, *, detail="Radio not connected"): + if mc is None: + return patch( + "app.dependencies.radio_manager.require_connected", + side_effect=HTTPException(status_code=503, detail=detail), + ) + return patch("app.dependencies.radio_manager.require_connected", return_value=mc) + + @pytest.fixture(autouse=True) def _reset_radio_state(): """Save/restore radio_manager state so tests don't leak.""" @@ -505,10 +515,7 @@ class TestSyncContacts: mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post("/api/contacts/sync") assert response.status_code == 200 @@ -521,10 +528,7 @@ class TestSyncContacts: @pytest.mark.asyncio async def test_sync_requires_connection(self, test_db, client): - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - + with _patch_require_connected(): response = await client.post("/api/contacts/sync") assert response.status_code == 503 @@ -547,10 +551,7 @@ class TestSyncContacts: mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post("/api/contacts/sync") assert response.status_code == 200 @@ -771,10 +772,7 @@ class TestAddRemoveRadio: mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 200 @@ -800,10 +798,7 @@ class TestAddRemoveRadio: mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 200 @@ -821,10 +816,7 @@ class TestAddRemoveRadio: mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # On radio radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 200 @@ -842,10 +834,7 @@ class TestAddRemoveRadio: mock_mc.commands.remove_contact = AsyncMock(return_value=mock_result) radio_manager._meshcore = mock_mc - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio") assert response.status_code == 200 @@ -857,10 +846,7 @@ class TestAddRemoveRadio: @pytest.mark.asyncio async def test_add_requires_connection(self, test_db, client): - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = False - mock_rm.meshcore = None - + with _patch_require_connected(): response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 503 @@ -869,10 +855,7 @@ class TestAddRemoveRadio: async def test_remove_not_found(self, test_db, client): mock_mc = MagicMock() - with patch("app.dependencies.radio_manager") as mock_dep_rm: - mock_dep_rm.is_connected = True - mock_dep_rm.meshcore = mock_mc - + with _patch_require_connected(mock_mc): response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio") assert response.status_code == 404 diff --git a/tests/test_radio_operation.py b/tests/test_radio_operation.py index 74be246..db415da 100644 --- a/tests/test_radio_operation.py +++ b/tests/test_radio_operation.py @@ -7,6 +7,11 @@ import pytest from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager from app.radio_sync import is_polling_paused +from app.services.radio_runtime import RadioRuntime + + +def _runtime(manager): + return RadioRuntime(lambda: manager) @pytest.fixture(autouse=True) @@ -180,11 +185,11 @@ class TestRequireConnected: from app.dependencies import require_connected - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_connected = True - mock_rm.meshcore = MagicMock() - mock_rm.is_setup_in_progress = True - + manager = MagicMock() + manager.is_connected = True + manager.meshcore = MagicMock() + manager.is_setup_in_progress = True + with patch("app.dependencies.radio_manager", _runtime(manager)): with pytest.raises(HTTPException) as exc_info: require_connected() @@ -197,11 +202,11 @@ class TestRequireConnected: from app.dependencies import require_connected - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_setup_in_progress = False - mock_rm.is_connected = False - mock_rm.meshcore = None - + manager = MagicMock() + manager.is_setup_in_progress = False + manager.is_connected = False + manager.meshcore = None + with patch("app.dependencies.radio_manager", _runtime(manager)): with pytest.raises(HTTPException) as exc_info: require_connected() @@ -212,11 +217,11 @@ class TestRequireConnected: from app.dependencies import require_connected mock_mc = MagicMock() - with patch("app.dependencies.radio_manager") as mock_rm: - mock_rm.is_setup_in_progress = False - mock_rm.is_connected = True - mock_rm.meshcore = mock_mc - + manager = MagicMock() + manager.is_setup_in_progress = False + manager.is_connected = True + manager.meshcore = mock_mc + with patch("app.dependencies.radio_manager", _runtime(manager)): result = require_connected() assert result is mock_mc From 18e1408292885f3d3d5b945939458dd0539dfe58 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:42:46 -0700 Subject: [PATCH 23/27] Be better about DB insertion shape --- app/event_handlers.py | 19 +++--- app/models.py | 94 +++++++++++++++++++++-------- app/packet_processor.py | 35 ++++++----- app/radio_sync.py | 4 +- app/repository/contacts.py | 49 +++++++++------ app/routers/contacts.py | 46 ++++---------- app/services/radio_runtime.py | 4 +- tests/test_radio_runtime_service.py | 23 +++++++ tests/test_repository.py | 32 ++++++++++ 9 files changed, 202 insertions(+), 104 deletions(-) diff --git a/app/event_handlers.py b/app/event_handlers.py index 173986b..d9361fe 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from meshcore import EventType -from app.models import CONTACT_TYPE_REPEATER, Contact +from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert from app.packet_processor import process_raw_packet from app.repository import ( AmbiguousPublicKeyPrefixError, @@ -228,11 +228,9 @@ async def on_new_contact(event: "Event") -> None: logger.debug("New contact: %s", public_key[:12]) - contact_data = { - **Contact.from_radio_dict(public_key.lower(), payload, on_radio=True), - "last_seen": int(time.time()), - } - await ContactRepository.upsert(contact_data) + contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True) + contact_upsert.last_seen = int(time.time()) + await ContactRepository.upsert(contact_upsert) adv_name = payload.get("adv_name") await record_contact_name_and_reconcile( @@ -245,7 +243,14 @@ async def on_new_contact(event: "Event") -> None: # Read back from DB so the broadcast includes all fields (last_contacted, # last_read_at, etc.) matching the REST Contact shape exactly. db_contact = await ContactRepository.get_by_key(public_key) - broadcast_event("contact", (db_contact.model_dump() if db_contact else contact_data)) + broadcast_event( + "contact", + ( + db_contact.model_dump() + if db_contact + else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump() + ), + ) async def on_ack(event: "Event") -> None: diff --git a/app/models.py b/app/models.py index 8c56f45..be428a0 100644 --- a/app/models.py +++ b/app/models.py @@ -5,6 +5,64 @@ from pydantic import BaseModel, Field from app.path_utils import normalize_contact_route +class ContactUpsert(BaseModel): + """Typed write contract for contacts persisted to SQLite.""" + + public_key: str = Field(description="Public key (64-char hex)") + name: str | None = None + type: int = 0 + flags: int = 0 + last_path: str | None = None + last_path_len: int = -1 + out_path_hash_mode: int | None = None + route_override_path: str | None = None + route_override_len: int | None = None + route_override_hash_mode: int | None = None + last_advert: int | None = None + lat: float | None = None + lon: float | None = None + last_seen: int | None = None + on_radio: bool | None = None + last_contacted: int | None = None + first_seen: int | None = None + + @classmethod + def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert": + return cls.model_validate( + { + **contact.model_dump(exclude={"last_read_at"}), + **changes, + } + ) + + @classmethod + def from_radio_dict( + cls, public_key: str, radio_data: dict, on_radio: bool = False + ) -> "ContactUpsert": + """Convert radio contact data to the contact-row write shape.""" + last_path, last_path_len, out_path_hash_mode = normalize_contact_route( + radio_data.get("out_path"), + radio_data.get("out_path_len", -1), + radio_data.get( + "out_path_hash_mode", + -1 if radio_data.get("out_path_len", -1) == -1 else 0, + ), + ) + return cls( + public_key=public_key, + name=radio_data.get("adv_name"), + type=radio_data.get("type", 0), + flags=radio_data.get("flags", 0), + last_path=last_path, + last_path_len=last_path_len, + out_path_hash_mode=out_path_hash_mode, + lat=radio_data.get("adv_lat"), + lon=radio_data.get("adv_lon"), + last_advert=radio_data.get("last_advert"), + on_radio=on_radio, + ) + + class Contact(BaseModel): public_key: str = Field(description="Public key (64-char hex)") name: str | None = None @@ -61,34 +119,18 @@ class Contact(BaseModel): "last_advert": self.last_advert if self.last_advert is not None else 0, } + def to_upsert(self, **changes) -> ContactUpsert: + """Convert the stored contact to the repository's write contract.""" + return ContactUpsert.from_contact(self, **changes) + @staticmethod def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict: - """Convert radio contact data to database format dict. - - This is the inverse of to_radio_dict(), used when syncing contacts - from radio to database. - """ - last_path, last_path_len, out_path_hash_mode = normalize_contact_route( - radio_data.get("out_path"), - radio_data.get("out_path_len", -1), - radio_data.get( - "out_path_hash_mode", - -1 if radio_data.get("out_path_len", -1) == -1 else 0, - ), - ) - return { - "public_key": public_key, - "name": radio_data.get("adv_name"), - "type": radio_data.get("type", 0), - "flags": radio_data.get("flags", 0), - "last_path": last_path, - "last_path_len": last_path_len, - "out_path_hash_mode": out_path_hash_mode, - "lat": radio_data.get("adv_lat"), - "lon": radio_data.get("adv_lon"), - "last_advert": radio_data.get("last_advert"), - "on_radio": on_radio, - } + """Backward-compatible dict wrapper over ContactUpsert.from_radio_dict().""" + return ContactUpsert.from_radio_dict( + public_key, + radio_data, + on_radio=on_radio, + ).model_dump() class CreateContactRequest(BaseModel): diff --git a/app/packet_processor.py b/app/packet_processor.py index d8e09b6..e119d20 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -30,6 +30,8 @@ from app.decoder import ( from app.keystore import get_private_key, get_public_key, has_private_key from app.models import ( CONTACT_TYPE_REPEATER, + Contact, + ContactUpsert, RawPacketBroadcast, RawPacketDecryptedInfo, ) @@ -489,21 +491,21 @@ async def _process_advertisement( hop_count=new_path_len, ) - contact_data = { - "public_key": advert.public_key.lower(), - "name": advert.name, - "type": contact_type, - "lat": advert.lat, - "lon": advert.lon, - "last_advert": advert.timestamp if advert.timestamp > 0 else timestamp, - "last_seen": timestamp, - "last_path": path_hex, - "last_path_len": path_len, - "out_path_hash_mode": out_path_hash_mode, - "first_seen": timestamp, # COALESCE in upsert preserves existing value - } + contact_upsert = ContactUpsert( + public_key=advert.public_key.lower(), + name=advert.name, + type=contact_type, + lat=advert.lat, + lon=advert.lon, + last_advert=advert.timestamp if advert.timestamp > 0 else timestamp, + last_seen=timestamp, + last_path=path_hex, + last_path_len=path_len, + out_path_hash_mode=out_path_hash_mode, + first_seen=timestamp, # COALESCE in upsert preserves existing value + ) - await ContactRepository.upsert(contact_data) + await ContactRepository.upsert(contact_upsert) await record_contact_name_and_reconcile( public_key=advert.public_key, contact_name=advert.name, @@ -517,7 +519,10 @@ async def _process_advertisement( if db_contact: broadcast_event("contact", db_contact.model_dump()) else: - broadcast_event("contact", contact_data) + broadcast_event( + "contact", + Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump(), + ) # For new contacts, optionally attempt to decrypt any historical DMs we may have stored # This is controlled by the auto_decrypt_dm_on_advert setting diff --git a/app/radio_sync.py b/app/radio_sync.py index 1fb20cc..f6eb876 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -17,7 +17,7 @@ from contextlib import asynccontextmanager from meshcore import EventType, MeshCore from app.event_handlers import cleanup_expired_acks -from app.models import Contact +from app.models import Contact, ContactUpsert from app.radio import RadioOperationBusyError from app.repository import ( AmbiguousPublicKeyPrefixError, @@ -155,7 +155,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict: for public_key, contact_data in contacts.items(): # Save to database await ContactRepository.upsert( - Contact.from_radio_dict(public_key, contact_data, on_radio=False) + ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False) ) await reconcile_contact_messages( public_key=public_key, diff --git a/app/repository/contacts.py b/app/repository/contacts.py index 24c3e22..94c69ee 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -1,4 +1,5 @@ import time +from collections.abc import Mapping from typing import Any from app.database import db @@ -7,6 +8,7 @@ from app.models import ( ContactAdvertPath, ContactAdvertPathSummary, ContactNameHistory, + ContactUpsert, ) from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override @@ -22,17 +24,28 @@ class AmbiguousPublicKeyPrefixError(ValueError): class ContactRepository: @staticmethod - async def upsert(contact: dict[str, Any]) -> None: + def _coerce_contact_upsert( + contact: ContactUpsert | Contact | Mapping[str, Any], + ) -> ContactUpsert: + if isinstance(contact, ContactUpsert): + return contact + if isinstance(contact, Contact): + return contact.to_upsert() + return ContactUpsert.model_validate(contact) + + @staticmethod + async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None: + contact_row = ContactRepository._coerce_contact_upsert(contact) last_path, last_path_len, out_path_hash_mode = normalize_contact_route( - contact.get("last_path"), - contact.get("last_path_len", -1), - contact.get("out_path_hash_mode"), + contact_row.last_path, + contact_row.last_path_len, + contact_row.out_path_hash_mode, ) route_override_path, route_override_len, route_override_hash_mode = ( normalize_route_override( - contact.get("route_override_path"), - contact.get("route_override_len"), - contact.get("route_override_hash_mode"), + contact_row.route_override_path, + contact_row.route_override_len, + contact_row.route_override_hash_mode, ) ) @@ -70,23 +83,23 @@ class ContactRepository: first_seen = COALESCE(contacts.first_seen, excluded.first_seen) """, ( - contact.get("public_key", "").lower(), - contact.get("name"), - contact.get("type", 0), - contact.get("flags", 0), + contact_row.public_key.lower(), + contact_row.name, + contact_row.type, + contact_row.flags, last_path, last_path_len, out_path_hash_mode, route_override_path, route_override_len, route_override_hash_mode, - contact.get("last_advert"), - contact.get("lat"), - contact.get("lon"), - contact.get("last_seen", int(time.time())), - contact.get("on_radio"), - contact.get("last_contacted"), - contact.get("first_seen"), + contact_row.last_advert, + contact_row.lat, + contact_row.lon, + contact_row.last_seen if contact_row.last_seen is not None else int(time.time()), + contact_row.on_radio, + contact_row.last_contacted, + contact_row.first_seen, ), ) await db.conn.commit() diff --git a/app/routers/contacts.py b/app/routers/contacts.py index c62f16f..3168736 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -12,6 +12,7 @@ from app.models import ( ContactAdvertPathSummary, ContactDetail, ContactRoutingOverrideRequest, + ContactUpsert, CreateContactRequest, NearestRepeater, TraceResponse, @@ -133,23 +134,7 @@ async def create_contact( if existing: # Update name if provided if request.name: - await ContactRepository.upsert( - { - "public_key": existing.public_key, - "name": request.name, - "type": existing.type, - "flags": existing.flags, - "last_path": existing.last_path, - "last_path_len": existing.last_path_len, - "out_path_hash_mode": existing.out_path_hash_mode, - "last_advert": existing.last_advert, - "lat": existing.lat, - "lon": existing.lon, - "last_seen": existing.last_seen, - "on_radio": existing.on_radio, - "last_contacted": existing.last_contacted, - } - ) + await ContactRepository.upsert(existing.to_upsert(name=request.name)) refreshed = await ContactRepository.get_by_key(request.public_key) if refreshed is not None: existing = refreshed @@ -164,22 +149,13 @@ async def create_contact( # Create new contact lower_key = request.public_key.lower() - contact_data = { - "public_key": lower_key, - "name": request.name, - "type": 0, # Unknown - "flags": 0, - "last_path": None, - "last_path_len": -1, - "out_path_hash_mode": -1, - "last_advert": None, - "lat": None, - "lon": None, - "last_seen": None, - "on_radio": False, - "last_contacted": None, - } - await ContactRepository.upsert(contact_data) + contact_upsert = ContactUpsert( + public_key=lower_key, + name=request.name, + out_path_hash_mode=-1, + on_radio=False, + ) + await ContactRepository.upsert(contact_upsert) logger.info("Created contact %s", lower_key[:12]) await reconcile_contact_messages( @@ -192,7 +168,7 @@ async def create_contact( if request.try_historical: await start_historical_dm_decryption(background_tasks, lower_key, request.name) - return Contact(**contact_data) + return Contact(**contact_upsert.model_dump()) @router.get("/{public_key}/detail", response_model=ContactDetail) @@ -309,7 +285,7 @@ async def sync_contacts_from_radio() -> dict: for public_key, contact_data in contacts.items(): lower_key = public_key.lower() await ContactRepository.upsert( - Contact.from_radio_dict(lower_key, contact_data, on_radio=True) + ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True) ) synced_keys.append(lower_key) await reconcile_contact_messages( diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index eb10251..bb2b700 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -73,8 +73,10 @@ class RadioRuntime: """Return MeshCore when available, mirroring existing HTTP semantics.""" if self.is_setup_in_progress: raise HTTPException(status_code=503, detail="Radio is initializing") + if not self.is_connected: + raise HTTPException(status_code=503, detail="Radio not connected") mc = self.meshcore - if not self.is_connected or mc is None: + if mc is None: raise HTTPException(status_code=503, detail="Radio not connected") return mc diff --git a/tests/test_radio_runtime_service.py b/tests/test_radio_runtime_service.py index 8af1c84..41ae42e 100644 --- a/tests/test_radio_runtime_service.py +++ b/tests/test_radio_runtime_service.py @@ -65,6 +65,29 @@ def test_require_connected_preserves_http_semantics(): assert exc.value.status_code == 503 +def test_require_connected_returns_fresh_meshcore_after_connectivity_check(): + old_meshcore = object() + new_meshcore = object() + + class _SwappingManager: + def __init__(self): + self._meshcore = old_meshcore + self.is_setup_in_progress = False + + @property + def is_connected(self): + self._meshcore = new_meshcore + return True + + @property + def meshcore(self): + return self._meshcore + + runtime = RadioRuntime(_SwappingManager()) + + assert runtime.require_connected() is new_meshcore + + @pytest.mark.asyncio async def test_radio_operation_delegates_to_current_manager(): manager = _Manager(meshcore="meshcore", is_connected=True) diff --git a/tests/test_repository.py b/tests/test_repository.py index 7b137c9..9dce1ce 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from app.models import Contact, ContactUpsert from app.repository import ( ContactAdvertPathRepository, ContactNameHistoryRepository, @@ -643,3 +644,34 @@ class TestMessageRepositoryGetById: result = await MessageRepository.get_by_id(999999) assert result is None + + +class TestContactRepositoryUpsertContracts: + @pytest.mark.asyncio + async def test_accepts_contact_upsert_model(self, test_db): + await ContactRepository.upsert( + ContactUpsert(public_key="aa" * 32, name="Alice", type=1, on_radio=False) + ) + + contact = await ContactRepository.get_by_key("aa" * 32) + assert contact is not None + assert contact.name == "Alice" + assert contact.type == 1 + + @pytest.mark.asyncio + async def test_accepts_contact_model(self, test_db): + await ContactRepository.upsert( + Contact( + public_key="bb" * 32, + name="Bob", + type=2, + on_radio=True, + out_path_hash_mode=-1, + ) + ) + + contact = await ContactRepository.get_by_key("bb" * 32) + assert contact is not None + assert contact.name == "Bob" + assert contact.type == 2 + assert contact.on_radio is True From 39b745f8b00f2b6b20e8574c29a5c1d335f57f76 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:53:19 -0700 Subject: [PATCH 24/27] Compactify some things for LLM wins --- app/services/radio_runtime.py | 54 +-- frontend/src/App.tsx | 174 +++++--- frontend/src/hooks/index.ts | 1 - frontend/src/hooks/useAppShellProps.ts | 308 -------------- frontend/src/hooks/useConversationMessages.ts | 347 ++++++++++++++- frontend/src/hooks/useConversationTimeline.ts | 399 ------------------ frontend/src/test/useAppShellProps.test.ts | 225 ---------- 7 files changed, 453 insertions(+), 1055 deletions(-) delete mode 100644 frontend/src/hooks/useAppShellProps.ts delete mode 100644 frontend/src/hooks/useConversationTimeline.ts delete mode 100644 frontend/src/test/useAppShellProps.test.ts diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index bb2b700..177dc72 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -15,7 +15,7 @@ import app.radio as radio_module class RadioRuntime: - """Thin wrapper around the process-global RadioManager.""" + """Thin forwarding wrapper around the process-global RadioManager.""" def __init__(self, manager_or_getter=None): if manager_or_getter is None: @@ -29,45 +29,25 @@ class RadioRuntime: def manager(self) -> Any: return self._manager_getter() - @property - def meshcore(self): - return self.manager.meshcore + def __getattr__(self, name: str) -> Any: + """Forward unknown attributes to the current global manager.""" + return getattr(self.manager, name) - @property - def connection_info(self) -> str | None: - return self.manager.connection_info + @staticmethod + def _is_local_runtime_attr(name: str) -> bool: + return name.startswith("_") or hasattr(RadioRuntime, name) - @property - def is_connected(self) -> bool: - return self.manager.is_connected + def __setattr__(self, name: str, value: Any) -> None: + if self._is_local_runtime_attr(name): + object.__setattr__(self, name, value) + return + setattr(self.manager, name, value) - @property - def is_reconnecting(self) -> bool: - return self.manager.is_reconnecting - - @property - def is_setup_in_progress(self) -> bool: - return self.manager.is_setup_in_progress - - @property - def is_setup_complete(self) -> bool: - return self.manager.is_setup_complete - - @property - def path_hash_mode(self) -> int: - return self.manager.path_hash_mode - - @path_hash_mode.setter - def path_hash_mode(self, mode: int) -> None: - self.manager.path_hash_mode = mode - - @property - def path_hash_mode_supported(self) -> bool: - return self.manager.path_hash_mode_supported - - @path_hash_mode_supported.setter - def path_hash_mode_supported(self, supported: bool) -> None: - self.manager.path_hash_mode_supported = supported + def __delattr__(self, name: str) -> None: + if self._is_local_runtime_attr(name): + object.__delattr__(self, name) + return + delattr(self.manager, name) def require_connected(self): """Return MeshCore when available, mirroring existing HTTP semantics.""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 961e313..9bec14b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,6 @@ import { takePrefetchOrFetch } from './prefetch'; import { useWebSocket } from './useWebSocket'; import { useAppShell, - useAppShellProps, useUnreadCounts, useConversationMessages, useRadioControl, @@ -222,81 +221,130 @@ export function App() { handleToggleBlockedName, messageInputRef, }); + const handleCreateCrackedChannel = useCallback( + async (name: string, key: string) => { + const created = await api.createChannel(name, key); + const updatedChannels = await api.getChannels(); + setChannels(updatedChannels); + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + void fetchUndecryptedCount().catch((error) => { + console.error('Failed to refresh undecrypted count after cracked channel create:', error); + }); + }, + [fetchUndecryptedCount, setChannels] + ); - const { - statusProps, - sidebarProps, - conversationPaneProps, - searchProps, - settingsProps, - crackerProps, - newMessageModalProps, - contactInfoPaneProps, - channelInfoPaneProps, - } = useAppShellProps({ + const statusProps = { health, config }; + const sidebarProps = { + contacts, + channels, + activeConversation, + onSelectConversation: handleSelectConversationWithTargetReset, + onNewMessage: handleOpenNewMessage, + lastMessageTimes, + unreadCounts, + mentions, + showCracker, + crackerRunning, + onToggleCracker: handleToggleCracker, + onMarkAllRead: () => { + void markAllRead(); + }, + favorites, + sortOrder: appSettings?.sidebar_sort_order ?? 'recent', + onSortOrderChange: (sortOrder: 'recent' | 'alpha') => { + void handleSortOrderChange(sortOrder); + }, + }; + const conversationPaneProps = { + activeConversation, contacts, channels, rawPackets, - undecryptedCount, - activeConversation, config, health, favorites, - appSettings, - unreadCounts, - mentions, - lastMessageTimes, - showCracker, - crackerRunning, - messageInputRef, - targetMessageId, - infoPaneContactKey, - infoPaneFromChannel, - infoPaneChannelKey, messages, messagesLoading, loadingOlder, hasOlderMessages, + targetMessageId, hasNewerMessages, loadingNewer, - handleOpenNewMessage, - handleToggleCracker, - markAllRead, - handleSortOrderChange, - handleSelectConversationWithTargetReset, - handleNavigateToMessage, - handleSaveConfig, - handleSaveAppSettings, - handleSetPrivateKey, - handleReboot, - handleAdvertise, - handleHealthRefresh, - fetchAppSettings, - setChannels, - fetchUndecryptedCount, - handleCreateContact, - handleCreateChannel, - handleCreateHashtagChannel, - handleDeleteContact, - handleDeleteChannel, - handleToggleFavorite, - handleSetChannelFloodScopeOverride, - handleOpenContactInfo, - handleOpenChannelInfo, - handleCloseContactInfo, - handleCloseChannelInfo, - handleSenderClick, - handleResendChannelMessage, - handleTrace, - handleSendMessage, - fetchOlderMessages, - fetchNewerMessages, - jumpToBottom, - setTargetMessageId, - handleNavigateToChannel, - handleBlockKey, - handleBlockName, - }); + messageInputRef, + onTrace: handleTrace, + onToggleFavorite: handleToggleFavorite, + onDeleteContact: handleDeleteContact, + onDeleteChannel: handleDeleteChannel, + onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, + onOpenContactInfo: handleOpenContactInfo, + onOpenChannelInfo: handleOpenChannelInfo, + onSenderClick: handleSenderClick, + onLoadOlder: fetchOlderMessages, + onResendChannelMessage: handleResendChannelMessage, + onTargetReached: () => setTargetMessageId(null), + onLoadNewer: fetchNewerMessages, + onJumpToBottom: jumpToBottom, + onSendMessage: handleSendMessage, + }; + const searchProps = { + contacts, + channels, + onNavigateToMessage: handleNavigateToMessage, + }; + const settingsProps = { + config, + health, + appSettings, + onSave: handleSaveConfig, + onSaveAppSettings: handleSaveAppSettings, + onSetPrivateKey: handleSetPrivateKey, + onReboot: handleReboot, + onAdvertise: handleAdvertise, + onHealthRefresh: handleHealthRefresh, + onRefreshAppSettings: fetchAppSettings, + blockedKeys: appSettings?.blocked_keys, + blockedNames: appSettings?.blocked_names, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + }; + const crackerProps = { + packets: rawPackets, + channels, + onChannelCreate: handleCreateCrackedChannel, + }; + const newMessageModalProps = { + contacts, + undecryptedCount, + onSelectConversation: handleSelectConversationWithTargetReset, + onCreateContact: handleCreateContact, + onCreateChannel: handleCreateChannel, + onCreateHashtagChannel: handleCreateHashtagChannel, + }; + const contactInfoPaneProps = { + contactKey: infoPaneContactKey, + fromChannel: infoPaneFromChannel, + onClose: handleCloseContactInfo, + contacts, + config, + favorites, + onToggleFavorite: handleToggleFavorite, + onNavigateToChannel: handleNavigateToChannel, + onToggleBlockedKey: handleBlockKey, + onToggleBlockedName: handleBlockName, + blockedKeys: appSettings?.blocked_keys ?? [], + blockedNames: appSettings?.blocked_names ?? [], + }; + const channelInfoPaneProps = { + channelKey: infoPaneChannelKey, + onClose: handleCloseChannelInfo, + channels, + favorites, + onToggleFavorite: handleToggleFavorite, + }; // Connect to WebSocket useWebSocket(wsHandlers); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d4386ad..d6d1c94 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,4 +9,3 @@ export { useContactsAndChannels } from './useContactsAndChannels'; export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; -export { useAppShellProps } from './useAppShellProps'; diff --git a/frontend/src/hooks/useAppShellProps.ts b/frontend/src/hooks/useAppShellProps.ts deleted file mode 100644 index 46a4a9d..0000000 --- a/frontend/src/hooks/useAppShellProps.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { useCallback, type ComponentProps, type Dispatch, type SetStateAction } from 'react'; - -import { api } from '../api'; -import { ChannelInfoPane } from '../components/ChannelInfoPane'; -import { ContactInfoPane } from '../components/ContactInfoPane'; -import { ConversationPane } from '../components/ConversationPane'; -import { NewMessageModal } from '../components/NewMessageModal'; -import { SearchView } from '../components/SearchView'; -import { SettingsModal } from '../components/SettingsModal'; -import { Sidebar } from '../components/Sidebar'; -import { StatusBar } from '../components/StatusBar'; -import { CrackerPanel } from '../components/CrackerPanel'; -import type { - AppSettings, - Channel, - Contact, - Conversation, - Favorite, - HealthStatus, - Message, - RadioConfig, - RawPacket, -} from '../types'; - -type StatusProps = Pick, 'health' | 'config'>; -type SidebarProps = ComponentProps; -type ConversationPaneProps = ComponentProps; -type SearchProps = ComponentProps; -type SettingsProps = Omit< - ComponentProps, - 'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange' ->; -type CrackerProps = Omit, 'visible' | 'onRunningChange'>; -type NewMessageModalProps = Omit, 'open' | 'onClose'>; -type ContactInfoPaneProps = ComponentProps; -type ChannelInfoPaneProps = ComponentProps; - -interface UseAppShellPropsArgs { - contacts: Contact[]; - channels: Channel[]; - rawPackets: RawPacket[]; - undecryptedCount: number; - activeConversation: Conversation | null; - config: RadioConfig | null; - health: HealthStatus | null; - favorites: Favorite[]; - appSettings: AppSettings | null; - unreadCounts: Record; - mentions: Record; - lastMessageTimes: Record; - showCracker: boolean; - crackerRunning: boolean; - messageInputRef: ConversationPaneProps['messageInputRef']; - targetMessageId: number | null; - infoPaneContactKey: string | null; - infoPaneFromChannel: boolean; - infoPaneChannelKey: string | null; - messages: Message[]; - messagesLoading: boolean; - loadingOlder: boolean; - hasOlderMessages: boolean; - hasNewerMessages: boolean; - loadingNewer: boolean; - handleOpenNewMessage: () => void; - handleToggleCracker: () => void; - markAllRead: () => void; - handleSortOrderChange: (sortOrder: 'recent' | 'alpha') => Promise; - handleSelectConversationWithTargetReset: ( - conv: Conversation, - options?: { preserveTarget?: boolean } - ) => void; - handleNavigateToMessage: SearchProps['onNavigateToMessage']; - handleSaveConfig: SettingsProps['onSave']; - handleSaveAppSettings: SettingsProps['onSaveAppSettings']; - handleSetPrivateKey: SettingsProps['onSetPrivateKey']; - handleReboot: SettingsProps['onReboot']; - handleAdvertise: SettingsProps['onAdvertise']; - handleHealthRefresh: SettingsProps['onHealthRefresh']; - fetchAppSettings: () => Promise; - setChannels: Dispatch>; - fetchUndecryptedCount: () => Promise; - handleCreateContact: NewMessageModalProps['onCreateContact']; - handleCreateChannel: NewMessageModalProps['onCreateChannel']; - handleCreateHashtagChannel: NewMessageModalProps['onCreateHashtagChannel']; - handleDeleteContact: ConversationPaneProps['onDeleteContact']; - handleDeleteChannel: ConversationPaneProps['onDeleteChannel']; - handleToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; - handleSetChannelFloodScopeOverride: ConversationPaneProps['onSetChannelFloodScopeOverride']; - handleOpenContactInfo: ConversationPaneProps['onOpenContactInfo']; - handleOpenChannelInfo: ConversationPaneProps['onOpenChannelInfo']; - handleCloseContactInfo: () => void; - handleCloseChannelInfo: () => void; - handleSenderClick: NonNullable; - handleResendChannelMessage: NonNullable; - handleTrace: ConversationPaneProps['onTrace']; - handleSendMessage: ConversationPaneProps['onSendMessage']; - fetchOlderMessages: ConversationPaneProps['onLoadOlder']; - fetchNewerMessages: ConversationPaneProps['onLoadNewer']; - jumpToBottom: ConversationPaneProps['onJumpToBottom']; - setTargetMessageId: Dispatch>; - handleNavigateToChannel: ContactInfoPaneProps['onNavigateToChannel']; - handleBlockKey: NonNullable; - handleBlockName: NonNullable; -} - -interface UseAppShellPropsResult { - statusProps: StatusProps; - sidebarProps: SidebarProps; - conversationPaneProps: ConversationPaneProps; - searchProps: SearchProps; - settingsProps: SettingsProps; - crackerProps: CrackerProps; - newMessageModalProps: NewMessageModalProps; - contactInfoPaneProps: ContactInfoPaneProps; - channelInfoPaneProps: ChannelInfoPaneProps; -} - -export function useAppShellProps({ - contacts, - channels, - rawPackets, - undecryptedCount, - activeConversation, - config, - health, - favorites, - appSettings, - unreadCounts, - mentions, - lastMessageTimes, - showCracker, - crackerRunning, - messageInputRef, - targetMessageId, - infoPaneContactKey, - infoPaneFromChannel, - infoPaneChannelKey, - messages, - messagesLoading, - loadingOlder, - hasOlderMessages, - hasNewerMessages, - loadingNewer, - handleOpenNewMessage, - handleToggleCracker, - markAllRead, - handleSortOrderChange, - handleSelectConversationWithTargetReset, - handleNavigateToMessage, - handleSaveConfig, - handleSaveAppSettings, - handleSetPrivateKey, - handleReboot, - handleAdvertise, - handleHealthRefresh, - fetchAppSettings, - setChannels, - fetchUndecryptedCount, - handleCreateContact, - handleCreateChannel, - handleCreateHashtagChannel, - handleDeleteContact, - handleDeleteChannel, - handleToggleFavorite, - handleSetChannelFloodScopeOverride, - handleOpenContactInfo, - handleOpenChannelInfo, - handleCloseContactInfo, - handleCloseChannelInfo, - handleSenderClick, - handleResendChannelMessage, - handleTrace, - handleSendMessage, - fetchOlderMessages, - fetchNewerMessages, - jumpToBottom, - setTargetMessageId, - handleNavigateToChannel, - handleBlockKey, - handleBlockName, -}: UseAppShellPropsArgs): UseAppShellPropsResult { - const handleCreateCrackedChannel = useCallback( - async (name, key) => { - const created = await api.createChannel(name, key); - const updatedChannels = await api.getChannels(); - setChannels(updatedChannels); - await api.decryptHistoricalPackets({ - key_type: 'channel', - channel_key: created.key, - }); - void fetchUndecryptedCount().catch((error) => { - console.error('Failed to refresh undecrypted count after cracked channel create:', error); - }); - }, - [fetchUndecryptedCount, setChannels] - ); - - return { - statusProps: { health, config }, - sidebarProps: { - contacts, - channels, - activeConversation, - onSelectConversation: handleSelectConversationWithTargetReset, - onNewMessage: handleOpenNewMessage, - lastMessageTimes, - unreadCounts, - mentions, - showCracker, - crackerRunning, - onToggleCracker: handleToggleCracker, - onMarkAllRead: () => { - void markAllRead(); - }, - favorites, - sortOrder: appSettings?.sidebar_sort_order ?? 'recent', - onSortOrderChange: (sortOrder) => { - void handleSortOrderChange(sortOrder); - }, - }, - conversationPaneProps: { - activeConversation, - contacts, - channels, - rawPackets, - config, - health, - favorites, - messages, - messagesLoading, - loadingOlder, - hasOlderMessages, - targetMessageId, - hasNewerMessages, - loadingNewer, - messageInputRef, - onTrace: handleTrace, - onToggleFavorite: handleToggleFavorite, - onDeleteContact: handleDeleteContact, - onDeleteChannel: handleDeleteChannel, - onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, - onOpenContactInfo: handleOpenContactInfo, - onOpenChannelInfo: handleOpenChannelInfo, - onSenderClick: handleSenderClick, - onLoadOlder: fetchOlderMessages, - onResendChannelMessage: handleResendChannelMessage, - onTargetReached: () => setTargetMessageId(null), - onLoadNewer: fetchNewerMessages, - onJumpToBottom: jumpToBottom, - onSendMessage: handleSendMessage, - }, - searchProps: { - contacts, - channels, - onNavigateToMessage: handleNavigateToMessage, - }, - settingsProps: { - config, - health, - appSettings, - onSave: handleSaveConfig, - onSaveAppSettings: handleSaveAppSettings, - onSetPrivateKey: handleSetPrivateKey, - onReboot: handleReboot, - onAdvertise: handleAdvertise, - onHealthRefresh: handleHealthRefresh, - onRefreshAppSettings: fetchAppSettings, - blockedKeys: appSettings?.blocked_keys, - blockedNames: appSettings?.blocked_names, - onToggleBlockedKey: handleBlockKey, - onToggleBlockedName: handleBlockName, - }, - crackerProps: { - packets: rawPackets, - channels, - onChannelCreate: handleCreateCrackedChannel, - }, - newMessageModalProps: { - contacts, - undecryptedCount, - onSelectConversation: handleSelectConversationWithTargetReset, - onCreateContact: handleCreateContact, - onCreateChannel: handleCreateChannel, - onCreateHashtagChannel: handleCreateHashtagChannel, - }, - contactInfoPaneProps: { - contactKey: infoPaneContactKey, - fromChannel: infoPaneFromChannel, - onClose: handleCloseContactInfo, - contacts, - config, - favorites, - onToggleFavorite: handleToggleFavorite, - onNavigateToChannel: handleNavigateToChannel, - blockedKeys: appSettings?.blocked_keys, - blockedNames: appSettings?.blocked_names, - onToggleBlockedKey: handleBlockKey, - onToggleBlockedName: handleBlockName, - }, - channelInfoPaneProps: { - channelKey: infoPaneChannelKey, - onClose: handleCloseChannelInfo, - channels, - favorites, - onToggleFavorite: handleToggleFavorite, - }, - }; -} diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index e85d146..9c507d8 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -1,14 +1,19 @@ import { useCallback, + useEffect, useRef, + useState, type Dispatch, type MutableRefObject, type SetStateAction, } from 'react'; -import { useConversationTimeline } from './useConversationTimeline'; +import { toast } from '../components/ui/sonner'; +import { api, isAbortError } from '../api'; +import * as messageCache from '../messageCache'; import type { Conversation, Message, MessagePath } from '../types'; const MAX_PENDING_ACKS = 500; +const MESSAGE_PAGE_SIZE = 200; interface PendingAckUpdate { ackCount: number; @@ -77,6 +82,10 @@ interface UseConversationMessagesResult { triggerReconcile: () => void; } +function isMessageConversation(conversation: Conversation | null): conversation is Conversation { + return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); +} + export function useConversationMessages( activeConversation: Conversation | null, targetMessageId?: number | null @@ -119,28 +128,322 @@ export function useConversationMessages( ...(pending.paths !== undefined && { paths: pending.paths }), }; }, []); + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [hasOlderMessages, setHasOlderMessages] = useState(false); + const [hasNewerMessages, setHasNewerMessages] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); - const { - messages, - messagesRef, - messagesLoading, - loadingOlder, - hasOlderMessages, - hasNewerMessages, - loadingNewer, - hasNewerMessagesRef, - setMessages, - fetchOlderMessages, - fetchNewerMessages, - jumpToBottom, - triggerReconcile, - } = useConversationTimeline({ - activeConversation, - targetMessageId, - applyPendingAck, - getMessageContentKey, - seenMessageContentRef: seenMessageContent, - }); + const abortControllerRef = useRef(null); + const fetchingConversationIdRef = useRef(null); + const messagesRef = useRef([]); + const hasOlderMessagesRef = useRef(false); + const hasNewerMessagesRef = useRef(false); + const prevConversationIdRef = useRef(null); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + hasOlderMessagesRef.current = hasOlderMessages; + }, [hasOlderMessages]); + + useEffect(() => { + hasNewerMessagesRef.current = hasNewerMessages; + }, [hasNewerMessages]); + + const syncSeenContent = useCallback( + (nextMessages: Message[]) => { + seenMessageContent.current.clear(); + for (const msg of nextMessages) { + seenMessageContent.current.add(getMessageContentKey(msg)); + } + }, + [seenMessageContent] + ); + + const fetchLatestMessages = useCallback( + async (showLoading = false, signal?: AbortSignal) => { + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const conversationId = activeConversation.id; + + if (showLoading) { + setMessagesLoading(true); + setMessages([]); + } + + try { + const data = await api.getMessages( + { + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: activeConversation.id, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ); + + if (fetchingConversationIdRef.current !== conversationId) { + return; + } + + const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); + setMessages(messagesWithPendingAck); + syncSeenContent(messagesWithPendingAck); + setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + if (isAbortError(err)) { + return; + } + console.error('Failed to fetch messages:', err); + toast.error('Failed to load messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + if (showLoading) { + setMessagesLoading(false); + } + } + }, + [activeConversation, applyPendingAck, syncSeenContent] + ); + + const reconcileFromBackend = useCallback( + (conversation: Conversation, signal: AbortSignal) => { + const conversationId = conversation.id; + api + .getMessages( + { + type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ) + .then((data) => { + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); + if (!merged) return; + + setMessages(merged); + syncSeenContent(merged); + if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { + setHasOlderMessages(true); + } + }) + .catch((err) => { + if (isAbortError(err)) return; + console.debug('Background reconciliation failed:', err); + }); + }, + [applyPendingAck, syncSeenContent] + ); + + const fetchOlderMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return; + + const conversationId = activeConversation.id; + const oldestMessage = messages.reduce( + (oldest, msg) => { + if (!oldest) return msg; + if (msg.received_at < oldest.received_at) return msg; + if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; + return oldest; + }, + null as Message | null + ); + if (!oldestMessage) return; + + setLoadingOlder(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + before: oldestMessage.received_at, + before_id: oldestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + + if (dataWithPendingAck.length > 0) { + setMessages((prev) => [...prev, ...dataWithPendingAck]); + for (const msg of dataWithPendingAck) { + seenMessageContent.current.add(getMessageContentKey(msg)); + } + } + setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch older messages:', err); + toast.error('Failed to load older messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingOlder(false); + } + }, [activeConversation, applyPendingAck, hasOlderMessages, loadingOlder, messages]); + + const fetchNewerMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return; + + const conversationId = activeConversation.id; + const newestMessage = messages.reduce( + (newest, msg) => { + if (!newest) return msg; + if (msg.received_at > newest.received_at) return msg; + if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; + return newest; + }, + null as Message | null + ); + if (!newestMessage) return; + + setLoadingNewer(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + after: newestMessage.received_at, + after_id: newestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const newMessages = dataWithPendingAck.filter( + (msg) => !seenMessageContent.current.has(getMessageContentKey(msg)) + ); + + if (newMessages.length > 0) { + setMessages((prev) => [...prev, ...newMessages]); + for (const msg of newMessages) { + seenMessageContent.current.add(getMessageContentKey(msg)); + } + } + setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch newer messages:', err); + toast.error('Failed to load newer messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingNewer(false); + } + }, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]); + + const jumpToBottom = useCallback(() => { + if (!activeConversation) return; + setHasNewerMessages(false); + messageCache.remove(activeConversation.id); + void fetchLatestMessages(true); + }, [activeConversation, fetchLatestMessages]); + + const triggerReconcile = useCallback(() => { + if (!isMessageConversation(activeConversation)) return; + const controller = new AbortController(); + reconcileFromBackend(activeConversation, controller.signal); + }, [activeConversation, reconcileFromBackend]); + + useEffect(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const prevId = prevConversationIdRef.current; + const newId = activeConversation?.id ?? null; + const conversationChanged = prevId !== newId; + fetchingConversationIdRef.current = newId; + prevConversationIdRef.current = newId; + + // Preserve around-loaded context on the same conversation when search clears targetMessageId. + if (!conversationChanged && !targetMessageId) { + return; + } + + setLoadingOlder(false); + setLoadingNewer(false); + if (conversationChanged) { + setHasNewerMessages(false); + } + + if ( + conversationChanged && + prevId && + messagesRef.current.length > 0 && + !hasNewerMessagesRef.current + ) { + messageCache.set(prevId, { + messages: messagesRef.current, + seenContent: new Set(seenMessageContent.current), + hasOlderMessages: hasOlderMessagesRef.current, + }); + } + + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + if (targetMessageId) { + setMessagesLoading(true); + setMessages([]); + const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; + void api + .getMessagesAround( + targetMessageId, + msgType as 'PRIV' | 'CHAN', + activeConversation.id, + controller.signal + ) + .then((response) => { + if (fetchingConversationIdRef.current !== activeConversation.id) return; + const withAcks = response.messages.map((msg) => applyPendingAck(msg)); + setMessages(withAcks); + syncSeenContent(withAcks); + setHasOlderMessages(response.has_older); + setHasNewerMessages(response.has_newer); + }) + .catch((err) => { + if (isAbortError(err)) return; + console.error('Failed to fetch messages around target:', err); + toast.error('Failed to jump to message'); + }) + .finally(() => { + setMessagesLoading(false); + }); + } else { + const cached = messageCache.get(activeConversation.id); + if (cached) { + setMessages(cached.messages); + seenMessageContent.current = new Set(cached.seenContent); + setHasOlderMessages(cached.hasOlderMessages); + setMessagesLoading(false); + reconcileFromBackend(activeConversation, controller.signal); + } else { + void fetchLatestMessages(true, controller.signal); + } + } + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeConversation?.id, activeConversation?.type, targetMessageId]); // Add a message if it's new (deduplication) // Returns true if the message was added, false if it was a duplicate diff --git a/frontend/src/hooks/useConversationTimeline.ts b/frontend/src/hooks/useConversationTimeline.ts deleted file mode 100644 index a080338..0000000 --- a/frontend/src/hooks/useConversationTimeline.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from 'react'; -import { toast } from '../components/ui/sonner'; -import { api, isAbortError } from '../api'; -import * as messageCache from '../messageCache'; -import type { Conversation, Message } from '../types'; - -const MESSAGE_PAGE_SIZE = 200; - -interface UseConversationTimelineArgs { - activeConversation: Conversation | null; - targetMessageId?: number | null; - applyPendingAck: (msg: Message) => Message; - getMessageContentKey: (msg: Message) => string; - seenMessageContentRef: MutableRefObject>; -} - -interface UseConversationTimelineResult { - messages: Message[]; - messagesRef: MutableRefObject; - messagesLoading: boolean; - loadingOlder: boolean; - hasOlderMessages: boolean; - hasNewerMessages: boolean; - loadingNewer: boolean; - hasNewerMessagesRef: MutableRefObject; - setMessages: Dispatch>; - fetchOlderMessages: () => Promise; - fetchNewerMessages: () => Promise; - jumpToBottom: () => void; - triggerReconcile: () => void; -} - -function isMessageConversation(conversation: Conversation | null): conversation is Conversation { - return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); -} - -export function useConversationTimeline({ - activeConversation, - targetMessageId, - applyPendingAck, - getMessageContentKey, - seenMessageContentRef, -}: UseConversationTimelineArgs): UseConversationTimelineResult { - const [messages, setMessages] = useState([]); - const [messagesLoading, setMessagesLoading] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [hasOlderMessages, setHasOlderMessages] = useState(false); - const [hasNewerMessages, setHasNewerMessages] = useState(false); - const [loadingNewer, setLoadingNewer] = useState(false); - - const abortControllerRef = useRef(null); - const fetchingConversationIdRef = useRef(null); - const messagesRef = useRef([]); - const hasOlderMessagesRef = useRef(false); - const hasNewerMessagesRef = useRef(false); - const prevConversationIdRef = useRef(null); - - useEffect(() => { - messagesRef.current = messages; - }, [messages]); - - useEffect(() => { - hasOlderMessagesRef.current = hasOlderMessages; - }, [hasOlderMessages]); - - useEffect(() => { - hasNewerMessagesRef.current = hasNewerMessages; - }, [hasNewerMessages]); - - const syncSeenContent = useCallback( - (nextMessages: Message[]) => { - seenMessageContentRef.current.clear(); - for (const msg of nextMessages) { - seenMessageContentRef.current.add(getMessageContentKey(msg)); - } - }, - [getMessageContentKey, seenMessageContentRef] - ); - - const fetchLatestMessages = useCallback( - async (showLoading = false, signal?: AbortSignal) => { - if (!isMessageConversation(activeConversation)) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - const conversationId = activeConversation.id; - - if (showLoading) { - setMessagesLoading(true); - setMessages([]); - } - - try { - const data = await api.getMessages( - { - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: activeConversation.id, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ); - - if (fetchingConversationIdRef.current !== conversationId) { - return; - } - - const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); - setMessages(messagesWithPendingAck); - syncSeenContent(messagesWithPendingAck); - setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - if (isAbortError(err)) { - return; - } - console.error('Failed to fetch messages:', err); - toast.error('Failed to load messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - if (showLoading) { - setMessagesLoading(false); - } - } - }, - [activeConversation, applyPendingAck, syncSeenContent] - ); - - const reconcileFromBackend = useCallback( - (conversation: Conversation, signal: AbortSignal) => { - const conversationId = conversation.id; - api - .getMessages( - { - type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ) - .then((data) => { - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); - if (!merged) return; - - setMessages(merged); - syncSeenContent(merged); - if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { - setHasOlderMessages(true); - } - }) - .catch((err) => { - if (isAbortError(err)) return; - console.debug('Background reconciliation failed:', err); - }); - }, - [applyPendingAck, syncSeenContent] - ); - - const fetchOlderMessages = useCallback(async () => { - if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return; - - const conversationId = activeConversation.id; - const oldestMessage = messages.reduce( - (oldest, msg) => { - if (!oldest) return msg; - if (msg.received_at < oldest.received_at) return msg; - if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; - return oldest; - }, - null as Message | null - ); - if (!oldestMessage) return; - - setLoadingOlder(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - before: oldestMessage.received_at, - before_id: oldestMessage.id, - }); - - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - - if (dataWithPendingAck.length > 0) { - setMessages((prev) => [...prev, ...dataWithPendingAck]); - for (const msg of dataWithPendingAck) { - seenMessageContentRef.current.add(getMessageContentKey(msg)); - } - } - setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch older messages:', err); - toast.error('Failed to load older messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingOlder(false); - } - }, [ - activeConversation, - applyPendingAck, - getMessageContentKey, - hasOlderMessages, - loadingOlder, - messages, - seenMessageContentRef, - ]); - - const fetchNewerMessages = useCallback(async () => { - if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return; - - const conversationId = activeConversation.id; - const newestMessage = messages.reduce( - (newest, msg) => { - if (!newest) return msg; - if (msg.received_at > newest.received_at) return msg; - if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; - return newest; - }, - null as Message | null - ); - if (!newestMessage) return; - - setLoadingNewer(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - after: newestMessage.received_at, - after_id: newestMessage.id, - }); - - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - const newMessages = dataWithPendingAck.filter( - (msg) => !seenMessageContentRef.current.has(getMessageContentKey(msg)) - ); - - if (newMessages.length > 0) { - setMessages((prev) => [...prev, ...newMessages]); - for (const msg of newMessages) { - seenMessageContentRef.current.add(getMessageContentKey(msg)); - } - } - setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch newer messages:', err); - toast.error('Failed to load newer messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingNewer(false); - } - }, [ - activeConversation, - applyPendingAck, - getMessageContentKey, - hasNewerMessages, - loadingNewer, - messages, - seenMessageContentRef, - ]); - - const jumpToBottom = useCallback(() => { - if (!activeConversation) return; - setHasNewerMessages(false); - messageCache.remove(activeConversation.id); - fetchLatestMessages(true); - }, [activeConversation, fetchLatestMessages]); - - const triggerReconcile = useCallback(() => { - if (!isMessageConversation(activeConversation)) return; - const controller = new AbortController(); - reconcileFromBackend(activeConversation, controller.signal); - }, [activeConversation, reconcileFromBackend]); - - useEffect(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const prevId = prevConversationIdRef.current; - const newId = activeConversation?.id ?? null; - const conversationChanged = prevId !== newId; - fetchingConversationIdRef.current = newId; - prevConversationIdRef.current = newId; - - if (!conversationChanged && !targetMessageId) { - return; - } - - setLoadingOlder(false); - setLoadingNewer(false); - if (conversationChanged) { - setHasNewerMessages(false); - } - - if ( - conversationChanged && - prevId && - messagesRef.current.length > 0 && - !hasNewerMessagesRef.current - ) { - messageCache.set(prevId, { - messages: messagesRef.current, - seenContent: new Set(seenMessageContentRef.current), - hasOlderMessages: hasOlderMessagesRef.current, - }); - } - - if (!isMessageConversation(activeConversation)) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - const controller = new AbortController(); - abortControllerRef.current = controller; - - if (targetMessageId) { - setMessagesLoading(true); - setMessages([]); - const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; - api - .getMessagesAround( - targetMessageId, - msgType as 'PRIV' | 'CHAN', - activeConversation.id, - controller.signal - ) - .then((response) => { - if (fetchingConversationIdRef.current !== activeConversation.id) return; - const withAcks = response.messages.map((msg) => applyPendingAck(msg)); - setMessages(withAcks); - syncSeenContent(withAcks); - setHasOlderMessages(response.has_older); - setHasNewerMessages(response.has_newer); - }) - .catch((err) => { - if (isAbortError(err)) return; - console.error('Failed to fetch messages around target:', err); - toast.error('Failed to jump to message'); - }) - .finally(() => { - setMessagesLoading(false); - }); - } else { - const cached = messageCache.get(activeConversation.id); - if (cached) { - setMessages(cached.messages); - seenMessageContentRef.current = new Set(cached.seenContent); - setHasOlderMessages(cached.hasOlderMessages); - setMessagesLoading(false); - reconcileFromBackend(activeConversation, controller.signal); - } else { - fetchLatestMessages(true, controller.signal); - } - } - - return () => { - controller.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeConversation?.id, activeConversation?.type, targetMessageId]); - - return { - messages, - messagesRef, - messagesLoading, - loadingOlder, - hasOlderMessages, - hasNewerMessages, - loadingNewer, - hasNewerMessagesRef, - setMessages, - fetchOlderMessages, - fetchNewerMessages, - jumpToBottom, - triggerReconcile, - }; -} diff --git a/frontend/src/test/useAppShellProps.test.ts b/frontend/src/test/useAppShellProps.test.ts deleted file mode 100644 index c5d057e..0000000 --- a/frontend/src/test/useAppShellProps.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -import { useAppShellProps } from '../hooks/useAppShellProps'; -import type { - AppSettings, - Channel, - Contact, - Conversation, - Favorite, - HealthStatus, - Message, - RadioConfig, - RawPacket, -} from '../types'; - -const mocks = vi.hoisted(() => ({ - api: { - createChannel: vi.fn(), - getChannels: vi.fn(), - decryptHistoricalPackets: vi.fn(), - }, -})); - -vi.mock('../api', () => ({ - api: mocks.api, -})); - -const publicChannel: Channel = { - key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', - name: 'Public', - is_hashtag: false, - on_radio: false, - last_read_at: null, -}; - -const config: RadioConfig = { - public_key: 'aa'.repeat(32), - name: 'TestNode', - lat: 0, - lon: 0, - tx_power: 17, - max_tx_power: 22, - radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, - path_hash_mode: 0, - path_hash_mode_supported: false, -}; - -const health: HealthStatus = { - status: 'connected', - radio_connected: true, - radio_initializing: false, - connection_info: null, - database_size_mb: 1, - oldest_undecrypted_timestamp: null, - fanout_statuses: {}, - bots_disabled: false, -}; - -const appSettings: AppSettings = { - max_radio_contacts: 200, - favorites: [], - auto_decrypt_dm_on_advert: false, - sidebar_sort_order: 'recent', - last_message_times: {}, - preferences_migrated: true, - advert_interval: 0, - last_advert_time: 0, - flood_scope: '', - blocked_keys: [], - blocked_names: [], -}; - -function createArgs(overrides: Partial[0]> = {}) { - const activeConversation: Conversation = { - type: 'channel', - id: publicChannel.key, - name: publicChannel.name, - }; - const contacts: Contact[] = []; - const channels: Channel[] = [publicChannel]; - const rawPackets: RawPacket[] = []; - const favorites: Favorite[] = []; - const messages: Message[] = []; - - return { - contacts, - channels, - rawPackets, - undecryptedCount: 0, - activeConversation, - config, - health, - favorites, - appSettings, - unreadCounts: {}, - mentions: {}, - lastMessageTimes: {}, - showCracker: false, - crackerRunning: false, - messageInputRef: { current: null }, - targetMessageId: null, - infoPaneContactKey: null, - infoPaneFromChannel: false, - infoPaneChannelKey: null, - messages, - messagesLoading: false, - loadingOlder: false, - hasOlderMessages: false, - hasNewerMessages: false, - loadingNewer: false, - handleOpenNewMessage: vi.fn(), - handleToggleCracker: vi.fn(), - markAllRead: vi.fn(async () => {}), - handleSortOrderChange: vi.fn(async () => {}), - handleSelectConversationWithTargetReset: vi.fn(), - handleNavigateToMessage: vi.fn(), - handleSaveConfig: vi.fn(async () => {}), - handleSaveAppSettings: vi.fn(async () => {}), - handleSetPrivateKey: vi.fn(async () => {}), - handleReboot: vi.fn(async () => {}), - handleAdvertise: vi.fn(async () => {}), - handleHealthRefresh: vi.fn(async () => {}), - fetchAppSettings: vi.fn(async () => {}), - setChannels: vi.fn(), - fetchUndecryptedCount: vi.fn(async () => {}), - handleCreateContact: vi.fn(async () => {}), - handleCreateChannel: vi.fn(async () => {}), - handleCreateHashtagChannel: vi.fn(async () => {}), - handleDeleteContact: vi.fn(async () => {}), - handleDeleteChannel: vi.fn(async () => {}), - handleToggleFavorite: vi.fn(async () => {}), - handleSetChannelFloodScopeOverride: vi.fn(async () => {}), - handleOpenContactInfo: vi.fn(), - handleOpenChannelInfo: vi.fn(), - handleCloseContactInfo: vi.fn(), - handleCloseChannelInfo: vi.fn(), - handleSenderClick: vi.fn(), - handleResendChannelMessage: vi.fn(async () => {}), - handleTrace: vi.fn(async () => {}), - handleSendMessage: vi.fn(async () => {}), - fetchOlderMessages: vi.fn(async () => {}), - fetchNewerMessages: vi.fn(async () => {}), - jumpToBottom: vi.fn(), - setTargetMessageId: vi.fn(), - handleNavigateToChannel: vi.fn(), - handleBlockKey: vi.fn(async () => {}), - handleBlockName: vi.fn(async () => {}), - ...overrides, - }; -} - -describe('useAppShellProps', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('creates a cracked channel, refreshes channels, decrypts history, and refreshes undecrypted count', async () => { - mocks.api.createChannel.mockResolvedValue({ - key: '11'.repeat(16), - name: 'Found', - is_hashtag: false, - }); - mocks.api.getChannels.mockResolvedValue([ - publicChannel, - { ...publicChannel, key: '11'.repeat(16), name: 'Found' }, - ]); - mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 }); - - const args = createArgs(); - const { result } = renderHook(() => useAppShellProps(args)); - - await act(async () => { - await result.current.crackerProps.onChannelCreate('Found', '11'.repeat(16)); - }); - - expect(mocks.api.createChannel).toHaveBeenCalledWith('Found', '11'.repeat(16)); - expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); - expect(args.setChannels).toHaveBeenCalledWith([ - publicChannel, - { ...publicChannel, key: '11'.repeat(16), name: 'Found' }, - ]); - expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({ - key_type: 'channel', - channel_key: '11'.repeat(16), - }); - expect(args.fetchUndecryptedCount).toHaveBeenCalledTimes(1); - }); - - it('does not fail cracked channel creation when undecrypted count refresh rejects', async () => { - mocks.api.createChannel.mockResolvedValue({ - key: '22'.repeat(16), - name: 'Found', - is_hashtag: false, - }); - mocks.api.getChannels.mockResolvedValue([ - publicChannel, - { ...publicChannel, key: '22'.repeat(16), name: 'Found' }, - ]); - mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 }); - - const args = createArgs({ - fetchUndecryptedCount: vi.fn(async () => { - throw new Error('refresh failed'); - }), - }); - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { result } = renderHook(() => useAppShellProps(args)); - - await act(async () => { - await result.current.crackerProps.onChannelCreate('Found', '22'.repeat(16)); - }); - - expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({ - key_type: 'channel', - channel_key: '22'.repeat(16), - }); - expect(consoleError).toHaveBeenCalledWith( - 'Failed to refresh undecrypted count after cracked channel create:', - expect.any(Error) - ); - - consoleError.mockRestore(); - }); -}); From f650e0ab34e26408d0f03e916a02b0ce93febd3b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 23:55:17 -0700 Subject: [PATCH 25/27] Make all scripts executable --- scripts/all_quality.sh | 0 scripts/docker_ci.sh | 0 scripts/e2e.sh | 0 scripts/extended_quality.sh | 0 scripts/print_frontend_licenses.cjs | 0 scripts/publish.sh | 0 6 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/all_quality.sh mode change 100644 => 100755 scripts/docker_ci.sh mode change 100644 => 100755 scripts/e2e.sh mode change 100644 => 100755 scripts/extended_quality.sh mode change 100644 => 100755 scripts/print_frontend_licenses.cjs mode change 100644 => 100755 scripts/publish.sh diff --git a/scripts/all_quality.sh b/scripts/all_quality.sh old mode 100644 new mode 100755 diff --git a/scripts/docker_ci.sh b/scripts/docker_ci.sh old mode 100644 new mode 100755 diff --git a/scripts/e2e.sh b/scripts/e2e.sh old mode 100644 new mode 100755 diff --git a/scripts/extended_quality.sh b/scripts/extended_quality.sh old mode 100644 new mode 100755 diff --git a/scripts/print_frontend_licenses.cjs b/scripts/print_frontend_licenses.cjs old mode 100644 new mode 100755 diff --git a/scripts/publish.sh b/scripts/publish.sh old mode 100644 new mode 100755 From dc87fa42b2406504bf5a34e844213a868fb04585 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 10 Mar 2026 00:00:57 -0700 Subject: [PATCH 26/27] Update AGENTS.md --- AGENTS.md | 9 ++++++++ app/AGENTS.md | 12 +++++++++- frontend/AGENTS.md | 22 ++++++++++++------- .../AGENTS_packet_visualizer.md | 0 4 files changed, 34 insertions(+), 9 deletions(-) rename frontend/src/components/{ => visualizer}/AGENTS_packet_visualizer.md (100%) diff --git a/AGENTS.md b/AGENTS.md index 1b02d8a..c8cf8dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,15 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific 5. **Offline-capable**: Radio operates independently; server syncs when connected 6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection +## Code Ethos + +- Prefer fewer, stronger modules over many tiny wrapper files. +- Split code only when the new module owns a real invariant, workflow, or contract. +- Avoid "enterprise" indirection layers whose main job is forwarding, renaming, or prop bundling. +- For this repo, "locally dense but semantically obvious" is better than context scattered across many files. +- Use typed contracts at important boundaries such as API payloads, WebSocket events, and repository writes. +- Refactors should be behavior-preserving slices with tests around the moved seam, not aesthetic reshuffles. + ## Intentional Security Design Decisions The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities. diff --git a/app/AGENTS.md b/app/AGENTS.md index e44d0f5..cdd6542 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -11,6 +11,14 @@ Keep it aligned with `app/` source files and router behavior. - MeshCore Python library (`meshcore` from PyPI) - PyCryptodome +## Code Ethos + +- Prefer strong domain modules over layers of pass-through helpers. +- Split code when the new module owns real policy, not just a nicer name. +- Avoid wrapper services around globals unless they materially improve testability or reduce coupling. +- Keep workflows locally understandable; do not scatter one reasoning unit across several files without a clear contract. +- Typed write/read contracts are preferred over loose dict-shaped repository inputs. + ## Backend Map ```text @@ -19,7 +27,7 @@ app/ ├── config.py # Env-driven runtime settings ├── database.py # SQLite connection + base schema + migration runner ├── migrations.py # Schema migrations (SQLite user_version) -├── models.py # Pydantic request/response models +├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert) ├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout) ├── services/ # Shared orchestration/domain services │ ├── messages.py # Shared message creation, dedup, ACK application @@ -240,6 +248,8 @@ Main tables: - `contact_name_history` (tracks name changes over time) - `app_settings` +Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data. + `app_settings` fields in active model: - `max_radio_contacts` - `favorites` diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 68f9381..776ce3b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -16,6 +16,15 @@ Keep it aligned with `frontend/src` source code. - `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker) - Multibyte-aware decoder build published as `meshcore-decoder-multibyte-patch` +## Code Ethos + +- Prefer fewer, stronger modules over many thin wrappers. +- Split code only when the new hook/component owns a real invariant or workflow. +- Keep one reasoning unit readable in one place, even if that file is moderately large. +- Avoid dedicated files whose main job is pass-through, prop bundling, or renaming. +- For this repo, "locally dense but semantically obvious" is better than indirection-heavy "clean architecture". +- When refactoring, preserve behavior first and add tests around the seam being moved. + ## Frontend Map ```text @@ -37,12 +46,10 @@ frontend/src/ │ ├── index.ts # Central re-export of all hooks │ ├── useConversationActions.ts # Send/resend/trace/block conversation actions │ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state -│ ├── useConversationMessages.ts # Dedup/update helpers over the conversation timeline -│ ├── useConversationTimeline.ts # Fetch, cache restore, jump-target loading, pagination, reconcile +│ ├── useConversationMessages.ts # Conversation timeline loading, cache restore, jump-target loading, pagination, dedup, pending ACK buffering │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery │ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker) -│ ├── useAppShellProps.ts # AppShell child prop assembly + cracker create/decrypt flow │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) │ ├── useRadioControl.ts # Radio health/config state, reconnection │ ├── useAppSettings.ts # Settings, favorites, preferences migration @@ -154,7 +161,6 @@ frontend/src/ ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts ├── useConversationNavigation.test.ts - ├── useAppShellProps.test.ts ├── useAppShell.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts @@ -186,13 +192,13 @@ High-level state is delegated to hooks: - `useConversationRouter`: URL hash → active conversation routing - `useConversationNavigation`: search target, conversation selection reset, and info-pane state - `useConversationActions`: send/resend/trace/block handlers and channel override updates -- `useAppShellProps`: assembles the prop bundles passed into `AppShell` children, including the cracker-created-channel historical decrypt flow -- `useConversationMessages`: dedup/update helpers and pending ACK buffering -- `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile +- `useConversationMessages`: conversation switch loading, cache restore, jump-target loading, pagination, dedup/update helpers, and pending ACK buffering - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps - `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination - `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions) +`App.tsx` intentionally still does the final `AppShell` prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic. + `ConversationPane.tsx` owns the main active-conversation surface branching: - empty state - map view @@ -359,7 +365,7 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear - **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. - **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page. - **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results. -- **Jump-to-message**: `useConversationTimeline` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. +- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. - **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling. - **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page). diff --git a/frontend/src/components/AGENTS_packet_visualizer.md b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md similarity index 100% rename from frontend/src/components/AGENTS_packet_visualizer.md rename to frontend/src/components/visualizer/AGENTS_packet_visualizer.md From 73e717fbd8b1905c5b4eec93da661ce47655c07d Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 10 Mar 2026 00:04:04 -0700 Subject: [PATCH 27/27] Fix Load All button height --- frontend/src/components/RepeaterDashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 6d96f51..8dd13fe 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -70,7 +70,7 @@ export function RepeaterDashboard({ return (
{/* Header */} -
+
{conversation.name} {anyLoading ? 'Loading...' : 'Load All'}