28 Commits
3.1.1 ... 3.2.0

Author SHA1 Message Date
Jack Kingsman
08e00373aa Updating changelog + build for 3.2.0 2026-03-12 11:55:56 -07:00
Jack Kingsman
2f0d35748a Deepend contrast on map colors. Fixes #54 2026-03-12 11:39:42 -07:00
Jack Kingsman
7db2974481 Fix bot kwargs and scoot over unread button to middle 2026-03-12 10:58:38 -07:00
Jack Kingsman
0a20929df6 Add conversation unread marker and jump-to-unread button 2026-03-12 10:54:25 -07:00
Jack Kingsman
30f6f95d8e Add path_bytes_per_hop to bot kwargs and encourage kwargs-only going forward 2026-03-12 10:32:33 -07:00
Jack Kingsman
9e8cf56b31 Be clearer about reality of location inclusion. Closes #53 2026-03-12 10:00:00 -07:00
Jack Kingsman
fb535298be Fix up search pane and header key alignment regression 2026-03-12 00:35:05 -07:00
Jack Kingsman
1f2903fc2d Add better preview pane and tweak some themes for contrast 2026-03-12 00:06:33 -07:00
Jack Kingsman
6466a5c355 Add node GPS enablement + sourcing. Closes #53. 2026-03-11 22:42:41 -07:00
Jack Kingsman
f8e88b3737 Docs tune up 2026-03-11 22:05:59 -07:00
Jack Kingsman
a13e241636 Other connected clients get new chans over WS 2026-03-11 22:02:05 -07:00
Jack Kingsman
8c1a58b293 Don't use a stale MC instance 2026-03-11 21:57:59 -07:00
Jack Kingsman
bf53e8a4cb Clarify message for fanout 2026-03-11 21:51:22 -07:00
Jack Kingsman
c6cd209192 Change direct trace icon 2026-03-11 21:40:09 -07:00
Jack Kingsman
e5c7ebb388 Tweak the win95 theme coloring a bit 2026-03-11 21:05:55 -07:00
Jack Kingsman
e37632de3f Add dedupe bug the agents keep getting hung up on to errata 2026-03-11 20:58:29 -07:00
Jack Kingsman
d36c5e3e32 Deal with reconciliation conflict from colliding WS fetches 2026-03-11 20:52:48 -07:00
Jack Kingsman
bc7506b0d9 Some misc frontend cleanup grossness 2026-03-11 20:49:37 -07:00
Jack Kingsman
38c7277c9d More careful guards around channel message key matching on collision 2026-03-11 20:41:31 -07:00
Jack Kingsman
20d0bd92bb Oh god, so much code for such a minor flow. Ambiguous sender manually fetched prefix DMs are now visible. 2026-03-11 20:38:41 -07:00
Jack Kingsman
e0df30b5f0 AGENTS.md fixups 2026-03-11 19:44:29 -07:00
Jack Kingsman
83635845b6 Don't sleep in the exception handler 2026-03-11 19:32:54 -07:00
Jack Kingsman
2e705538fd Abort serch requests on unmount 2026-03-11 19:26:27 -07:00
Jack Kingsman
4363fd2a73 Track message reconciliation and don't fire on stale returns 2026-03-11 19:20:38 -07:00
Jack Kingsman
5bd3205de5 Stop channs with blocked senders bothering the unread count 2026-03-11 19:11:53 -07:00
Jack Kingsman
bcde3bd9d5 Don't construct synthetic objects 2026-03-11 19:04:57 -07:00
Jack Kingsman
15a8c637e4 Do our own tracking for contact-on-radio with more correct return state 2026-03-11 19:01:14 -07:00
Jack Kingsman
d38efc0421 Add warning on search for user-key linkage unreliability 2026-03-11 18:45:03 -07:00
78 changed files with 3085 additions and 452 deletions

View File

@@ -22,7 +22,7 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
## Architecture Overview
@@ -290,14 +290,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` |
| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| GET | `/api/contacts` | List contacts |
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| GET | `/api/contacts/name-detail` | Channel activity summary for a sender name without a resolved key |
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
@@ -459,3 +462,9 @@ Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.

View File

@@ -1,3 +1,14 @@
## [3.2.0] - 2026-03-12
Feature: Improve ambiguous-sender DM handling and visibility
Feature: Allow for toggling of node GPS broadcast
Feature: Add path width to bot and move example to full kwargs
Feature: Improve node map color contrast
Bugfix: More accurate tracking of contact data
Bugfix: Misc. frontend performance and bugfixes
Misc: Clearer warnings on user-key linkage
Misc: Documentation improvements
## [3.1.1] - 2026-03-11
Feature: Add basic auth

View File

@@ -48,6 +48,7 @@ app/
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── path_utils.py # Path hex rendering and hop-width helpers
├── region_scope.py # Normalize/validate regional flood-scope values
├── keystore.py # Ephemeral private/public key storage for DM decryption
├── frontend_static.py # Mount/serve built frontend (production)
└── routers/
@@ -145,16 +146,19 @@ app/
- `GET /health`
### Radio
- `GET /radio/config` — includes `path_hash_mode` and `path_hash_mode_supported`
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `PUT /radio/private-key`
- `POST /radio/advertise`
- `POST /radio/disconnect`
- `POST /radio/reboot`
- `POST /radio/reconnect`
### Contacts
- `GET /contacts`
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `GET /contacts/name-detail` — name-only activity summary for unresolved channel senders
- `GET /contacts/{public_key}`
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
@@ -293,16 +297,20 @@ tests/
├── test_api.py # REST endpoint integration tests
├── test_bot.py # Bot execution and sandboxing
├── test_channels_router.py # Channels router endpoints
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
├── test_config.py # Configuration validation
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
├── test_contacts_router.py # Contacts router endpoints
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
├── test_key_normalization.py # Public key normalization
├── test_keystore.py # Ephemeral keystore
├── test_message_pagination.py # Cursor-based message pagination
@@ -315,6 +323,7 @@ tests/
├── 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_radio_runtime_service.py # radio_runtime seam behavior and helpers
├── test_real_crypto.py # Real cryptographic operations
├── test_radio_operation.py # radio_operation() context manager
├── test_radio_router.py # Radio router endpoints
@@ -324,11 +333,10 @@ tests/
├── test_rx_log_data.py # on_rx_log_data event handler integration
├── test_messages_search.py # Message search, around, forward pagination
├── test_block_lists.py # Blocked keys/names filtering
├── test_security.py # Optional Basic Auth middleware / config behavior
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_statistics.py # Statistics aggregation
├── test_channel_sender_backfill.py # Sender key backfill for channel messages
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
├── test_main_startup.py # App startup and lifespan
├── test_path_utils.py # Path hex rendering helpers
├── test_websocket.py # WS manager broadcast/cleanup

View File

@@ -13,6 +13,7 @@ from app.repository import (
from app.services import dm_ack_tracker
from app.services.contact_reconciliation import (
claim_prefix_messages_for_contact,
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
@@ -88,6 +89,20 @@ async def on_contact_message(event: "Event") -> None:
sender_pubkey[:12],
)
return
elif sender_pubkey:
placeholder_upsert = ContactUpsert(
public_key=sender_pubkey.lower(),
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
out_path_hash_mode=-1,
)
await ContactRepository.upsert(placeholder_upsert)
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
if contact:
broadcast_event("contact", contact.model_dump())
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
@@ -231,6 +246,10 @@ async def on_new_contact(event: "Event") -> None:
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)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
adv_name = payload.get("adv_name")
await record_contact_name_and_reconcile(
@@ -251,6 +270,15 @@ async def on_new_contact(event: "Event") -> None:
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
),
)
if db_contact:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": db_contact.model_dump(),
},
)
async def on_ack(event: "Event") -> None:

View File

@@ -16,6 +16,7 @@ WsEventType = Literal[
"health",
"message",
"contact",
"contact_resolved",
"channel",
"contact_deleted",
"channel_deleted",
@@ -30,6 +31,11 @@ class ContactDeletedPayload(TypedDict):
public_key: str
class ContactResolvedPayload(TypedDict):
previous_public_key: str
contact: Contact
class ChannelDeletedPayload(TypedDict):
key: str
@@ -49,6 +55,7 @@ WsEventPayload = (
HealthResponse
| Message
| Contact
| ContactResolvedPayload
| Channel
| ContactDeletedPayload
| ChannelDeletedPayload
@@ -61,6 +68,7 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
"health": TypeAdapter(HealthResponse),
"message": TypeAdapter(Message),
"contact": TypeAdapter(Contact),
"contact_resolved": TypeAdapter(ContactResolvedPayload),
"channel": TypeAdapter(Channel),
"contact_deleted": TypeAdapter(ContactDeletedPayload),
"channel_deleted": TypeAdapter(ChannelDeletedPayload),

View File

@@ -10,6 +10,36 @@ from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
"""Derive hop width from the first serialized message path when possible."""
if not isinstance(path_value, str) or not path_value:
return None
if not isinstance(paths, list) or not paths:
return None
first_path = paths[0]
if not isinstance(first_path, dict):
return None
path_hops = first_path.get("path_len")
if not isinstance(path_hops, int) or path_hops <= 0:
return None
path_hex_chars = len(path_value)
if path_hex_chars % 2 != 0:
return None
path_bytes = path_hex_chars // 2
if path_bytes % path_hops != 0:
return None
hop_width = path_bytes // path_hops
if hop_width not in (1, 2, 3):
return None
return hop_width
class BotModule(FanoutModule):
"""Wraps a single bot's code execution and response routing.
@@ -101,11 +131,11 @@ class BotModule(FanoutModule):
sender_timestamp = data.get("sender_timestamp")
path_value = data.get("path")
paths = data.get("paths")
# Message model serializes paths as list of dicts; extract first path string
if path_value is None:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
@@ -130,6 +160,7 @@ class BotModule(FanoutModule):
sender_timestamp,
path_value,
is_outgoing,
path_bytes_per_hop,
),
timeout=BOT_EXECUTION_TIMEOUT,
)

View File

@@ -15,6 +15,7 @@ import inspect
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any
from fastapi import HTTPException
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
_last_bot_send_time: float = 0.0
@dataclass(frozen=True)
class BotCallPlan:
"""How to call a validated bot() function."""
call_style: str
keyword_args: tuple[str, ...] = ()
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
"""Validate bot() signature and return a supported call plan."""
try:
sig = (
bot_func_or_sig
if isinstance(bot_func_or_sig, inspect.Signature)
else inspect.signature(bot_func_or_sig)
)
except (ValueError, TypeError) as exc:
raise ValueError("Bot function signature could not be inspected") from exc
params = sig.parameters
param_values = tuple(params.values())
positional_params = [
p
for p in param_values
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
explicit_optional_names = tuple(
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
)
unsupported_required_kwonly = [
p.name
for p in param_values
if p.kind == inspect.Parameter.KEYWORD_ONLY
and p.default is inspect.Parameter.empty
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
]
if unsupported_required_kwonly:
raise ValueError(
"Bot function signature is not supported. Unsupported required keyword-only "
"parameters: " + ", ".join(unsupported_required_kwonly)
)
positional_capacity = len(positional_params)
base_args = [object()] * 8
base_keyword_args: dict[str, object] = {
"sender_name": object(),
"sender_key": object(),
"message_text": object(),
"is_dm": object(),
"channel_key": object(),
"channel_name": object(),
"sender_timestamp": object(),
"path": object(),
}
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
keyword_args = dict(base_keyword_args)
if has_kwargs or "is_outgoing" in params:
keyword_args["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
keyword_args["path_bytes_per_hop"] = 1
candidate_specs.append(("keyword", [], keyword_args))
if not has_kwargs and explicit_optional_names:
kwargs: dict[str, object] = {}
if has_kwargs or "is_outgoing" in params:
kwargs["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
kwargs["path_bytes_per_hop"] = 1
candidate_specs.append(("mixed_keyword", base_args, kwargs))
if has_varargs or positional_capacity >= 10:
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
if has_varargs or positional_capacity >= 9:
candidate_specs.append(("positional_9", base_args + [False], {}))
if has_varargs or positional_capacity >= 8:
candidate_specs.append(("legacy", base_args, {}))
for call_style, args, kwargs in candidate_specs:
try:
sig.bind(*args, **kwargs)
except TypeError:
continue
if call_style in {"keyword", "mixed_keyword"}:
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
return BotCallPlan(call_style=call_style)
raise ValueError(
"Bot function signature is not supported. Use the default bot template as a reference. "
"Supported trailing parameters are: path; path + is_outgoing; "
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
"or use **kwargs for forward compatibility."
)
def execute_bot_code(
code: str,
sender_name: str | None,
@@ -50,17 +147,19 @@ def execute_bot_code(
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
path_bytes_per_hop: int | None = None,
) -> str | list[str] | None:
"""
Execute user-provided bot code with message context.
The code should define a function:
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
or use named parameters / `**kwargs`.
that returns either None (no response), a string (single response message),
or a list of strings (multiple messages sent in order).
Legacy bot functions with 8 parameters (without is_outgoing) are detected
via inspect and called without the new parameter for backward compatibility.
Legacy bot functions with older signatures are detected via inspect and
called without the newer parameters for backward compatibility.
Args:
code: Python code defining the bot function
@@ -73,6 +172,7 @@ def execute_bot_code(
sender_timestamp: Sender's timestamp from the message (may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
Returns:
Response string, list of strings, or None.
@@ -100,30 +200,28 @@ def execute_bot_code(
return None
bot_func = namespace["bot"]
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
# or uses the legacy 8-param signature, for backward compatibility.
# Three cases: explicit is_outgoing param or 9+ params (positional),
# **kwargs (pass as keyword), or legacy 8-param (omit).
call_style = "legacy" # "positional", "keyword", or "legacy"
try:
sig = inspect.signature(bot_func)
params = sig.parameters
non_variadic = [
p
for p in params.values()
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
]
if "is_outgoing" in params or len(non_variadic) >= 9:
call_style = "positional"
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
call_style = "keyword"
except (ValueError, TypeError):
pass
call_plan = _analyze_bot_signature(bot_func)
except ValueError as exc:
logger.error("%s", exc)
return None
try:
# Call the bot function with appropriate signature
if call_style == "positional":
if call_plan.call_style == "positional_10":
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
)
elif call_plan.call_style == "positional_9":
result = bot_func(
sender_name,
sender_key,
@@ -135,18 +233,29 @@ def execute_bot_code(
path,
is_outgoing,
)
elif call_style == "keyword":
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing=is_outgoing,
)
elif call_plan.call_style == "keyword":
keyword_args: dict[str, Any] = {}
if "sender_name" in call_plan.keyword_args:
keyword_args["sender_name"] = sender_name
if "sender_key" in call_plan.keyword_args:
keyword_args["sender_key"] = sender_key
if "message_text" in call_plan.keyword_args:
keyword_args["message_text"] = message_text
if "is_dm" in call_plan.keyword_args:
keyword_args["is_dm"] = is_dm
if "channel_key" in call_plan.keyword_args:
keyword_args["channel_key"] = channel_key
if "channel_name" in call_plan.keyword_args:
keyword_args["channel_name"] = channel_name
if "sender_timestamp" in call_plan.keyword_args:
keyword_args["sender_timestamp"] = sender_timestamp
if "path" in call_plan.keyword_args:
keyword_args["path"] = path
if "is_outgoing" in call_plan.keyword_args:
keyword_args["is_outgoing"] = is_outgoing
if "path_bytes_per_hop" in call_plan.keyword_args:
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
result = bot_func(**keyword_args)
else:
result = bot_func(
sender_name,

View File

@@ -40,7 +40,10 @@ from app.repository import (
ContactRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import record_contact_name_and_reconcile
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
@@ -504,6 +507,10 @@ async def _process_advertisement(
)
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
)
await record_contact_name_and_reconcile(
public_key=advert.public_key,
contact_name=advert.name,
@@ -516,6 +523,14 @@ async def _process_advertisement(
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
if db_contact:
broadcast_event("contact", db_contact.model_dump())
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": db_contact.model_dump(),
},
)
else:
broadcast_event(
"contact",

View File

@@ -339,7 +339,8 @@ async def ensure_default_channels() -> None:
Ensure default channels exist in the database.
These will be configured on the radio when needed for sending.
The Public channel is protected - it always exists with the canonical name.
This seeds the canonical Public channel row in the database if it is missing
or misnamed. It does not make the channel undeletable through the router.
"""
# Public channel - no hashtag, specific well-known key
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
@@ -604,7 +605,6 @@ async def _periodic_advert_loop():
break
except Exception as e:
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
def start_periodic_advert():
@@ -733,6 +733,12 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
continue
if not contact:
continue
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
favorite.id,
)
continue
key = contact.public_key.lower()
if key in selected_keys:
continue
@@ -802,6 +808,9 @@ async def ensure_contact_on_radio(
if not contact:
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
return {"loaded": 0, "error": "Contact not found"}
if len(contact.public_key) < 64:
logger.debug("Cannot sync unresolved prefix-only contact %s to radio", public_key)
return {"loaded": 0, "error": "Full contact key not yet known"}
if mc is not None:
_last_contact_sync = now
@@ -834,6 +843,11 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
failed = 0
for contact in contacts:
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only contact %s during radio load", contact.public_key
)
continue
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
already_on_radio += 1

View File

@@ -168,6 +168,9 @@ class ContactRepository:
the prefix (to avoid silently selecting the wrong contact).
"""
normalized_prefix = prefix.lower()
exact = await ContactRepository.get_by_key(normalized_prefix)
if exact:
return exact
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
(f"{normalized_prefix}%",),
@@ -258,7 +261,7 @@ class ContactRepository:
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2 AND last_contacted IS NOT NULL
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
ORDER BY last_contacted DESC
LIMIT ?
""",
@@ -273,7 +276,7 @@ class ContactRepository:
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2 AND last_advert IS NOT NULL
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
ORDER BY last_advert DESC
LIMIT ?
""",
@@ -406,6 +409,103 @@ class ContactRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def promote_prefix_placeholders(full_key: str) -> list[str]:
"""Promote prefix-only placeholder contacts to a resolved full key.
Returns the placeholder public keys that were merged into the full key.
"""
normalized_full_key = full_key.lower()
cursor = await db.conn.execute(
"""
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
FROM contacts
WHERE length(public_key) < 64
AND ? LIKE public_key || '%'
ORDER BY length(public_key) DESC, public_key
""",
(normalized_full_key,),
)
rows = list(await cursor.fetchall())
if not rows:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
if old_key == normalized_full_key:
continue
match_cursor = await db.conn.execute(
"""
SELECT COUNT(*) AS match_count
FROM contacts
WHERE length(public_key) = 64
AND public_key LIKE ? || '%'
""",
(old_key,),
)
match_row = await match_cursor.fetchone()
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
promoted_keys.append(old_key)
await db.conn.commit()
return promoted_keys
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all contacts as read at the given timestamp."""

View File

@@ -162,7 +162,8 @@ class MessageRepository:
AND ? LIKE conversation_key || '%'
AND (
SELECT COUNT(*) FROM contacts
WHERE public_key LIKE messages.conversation_key || '%'
WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%'
) = 1""",
(lower_key, lower_key),
)
@@ -179,8 +180,16 @@ class MessageRepository:
"""
cursor = await db.conn.execute(
"""UPDATE messages SET sender_key = ?
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
(public_key.lower(), name),
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL
AND (
SELECT COUNT(*) FROM contacts
WHERE name = ?
) = 1
AND EXISTS (
SELECT 1 FROM contacts
WHERE public_key = ? AND name = ?
)""",
(public_key.lower(), name, name, public_key.lower(), name),
)
await db.conn.commit()
return cursor.rowcount
@@ -652,13 +661,61 @@ class MessageRepository:
if mention_token and row["has_mention"]:
mention_flags[state_key] = True
# Last message times for all conversations (including read ones)
# Last message times for all conversations (including read ones),
# excluding blocked incoming traffic so refresh matches live WS behavior.
last_time_filters: list[str] = []
last_time_params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
last_time_filters.append(
f"""
NOT (
type = 'PRIV'
AND outgoing = 0
AND LOWER(conversation_key) IN ({placeholders})
)
"""
)
last_time_params.extend(blocked_keys)
last_time_filters.append(
f"""
NOT (
type = 'CHAN'
AND outgoing = 0
AND sender_key IS NOT NULL
AND LOWER(sender_key) IN ({placeholders})
)
"""
)
last_time_params.extend(blocked_keys)
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
last_time_filters.append(
f"""
NOT (
type = 'CHAN'
AND outgoing = 0
AND sender_name IS NOT NULL
AND sender_name IN ({placeholders})
)
"""
)
last_time_params.extend(blocked_names)
last_time_where_sql = (
f"WHERE {' AND '.join(last_time_filters)}" if last_time_filters else ""
)
cursor = await db.conn.execute(
"""
f"""
SELECT type, conversation_key, MAX(received_at) as last_message_time
FROM messages
{last_time_where_sql}
GROUP BY type, conversation_key
"""
""",
last_time_params,
)
rows = await cursor.fetchall()
for row in rows:

View File

@@ -17,6 +17,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
def _broadcast_channel_update(channel: Channel) -> None:
broadcast_event("channel", channel.model_dump())
class CreateChannelRequest(BaseModel):
name: str = Field(min_length=1, max_length=32)
key: str | None = Field(
@@ -98,13 +102,12 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
on_radio=False,
)
return Channel(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
flood_scope_override=None,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
_broadcast_channel_update(stored)
return stored
@router.post("/sync")
@@ -123,6 +126,9 @@ async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, l
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
if key_hex is not None:
count += 1
stored = await ChannelRepository.get_by_key(key_hex)
if stored is not None:
_broadcast_channel_update(stored)
logger.debug(
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
)

View File

@@ -28,7 +28,10 @@ from app.repository import (
ContactRepository,
MessageRepository,
)
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
@@ -93,6 +96,19 @@ async def _broadcast_contact_update(contact: Contact) -> None:
broadcast_event("contact", contact.model_dump())
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
from app.websocket import broadcast_event
for old_key in previous_public_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": contact.model_dump(),
},
)
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
@@ -257,12 +273,23 @@ async def create_contact(
if refreshed is not None:
existing = refreshed
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
log=logger,
)
if promoted_keys:
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await _broadcast_contact_resolution(promoted_keys, existing)
# Trigger historical decryption if requested (even for existing contacts)
if request.try_historical:
await start_historical_dm_decryption(
background_tasks, request.public_key, request.name or existing.name
)
await _broadcast_contact_update(existing)
return existing
# Create new contact
@@ -275,6 +302,10 @@ async def create_contact(
)
await ContactRepository.upsert(contact_upsert)
logger.info("Created contact %s", lower_key[:12])
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=lower_key,
log=logger,
)
await reconcile_contact_messages(
public_key=lower_key,
@@ -286,7 +317,12 @@ async def create_contact(
if request.try_historical:
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
return Contact(**contact_upsert.model_dump())
stored = await ContactRepository.get_by_key(lower_key)
if stored is None:
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
await _broadcast_contact_update(stored)
await _broadcast_contact_resolution(promoted_keys, stored)
return stored
@router.get("/{public_key}/detail", response_model=ContactDetail)
@@ -365,12 +401,20 @@ async def sync_contacts_from_radio() -> dict:
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=lower_key,
log=logger,
)
synced_keys.append(lower_key)
await reconcile_contact_messages(
public_key=lower_key,
contact_name=contact_data.get("adv_name"),
log=logger,
)
stored = await ContactRepository.get_by_key(lower_key)
if stored is not None:
await _broadcast_contact_update(stored)
await _broadcast_contact_resolution(promoted_keys, stored)
count += 1
# Clear on_radio for contacts not found on the radio
@@ -419,6 +463,7 @@ async def add_contact_to_radio(public_key: str) -> dict:
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await ContactRepository.set_on_radio(contact.public_key, True)
return {"status": "ok", "message": "Contact already on radio"}
logger.info("Adding contact %s to radio", contact.public_key[:12])

View File

@@ -1,5 +1,7 @@
"""REST API for fanout config CRUD."""
import ast
import inspect
import logging
import re
import string
@@ -8,6 +10,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.fanout.bot_exec import _analyze_bot_signature
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
@@ -144,18 +147,78 @@ def _validate_mqtt_community_config(config: dict) -> None:
def _validate_bot_config(config: dict) -> None:
"""Validate bot config blob (syntax-check the code)."""
"""Validate bot config blob (syntax-check the code and supported signature)."""
code = config.get("code", "")
if not code or not code.strip():
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
try:
compile(code, "<bot_code>", "exec")
tree = ast.parse(code, filename="<bot_code>", mode="exec")
except SyntaxError as e:
raise HTTPException(
status_code=400,
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
) from None
bot_def = next(
(
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
),
None,
)
if bot_def is None:
raise HTTPException(
status_code=400,
detail=(
"Bot code must define a callable bot() function. "
"Use the default bot template as a reference."
),
)
try:
parameters: list[inspect.Parameter] = []
positional_args = [
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
]
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
sentinel_default = object()
for index, (arg, kind) in enumerate(positional_args):
has_default = index >= positional_defaults_start
parameters.append(
inspect.Parameter(
arg.arg,
kind=kind,
default=sentinel_default if has_default else inspect.Parameter.empty,
)
)
if bot_def.args.vararg is not None:
parameters.append(
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
)
for kwonly_arg, kw_default in zip(
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
):
parameters.append(
inspect.Parameter(
kwonly_arg.arg,
kind=inspect.Parameter.KEYWORD_ONLY,
default=(
sentinel_default if kw_default is not None else inspect.Parameter.empty
),
)
)
if bot_def.args.kwarg is not None:
parameters.append(
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
)
_analyze_bot_signature(inspect.Signature(parameters))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
def _validate_apprise_config(config: dict) -> None:
"""Validate apprise config blob."""

View File

@@ -108,6 +108,11 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
raise HTTPException(
status_code=404, detail=f"Contact not found in database: {request.destination}"
)
if len(db_contact.public_key) < 64:
raise HTTPException(
status_code=409,
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
)
return await send_direct_message_to_contact(
contact=db_contact,

View File

@@ -1,4 +1,5 @@
import logging
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
@@ -19,6 +20,8 @@ from app.websocket import broadcast_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
@@ -51,6 +54,10 @@ class RadioConfigResponse(BaseModel):
path_hash_mode_supported: bool = Field(
default=False, description="Whether firmware supports path hash mode setting"
)
advert_location_source: AdvertLocationSource = Field(
default="current",
description="Whether adverts include the node's current location state",
)
class RadioConfigUpdate(BaseModel):
@@ -65,6 +72,10 @@ class RadioConfigUpdate(BaseModel):
le=2,
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
)
advert_location_source: AdvertLocationSource | None = Field(
default=None,
description="Whether adverts include the node's current location state",
)
class PrivateKeyUpdate(BaseModel):
@@ -80,6 +91,9 @@ async def get_radio_config() -> RadioConfigResponse:
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
adv_loc_policy = info.get("adv_loc_policy", 1)
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
@@ -95,6 +109,7 @@ async def get_radio_config() -> RadioConfigResponse:
),
path_hash_mode=radio_manager.path_hash_mode,
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source,
)

View File

@@ -2,11 +2,29 @@
import logging
from app.repository import ContactNameHistoryRepository, MessageRepository
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
logger = logging.getLogger(__name__)
async def promote_prefix_contacts_for_contact(
*,
public_key: str,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> list[str]:
"""Promote prefix-only placeholder contacts once a full key is known."""
normalized_key = public_key.lower()
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
if promoted:
(log or logger).info(
"Promoted %d prefix contact placeholder(s) for %s",
len(promoted),
normalized_key[:12],
)
return promoted
async def claim_prefix_messages_for_contact(
*,
public_key: str,

View File

@@ -206,7 +206,6 @@ async def send_channel_message_to_channel(
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
@@ -235,27 +234,27 @@ async def send_channel_message_to_channel(
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:
if now is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
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
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,

View File

@@ -32,6 +32,18 @@ async def apply_radio_config_update(
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
) -> None:
"""Apply a validated radio-config update to the connected radio."""
if update.advert_location_source is not None:
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
logger.info(
"Setting advert location policy to %s",
update.advert_location_source,
)
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set advert location policy: {result.payload}"
)
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)

View File

@@ -32,16 +32,19 @@ async def run_post_connect_setup(radio_manager) -> None:
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:
mc = radio_manager.meshcore
if not mc:
return
# Register event handlers against the locked, current transport.
register_event_handlers(mc)
await export_and_store_private_key(mc)
# Sync radio clock with system time

View File

@@ -74,6 +74,7 @@ frontend/src/
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
@@ -105,6 +106,7 @@ frontend/src/
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
@@ -136,9 +138,13 @@ frontend/src/
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── contactAvatar.test.ts
├── contactInfoPane.test.tsx
├── integration.test.ts
├── mapView.test.tsx
├── messageCache.test.ts
├── messageList.test.tsx
├── messageParser.test.ts
├── rawPacketList.test.tsx
├── pathUtils.test.ts
├── prefetch.test.ts
├── radioPresets.test.ts
@@ -152,12 +158,14 @@ frontend/src/
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── statusBar.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
├── channelInfoKeyVisibility.test.tsx
├── chatHeaderKeyVisibility.test.tsx
├── searchView.test.tsx
├── useConversationActions.test.ts
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
@@ -240,13 +248,14 @@ High-level state is delegated to hooks:
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
## WebSocket (`useWebSocket.ts`)
- 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.
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
- 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.

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.1.1",
"version": "3.2.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -18,7 +18,51 @@ import {
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { messageContainsMention } from './utils/messageParser';
import type { Conversation, RawPacket } from './types';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
interface ChannelUnreadMarker {
channelId: string;
lastReadAt: number | null;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
}
export function getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
}: UnreadBoundaryBackfillParams): string | null {
if (activeConversation?.type !== 'channel') return null;
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
if (unreadMarker.lastReadAt === null) return null;
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
const oldestLoadedMessage = 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 (!oldestLoadedMessage) return null;
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
}
export function App() {
const quoteSearchOperatorValue = useCallback((value: string) => {
@@ -27,6 +71,8 @@ export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
notificationsSupported,
notificationsPermission,
@@ -192,11 +238,67 @@ export function App() {
mentions,
lastMessageTimes,
incrementUnread,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null);
return;
}
const activeChannelId = activeConversation.id;
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
setChannelUnreadMarker((prev) => {
if (prev?.channelId === activeChannelId) {
return prev;
}
if (activeChannelUnreadCount <= 0) {
return null;
}
const activeChannel = channels.find((channel) => channel.key === activeChannelId);
return {
channelId: activeChannelId,
lastReadAt: activeChannel?.last_read_at ?? null,
};
});
}, [activeConversation, channels, unreadCounts]);
useEffect(() => {
lastUnreadBackfillAttemptRef.current = null;
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
useEffect(() => {
const backfillKey = getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker: channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
});
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
return;
}
lastUnreadBackfillAttemptRef.current = backfillKey;
void fetchOlderMessages();
}, [
activeConversation,
channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
fetchOlderMessages,
]);
const wsHandlers = useRealtimeAppState({
prevHealthRef,
setHealth,
@@ -214,6 +316,7 @@ export function App() {
addMessageIfNew,
trackNewMessage,
incrementUnread,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -292,6 +395,11 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt:
activeConversation?.type === 'channel' &&
channelUnreadMarker?.channelId === activeConversation.id
? channelUnreadMarker.lastReadAt
: undefined,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -310,6 +418,7 @@ export function App() {
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
onDismissUnreadMarker: () => setChannelUnreadMarker(null),
notificationsSupported,
notificationsPermission,
notificationsEnabled:

View File

@@ -178,7 +178,14 @@ export function AppShell({
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
<Sheet open={sidebarOpen} onOpenChange={onSidebarOpenChange}>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetContent
side="left"
className="w-[280px] p-0 flex flex-col"
hideCloseButton
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Bell, Globe2, Info, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
@@ -46,6 +48,8 @@ export function ChatHeader({
onOpenChannelInfo,
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
const [contactStatusInline, setContactStatusInline] = useState(true);
const keyTextRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
@@ -62,6 +66,13 @@ export function ChatHeader({
: null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const activeContact =
conversation.type === 'contact'
? contacts.find((contact) => contact.public_key === conversation.id)
: null;
const activeContactIsPrefixOnly = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
@@ -85,15 +96,60 @@ export function ChatHeader({
onSetChannelFloodScopeOverride(conversation.id, nextValue);
};
const handleOpenConversationInfo = () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
return;
}
if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
};
useEffect(() => {
if (conversation.type !== 'contact') {
setContactStatusInline(true);
return;
}
const measure = () => {
const keyElement = keyTextRef.current;
if (!keyElement) return;
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
setContactStatusInline(!isTruncated);
};
measure();
const onResize = () => {
window.requestAnimationFrame(measure);
};
window.addEventListener('resize', onResize);
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
window.requestAnimationFrame(measure);
});
if (keyTextRef.current?.parentElement) {
observer.observe(keyTextRef.current.parentElement);
}
}
return () => {
window.removeEventListener('resize', onResize);
observer?.disconnect();
};
}, [conversation.id, conversation.type, showKey]);
return (
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
<button
type="button"
className="avatar-action-button flex-shrink-0 cursor-pointer rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
aria-label={`View info for ${conversation.name}`}
@@ -105,42 +161,41 @@ export function ChatHeader({
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
clickable
/>
</span>
</button>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
}
: undefined
}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
{titleClickable && (
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (
@@ -156,6 +211,7 @@ export function ChatHeader({
</button>
) : (
<span
ref={keyTextRef}
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
@@ -178,21 +234,25 @@ export function ChatHeader({
</span>
)}
</span>
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
if (!contact) return null;
return (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
<ContactStatusInfo
contact={contact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
);
})()}
{conversation.type === 'contact' && activeContact && contactStatusInline && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
@@ -214,12 +274,17 @@ export function ChatHeader({
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
>
<Route className="h-4 w-4" aria-hidden="true" />
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && (

View File

@@ -2,6 +2,11 @@ import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import {
getContactDisplayName,
isPrefixOnlyContact,
isUnknownFullKeyContact,
} from '../utils/pubkey';
import {
isValidLocation,
calculateDistance,
@@ -133,6 +138,11 @@ export function ContactInfoPane({
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
const isUnknownFullKeyResolvedContact =
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -249,7 +259,7 @@ export function ContactInfoPane({
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">
{contact.name || contact.public_key.slice(0, 12)}
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
@@ -278,6 +288,22 @@ export function ContactInfoPane({
</div>
</div>
{isPrefixOnlyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM
arrives before we hear an advertisement. This contact stays read-only until the full
key resolves from a later advertisement.
</div>
)}
{isUnknownFullKeyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
We know this sender&apos;s full key, but we have not yet heard an advertisement that
fills in their identity details. Those details will appear automatically when an
advertisement arrives.
</div>
)}
{/* Info grid */}
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">

View File

@@ -15,6 +15,7 @@ import type {
RadioConfig,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
const RepeaterDashboard = lazy(() =>
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
@@ -39,6 +40,7 @@ interface ConversationPaneProps {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
unreadMarkerLastReadAt?: number | null;
targetMessageId: number | null;
hasNewerMessages: boolean;
loadingNewer: boolean;
@@ -56,6 +58,7 @@ interface ConversationPaneProps {
onTargetReached: () => void;
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
}
@@ -66,6 +69,25 @@ function LoadingPane({ label }: { label: string }) {
);
}
function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'prefix-only' }) {
if (variant === 'prefix-only') {
return (
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM arrives
before we learn their full identity. This conversation is read-only until we hear an
advertisement that resolves the full key.
</div>
);
}
return (
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
A full identity profile is not yet available because we have not heard an advertisement from
this sender. The contact will fill in automatically when an advertisement arrives.
</div>
);
}
export function ConversationPane({
activeConversation,
contacts,
@@ -81,6 +103,7 @@ export function ConversationPane({
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -98,6 +121,7 @@ export function ConversationPane({
onTargetReached,
onLoadNewer,
onJumpToBottom,
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
@@ -106,6 +130,17 @@ export function ConversationPane({
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
const activeContact = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
}, [activeConversation, contacts]);
const isPrefixOnlyActiveContact = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const isUnknownFullKeyActiveContact =
activeContact !== null &&
!isPrefixOnlyActiveContact &&
isUnknownFullKeyContact(activeContact.public_key, activeContact.last_advert);
if (!activeConversation) {
return (
@@ -198,6 +233,12 @@ export function ConversationPane({
onOpenContactInfo={onOpenContactInfo}
onOpenChannelInfo={onOpenChannelInfo}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact && (
<ContactResolutionBanner variant="prefix-only" />
)}
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
<ContactResolutionBanner variant="unknown-full-key" />
)}
<MessageList
key={activeConversation.id}
messages={messages}
@@ -205,6 +246,12 @@ export function ConversationPane({
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
@@ -220,16 +267,20 @@ export function ConversationPane({
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected ? 'Radio not connected' : `Message ${activeConversation.name}...`
}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: `Message ${activeConversation.name}...`
}
/>
)}
</>
);
}

View File

@@ -0,0 +1,22 @@
interface DirectTraceIconProps {
className?: string;
}
export function DirectTraceIcon({ className }: DirectTraceIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M3 12h12" />
<circle cx="18" cy="12" r="3" />
</svg>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -13,17 +13,27 @@ interface MapViewProps {
focusedKey?: string | null;
}
const MAP_RECENCY_COLORS = {
recent: '#06b6d4',
today: '#2563eb',
stale: '#f59e0b',
old: '#64748b',
} as const;
const MAP_MARKER_STROKE = '#0f172a';
const MAP_REPEATER_RING = '#f8fafc';
// Calculate marker color based on how recently the contact was heard
function getMarkerColor(lastSeen: number): string {
function getMarkerColor(lastSeen: number | null | undefined): string {
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
const now = Date.now() / 1000;
const age = now - lastSeen;
const hour = 3600;
const day = 86400;
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
if (age < day) return '#4ade80'; // Light green - less than 1 day
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
return '#9ca3af'; // Gray - older (up to 7 days)
if (age < hour) return MAP_RECENCY_COLORS.recent;
if (age < day) return MAP_RECENCY_COLORS.today;
if (age < 3 * day) return MAP_RECENCY_COLORS.stale;
return MAP_RECENCY_COLORS.old;
}
// Component to handle map bounds fitting
@@ -94,16 +104,17 @@ function MapBoundsHandler({
}
export function MapView({ contacts, focusedKey }: MapViewProps) {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
// Filter to contacts with GPS coordinates, heard within the last 7 days.
// Always include the focused contact so "view on map" links work for older nodes.
const mappableContacts = useMemo(() => {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
return contacts.filter(
(c) =>
isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
);
}, [contacts, focusedKey]);
}, [contacts, focusedKey, sevenDaysAgo]);
// Find the focused contact by key
const focusedContact = useMemo(() => {
@@ -111,6 +122,10 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
return mappableContacts.find((c) => c.public_key === focusedKey) || null;
}, [focusedKey, mappableContacts]);
const includesFocusedOutsideWindow =
focusedContact != null &&
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
// Track marker refs to open popup programmatically
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
@@ -137,19 +152,48 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
<span>
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
in the last 7 days
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#22c55e]" aria-hidden="true" /> &lt;1h
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
aria-hidden="true"
/>{' '}
&lt;1h
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> &lt;1d
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
aria-hidden="true"
/>{' '}
&lt;1d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> &lt;3d
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
aria-hidden="true"
/>{' '}
&lt;3d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" aria-hidden="true" /> older
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
aria-hidden="true"
/>{' '}
older
</span>
<span className="flex items-center gap-1">
<span
className="w-3 h-3 rounded-full border-2"
style={{ borderColor: MAP_REPEATER_RING, backgroundColor: MAP_RECENCY_COLORS.today }}
aria-hidden="true"
/>{' '}
repeater
</span>
</div>
</div>
@@ -175,41 +219,46 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
{mappableContacts.map((contact) => {
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
const color = getMarkerColor(contact.last_seen!);
const color = getMarkerColor(contact.last_seen);
const displayName = contact.name || contact.public_key.slice(0, 12);
const lastHeardLabel =
contact.last_seen != null
? formatTime(contact.last_seen)
: 'Never heard by this server';
const radius = isRepeater ? 10 : 7;
return (
<CircleMarker
key={contact.public_key}
ref={(ref) => setMarkerRef(contact.public_key, ref)}
center={[contact.lat!, contact.lon!]}
radius={isRepeater ? 10 : 7}
pathOptions={{
color: isRepeater ? color : '#000',
fillColor: color,
fillOpacity: 0.8,
weight: isRepeater ? 0 : 1,
}}
>
<Popup>
<div className="text-sm">
<div className="font-medium flex items-center gap-1">
{isRepeater && (
<span title="Repeater" aria-hidden="true">
🛜
</span>
)}
{displayName}
<Fragment key={contact.public_key}>
<CircleMarker
key={contact.public_key}
ref={(ref) => setMarkerRef(contact.public_key, ref)}
center={[contact.lat!, contact.lon!]}
radius={radius}
pathOptions={{
color: isRepeater ? MAP_REPEATER_RING : MAP_MARKER_STROKE,
fillColor: color,
fillOpacity: 0.9,
weight: isRepeater ? 3 : 2,
}}
>
<Popup>
<div className="text-sm">
<div className="font-medium flex items-center gap-1">
{isRepeater && (
<span title="Repeater" aria-hidden="true">
🛜
</span>
)}
{displayName}
</div>
<div className="text-xs text-gray-500 mt-1">Last heard: {lastHeardLabel}</div>
<div className="text-xs text-gray-400 mt-1 font-mono">
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
</div>
</div>
<div className="text-xs text-gray-500 mt-1">
Last heard: {formatTime(contact.last_seen!)}
</div>
<div className="text-xs text-gray-400 mt-1 font-mono">
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
</div>
</div>
</Popup>
</CircleMarker>
</Popup>
</CircleMarker>
</Fragment>
);
})}
</MapContainer>

View File

@@ -150,7 +150,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
return (
<form
className="px-4 py-2.5 border-t border-border flex flex-col gap-1"
className="message-input-shell px-4 py-2.5 border-t border-border flex flex-col gap-1"
onSubmit={handleSubmit}
autoComplete="off"
>

View File

@@ -1,4 +1,5 @@
import {
Fragment,
useEffect,
useLayoutEffect,
useRef,
@@ -22,6 +23,8 @@ interface MessageListProps {
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
unreadMarkerLastReadAt?: number | null;
onDismissUnreadMarker?: () => void;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
@@ -172,6 +175,8 @@ export function MessageList({
loading,
loadingOlder = false,
hasOlderMessages = false,
unreadMarkerLastReadAt,
onDismissUnreadMarker,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
@@ -198,7 +203,12 @@ export function MessageList({
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
const targetScrolledRef = useRef(false);
const unreadMarkerRef = useRef<HTMLButtonElement | HTMLDivElement | null>(null);
const setUnreadMarkerElement = useCallback((node: HTMLButtonElement | HTMLDivElement | null) => {
unreadMarkerRef.current = node;
}, []);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -389,6 +399,18 @@ export function MessageList({
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages]
);
const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) {
return -1;
}
const boundary = unreadMarkerLastReadAt ?? 0;
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
}, [sortedMessages, unreadMarkerLastReadAt]);
useEffect(() => {
setShowJumpToUnread(unreadMarkerIndex !== -1);
}, [unreadMarkerIndex]);
// Sender info for outgoing messages (used by path modal on own messages)
const selfSenderInfo = useMemo<SenderInfo>(
@@ -606,97 +628,108 @@ export function MessageList({
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
}
}
const avatarActionLabel =
avatarName && avatarName !== 'Unknown'
? `View info for ${avatarName}`
: `View info for ${avatarKey.slice(0, 12)}`;
return (
<div
key={msg.id}
data-message-id={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<span
role={onOpenContactInfo ? 'button' : undefined}
tabIndex={onOpenContactInfo ? 0 : undefined}
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
onClick={
onOpenContactInfo
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
: undefined
}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable={!!onOpenContactInfo}
variant={avatarVariant}
/>
<Fragment key={msg.id}>
{unreadMarkerIndex === index &&
(onDismissUnreadMarker ? (
<button
ref={setUnreadMarkerElement}
type="button"
className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onDismissUnreadMarker}
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
)}
</div>
)}
<span className="h-px flex-1 bg-border" />
</button>
) : (
<div
ref={setUnreadMarkerElement}
className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
<span className="h-px flex-1 bg-border" />
</div>
))}
<div
data-message-id={msg.id}
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground ml-2">
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
)}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
@@ -705,11 +738,58 @@ export function MessageList({
}
/>
)}
</>
</div>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
</>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
@@ -718,48 +798,28 @@ export function MessageList({
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
isOutgoingChan: true,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: true,
});
}}
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
</div>
</div>
</div>
</div>
</Fragment>
);
})}
{loadingNewer && (
@@ -775,6 +835,20 @@ export function MessageList({
</div>
{/* Scroll to bottom button */}
{showJumpToUnread && (
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
<button
type="button"
onClick={() => {
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
setShowJumpToUnread(false);
}}
className="pointer-events-auto h-9 rounded-full bg-card hover:bg-accent border border-border px-3 text-sm font-medium shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Jump to unread
</button>
</div>
)}
{showScrollToBottom && (
<button
onClick={scrollToBottom}

View File

@@ -169,34 +169,40 @@ export function NewMessageModal({
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.length === 0 ? (
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
) : (
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key)}
</div>
))
contacts
.filter((contact) => contact.public_key.length === 64)
.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</div>
))
)}
</div>
</TabsContent>

View File

@@ -1,6 +1,7 @@
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -126,7 +127,7 @@ export function RepeaterDashboard({
title="Direct Trace"
aria-label="Direct Trace"
>
<Route className="h-4 w-4" aria-hidden="true" />
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
{notificationsSupported && (
<button

View File

@@ -123,6 +123,12 @@ export function SearchView({
inputRef.current?.focus();
}, [prefillRequest]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
// Fetch search results
useEffect(() => {
if (!debouncedQuery) {
@@ -243,6 +249,11 @@ export function SearchView({
Tip: use <code>user:</code> or <code>channel:</code> for keys or names, and wrap names
with spaces in them in quotes.
</p>
<p className="mt-2 text-xs">
Warning: User-key linkage for group messages is best-effort and based on correlation
at advertise time. It does not account for multiple users with the same name, and
should be considered unreliable.
</p>
</div>
)}

View File

@@ -5,9 +5,9 @@ import {
ChevronDown,
ChevronRight,
LockOpen,
Logs,
Map,
Search as SearchIcon,
Sparkles,
SquarePen,
Waypoints,
X,
@@ -416,7 +416,7 @@ export function Sidebar({
key: `${keyPrefix}-${contact.public_key}`,
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
unreadCount: getUnreadCount('contact', contact.public_key),
isMention: hasMention('contact', contact.public_key),
notificationsEnabled:
@@ -533,7 +533,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-raw',
active: isActive('raw', 'raw'),
icon: <Waypoints className="h-4 w-4" />,
icon: <Logs className="h-4 w-4" />,
label: 'Packet Feed',
onClick: () =>
handleSelectConversation({
@@ -557,7 +557,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-visualizer',
active: isActive('visualizer', 'visualizer'),
icon: <Sparkles className="h-4 w-4" />,
icon: <Waypoints className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({

View File

@@ -74,35 +74,33 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
return `${label} #${nextIndex}`;
}
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None,
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
) -> str | list[str] | None:
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
kwargs keys currently provided:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
channel_name = kwargs.get("channel_name")
is_outgoing = kwargs.get("is_outgoing", False)
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
# Don't reply to our own outgoing messages
if is_outgoing:
return None
@@ -1459,7 +1457,8 @@ export function SettingsFanoutSection({
return (
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
Integrations are an experimental feature in open beta.
Integrations are an experimental feature in open beta, and allow you to fanout raw and
decrypted messages across multiple services for automation, analysis, or archiving.
</div>
{health?.bots_disabled && (

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Logs, MessageSquare } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import { ContactAvatar } from '../ContactAvatar';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
@@ -97,7 +99,7 @@ function ThemePreview({ className }: { className?: string }) {
return (
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
<p className="text-xs text-muted-foreground mb-3">
Preview alert and message contrast for the selected theme.
Preview alert, message, sidebar, and badge contrast for the selected theme.
</p>
<div className="space-y-2">
@@ -125,6 +127,42 @@ function ThemePreview({ className }: { className?: string }) {
text="Hi there! I'm using RemoteTerm."
/>
</div>
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
<div className="space-y-1">
<PreviewSidebarRow
active
leading={
<span
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-primary"
aria-hidden="true"
>
<Logs className="h-3.5 w-3.5" />
</span>
}
label="Packet Feed"
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
label="Alice"
badge={
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
3
</span>
}
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
label="Mesh Ops"
badge={
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
@2
</span>
}
/>
</div>
</div>
</div>
);
}
@@ -153,3 +191,34 @@ function PreviewMessage({
</div>
);
}
function PreviewSidebarRow({
leading,
label,
badge,
active = false,
}: {
leading: React.ReactNode;
label: string;
badge?: React.ReactNode;
active?: boolean;
}) {
return (
<div
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
{label}
</span>
{badge}
{!badge && (
<span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
</div>
);
}

View File

@@ -54,6 +54,7 @@ export function SettingsRadioSection({
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
@@ -86,6 +87,7 @@ export function SettingsRadioSection({
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
setPathHashMode(String(config.path_hash_mode));
setAdvertLocationSource(config.advert_location_source ?? 'current');
}, [config]);
useEffect(() => {
@@ -175,6 +177,9 @@ export function SettingsRadioSection({
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
...(advertLocationSource !== (config.advert_location_source ?? 'current')
? { advert_location_source: advertLocationSource }
: {}),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -506,6 +511,25 @@ export function SettingsRadioSection({
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="advert-location-source">Advert Location Source</Label>
<select
id="advert-location-source"
value={advertLocationSource}
onChange={(e) => setAdvertLocationSource(e.target.value as 'off' | 'current')}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="off">Off</option>
<option value="current">Include Node Location</option>
</select>
<p className="text-xs text-muted-foreground">
Companion-radio firmware does not distinguish between saved coordinates and live GPS
here. When enabled, adverts include the node&apos;s current location state. That may be
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
is already updating them. RemoteTerm cannot enable GPS on the node through the interface
library.
</p>
</div>
</div>
{config.path_hash_mode_supported && (

View File

@@ -58,7 +58,7 @@ export function useContactsAndChannels({
setActiveConversation({
type: 'contact',
id: created.public_key,
name: getContactDisplayName(created.name, created.public_key),
name: getContactDisplayName(created.name, created.public_key, created.last_advert),
});
},
[fetchAllContacts, setActiveConversation]

View File

@@ -137,6 +137,7 @@ export function useConversationMessages(
const abortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const latestReconcileRequestIdRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
@@ -194,8 +195,12 @@ export function useConversationMessages(
}
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
setMessages(messagesWithPendingAck);
syncSeenContent(messagesWithPendingAck);
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
const nextMessages = merged ?? messagesRef.current;
if (merged) {
setMessages(merged);
}
syncSeenContent(nextMessages);
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
if (isAbortError(err)) {
@@ -215,7 +220,7 @@ export function useConversationMessages(
);
const reconcileFromBackend = useCallback(
(conversation: Conversation, signal: AbortSignal) => {
(conversation: Conversation, signal: AbortSignal, requestId: number) => {
const conversationId = conversation.id;
api
.getMessages(
@@ -228,16 +233,15 @@ export function useConversationMessages(
)
.then((data) => {
if (fetchingConversationIdRef.current !== conversationId) return;
if (latestReconcileRequestIdRef.current !== requestId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
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;
@@ -352,7 +356,9 @@ export function useConversationMessages(
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const controller = new AbortController();
reconcileFromBackend(activeConversation, controller.signal);
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
reconcileFromBackend(activeConversation, controller.signal, requestId);
}, [activeConversation, reconcileFromBackend]);
useEffect(() => {
@@ -365,6 +371,7 @@ export function useConversationMessages(
const conversationChanged = prevId !== newId;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
latestReconcileRequestIdRef.current = 0;
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
if (!conversationChanged && !targetMessageId) {
@@ -433,7 +440,9 @@ export function useConversationMessages(
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
reconcileFromBackend(activeConversation, controller.signal);
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
reconcileFromBackend(activeConversation, controller.signal, requestId);
} else {
void fetchLatestMessages(true, controller.signal);
}

View File

@@ -151,7 +151,7 @@ export function useConversationRouter({
setActiveConversationState({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
hasSetDefaultConversation.current = true;
return;
@@ -179,7 +179,7 @@ export function useConversationRouter({
setActiveConversationState({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
hasSetDefaultConversation.current = true;
return;

View File

@@ -11,6 +11,7 @@ import type { UseWebSocketOptions } from '../useWebSocket';
import { toast } from '../components/ui/sonner';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import { getContactDisplayName } from '../utils/pubkey';
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
import { getMessageContentKey } from './useConversationMessages';
import type {
@@ -40,6 +41,7 @@ interface UseRealtimeAppStateArgs {
addMessageIfNew: (msg: Message) => boolean;
trackNewMessage: (msg: Message) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
@@ -100,6 +102,7 @@ export function useRealtimeAppState({
addMessageIfNew,
trackNewMessage,
incrementUnread,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -225,6 +228,28 @@ export function useRealtimeAppState({
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
},
onContactResolved: (previousPublicKey: string, contact: Contact) => {
setContacts((prev) =>
mergeContactIntoList(
prev.filter((candidate) => candidate.public_key !== previousPublicKey),
contact
)
);
messageCache.rename(previousPublicKey, contact.public_key);
renameConversationState(
getStateKey('contact', previousPublicKey),
getStateKey('contact', contact.public_key)
);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === previousPublicKey) {
setActiveConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
}
},
onChannel: (channel: Channel) => {
mergeChannelIntoList(channel);
},
@@ -264,6 +289,7 @@ export function useRealtimeAppState({
fetchConfig,
hasNewerMessagesRef,
incrementUnread,
renameConversationState,
maxRawPackets,
mergeChannelIntoList,
pendingDeleteFallbackRef,

View File

@@ -3,6 +3,7 @@ import { api } from '../api';
import {
getLastMessageTimes,
setLastMessageTime,
renameConversationTimeKey,
getStateKey,
type ConversationTimes,
} from '../utils/conversationState';
@@ -15,6 +16,7 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
markAllRead: () => void;
trackNewMessage: (msg: Message) => void;
refreshUnreads: () => Promise<void>;
@@ -170,6 +172,28 @@ export function useUnreadCounts(
}
}, []);
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
if (oldStateKey === newStateKey) return;
setUnreadCounts((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = (next[newStateKey] || 0) + next[oldStateKey];
delete next[oldStateKey];
return next;
});
setMentions((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = next[newStateKey] || next[oldStateKey];
delete next[oldStateKey];
return next;
});
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -204,6 +228,7 @@ export function useUnreadCounts(
mentions,
lastMessageTimes,
incrementUnread,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads: fetchUnreads,

View File

@@ -138,6 +138,36 @@ export function remove(id: string): void {
cache.delete(id);
}
/** Move cached conversation state to a new conversation id. */
export function rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = cache.get(oldId);
if (!oldEntry) return;
const newEntry = cache.get(newId);
if (!newEntry) {
cache.delete(oldId);
cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
cache.delete(oldId);
cache.set(newId, {
messages: mergedMessages,
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
});
}
/** Clear the entire cache. */
export function clear(): void {
cache.clear();

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
@@ -94,6 +94,28 @@ describe('ChatHeader key visibility', () => {
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('renders the clickable conversation title as a real button inside the heading', () => {
const pubKey = '12'.repeat(32);
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
const onOpenContactInfo = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
onOpenContactInfo={onOpenContactInfo}
/>
);
const heading = screen.getByRole('heading', { name: /alice/i });
const titleButton = within(heading).getByRole('button', { name: 'View info for Alice' });
expect(heading).toContainElement(titleButton);
fireEvent.click(titleButton);
expect(onOpenContactInfo).toHaveBeenCalledWith(pubKey);
});
it('copies key to clipboard when revealed key is clicked', async () => {
const key = 'FF'.repeat(16);
const channel = makeChannel(key, 'Priv', false);

View File

@@ -13,12 +13,16 @@ import type {
RadioConfig,
} from '../types';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
MessageList: mocks.messageList,
}));
vi.mock('../components/MessageInput', () => ({
@@ -107,6 +111,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
unreadMarkerLastReadAt: undefined,
targetMessageId: null,
hasNewerMessages: false,
loadingNewer: false,
@@ -124,6 +129,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onTargetReached: vi.fn(),
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
...overrides,
@@ -133,6 +139,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
describe('ConversationPane', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.messageList.mockImplementation(() => <div data-testid="message-list" />);
});
it('renders the empty state when no conversation is active', () => {
@@ -196,4 +203,122 @@ describe('ConversationPane', () => {
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
it('passes unread marker props to MessageList only for channel conversations', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'channel',
id: channel.key,
name: channel.name,
},
unreadMarkerLastReadAt: 1700000000,
})}
/>
);
await waitFor(() => {
expect(mocks.messageList).toHaveBeenCalled();
});
const channelCallArgs = mocks.messageList.mock.calls[
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const channelCall = channelCallArgs?.[0] as Record<string, unknown> | undefined;
expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000);
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: 'Alice',
},
unreadMarkerLastReadAt: 1700000000,
})}
/>
);
const contactCallArgs = mocks.messageList.mock.calls[
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const contactCall = contactCallArgs?.[0] as Record<string, unknown> | undefined;
expect(contactCall?.unreadMarkerLastReadAt).toBeUndefined();
expect(contactCall?.onDismissUnreadMarker).toBeUndefined();
});
it('shows a warning but keeps input for full-key contacts without an advert', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: '[unknown sender]',
},
contacts: [
{
public_key: 'cc'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
},
],
})}
/>
);
expect(screen.getByText(/A full identity profile is not yet available/i)).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
it('hides input and shows a read-only warning for prefix-only contacts', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'abc123def456',
name: 'abc123def456',
},
contacts: [
{
public_key: 'abc123def456',
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
},
],
})}
/>
);
expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,55 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MapView } from '../components/MapView';
import type { Contact } from '../types';
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TileLayer: () => null,
CircleMarker: forwardRef<
HTMLDivElement,
{ children: React.ReactNode; pathOptions?: { fillColor?: string } }
>(({ children, pathOptions }, ref) => (
<div ref={ref} data-fill-color={pathOptions?.fillColor}>
{children}
</div>
)),
Popup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({
setView: vi.fn(),
fitBounds: vi.fn(),
}),
}));
describe('MapView', () => {
it('renders a never-heard fallback for a focused contact without last_seen', () => {
const contact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Mystery Node',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: 40,
lon: -74,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
render(<MapView contacts={[contact]} focusedKey={contact.public_key} />);
expect(
screen.getByText(/showing 1 contact heard in the last 7 days plus the focused contact/i)
).toBeInTheDocument();
expect(screen.getByText('Last heard: Never heard by this server')).toBeInTheDocument();
});
});

View File

@@ -1,9 +1,13 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
const scrollIntoViewMock = vi.fn();
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
@@ -24,6 +28,15 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}
describe('MessageList channel sender rendering', () => {
beforeEach(() => {
scrollIntoViewMock.mockReset();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoViewMock,
writable: true,
});
});
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
render(
<MessageList
@@ -61,4 +74,85 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});
it('gives clickable sender avatars an accessible label', () => {
render(
<MessageList
messages={[
createMessage({
text: 'garbled payload with no sender prefix',
sender_name: 'Alice',
sender_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onOpenContactInfo={() => {}}
/>
);
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
];
function DismissibleUnreadMarkerList() {
const [unreadMarkerLastReadAt, setUnreadMarkerLastReadAt] = useState<number | undefined>(
1700000005
);
return (
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={unreadMarkerLastReadAt}
onDismissUnreadMarker={() => setUnreadMarkerLastReadAt(undefined)}
/>
);
}
render(<DismissibleUnreadMarkerList />);
const marker = screen.getByRole('button', { name: /Unread messages/i });
expect(marker).toBeInTheDocument();
expect(screen.getByText('older')).toBeInTheDocument();
expect(screen.getByText('newer')).toBeInTheDocument();
await user.click(marker);
expect(screen.queryByRole('button', { name: /Unread messages/i })).not.toBeInTheDocument();
});
it('shows a jump-to-unread button and dismisses it after use without hiding the marker', async () => {
const user = userEvent.setup();
const messages = [
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
);
const jumpButton = screen.getByRole('button', { name: 'Jump to unread' });
expect(jumpButton).toBeInTheDocument();
expect(screen.getByText('Unread messages')).toBeInTheDocument();
await user.click(jumpButton);
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
expect(screen.getByText('Unread messages')).toBeInTheDocument();
expect(scrollIntoViewMock).toHaveBeenCalled();
});
});

View File

@@ -71,6 +71,9 @@ describe('SearchView', () => {
render(<SearchView {...defaultProps} />);
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
expect(screen.getByText(/Tip: use/i)).toBeInTheDocument();
expect(
screen.getByText(/User-key linkage for group messages is best-effort/i)
).toBeInTheDocument();
});
it('focuses input on mount', () => {
@@ -280,4 +283,35 @@ describe('SearchView', () => {
expect.any(AbortSignal)
);
});
it('aborts the load-more request on unmount', async () => {
const pageResults = Array.from({ length: 50 }, (_, i) =>
createSearchResult({ id: i + 1, text: `result ${i}` })
);
let resolveLoadMore: ((value: Message[]) => void) | null = null;
mockGetMessages.mockResolvedValueOnce(pageResults).mockImplementationOnce(
() =>
new Promise<Message[]>((resolve) => {
resolveLoadMore = resolve;
})
);
const { unmount } = render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('result');
fireEvent.click(screen.getByText('Load more results'));
const loadMoreSignal = mockGetMessages.mock.calls[1]?.[1] as AbortSignal | undefined;
expect(loadMoreSignal).toBeInstanceOf(AbortSignal);
expect(loadMoreSignal?.aborted).toBe(false);
unmount();
expect(loadMoreSignal?.aborted).toBe(true);
await act(async () => {
resolveLoadMore?.([createSearchResult({ id: 99, text: 'late result' })]);
await Promise.resolve();
});
});
});

View File

@@ -32,6 +32,7 @@ const baseConfig: RadioConfig = {
},
path_hash_mode: 0,
path_hash_mode_supported: false,
advert_location_source: 'current',
};
const baseHealth: HealthStatus = {
@@ -203,6 +204,22 @@ describe('SettingsModal', () => {
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
});
it('saves advert location source through radio config save', async () => {
const { onSave } = renderModal();
openRadioSection();
fireEvent.change(screen.getByLabelText('Advert Location Source'), {
target: { value: 'off' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ advert_location_source: 'off' })
);
});
});
it('saves changed max contacts value through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal();
openRadioSection();
@@ -279,24 +296,6 @@ describe('SettingsModal', () => {
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
});
it('shows the theme contrast preview in local settings', () => {
renderModal();
openLocalSection();
expect(
screen.getByText('Preview alert and message contrast for the selected theme.')
).toBeInTheDocument();
expect(
screen.getByText('Connected preview: radio link healthy and syncing.')
).toBeInTheDocument();
expect(
screen.getByText('Warning preview: packet audit suggests missing history.')
).toBeInTheDocument();
expect(screen.getByText('Error preview: radio reconnect failed.')).toBeInTheDocument();
expect(screen.getByText('Hello, mesh!')).toBeInTheDocument();
expect(screen.getByText("Hi there! I'm using RemoteTerm.")).toBeInTheDocument();
});
it('lists the new Windows 95 and iPhone themes', () => {
renderModal();
openLocalSection();

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { getUnreadBoundaryBackfillKey } from '../App';
import type { Conversation, Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'channel-1',
text: 'Alice: hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const channelConversation: Conversation = {
type: 'channel',
id: 'channel-1',
name: 'Busy room',
};
describe('getUnreadBoundaryBackfillKey', () => {
it('returns a fetch key when the unread boundary is older than the loaded window', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBe('channel-1:1700000000:20');
});
it('does not backfill when the loaded window already reaches the unread boundary', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000200,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
it('does not backfill when there is no older history to fetch', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
})
).toBeNull();
});
it('does not backfill for channels where everything is unread', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: null,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
});

View File

@@ -101,6 +101,40 @@ describe('useConversationMessages ACK ordering', () => {
expect(result.current.messages[0].paths).toEqual(paths);
});
it('preserves a WebSocket-arrived message when latest fetch resolves afterward', async () => {
const deferred = createDeferred<Message[]>();
mockGetMessages.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() => useConversationMessages(createConversation()));
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
act(() => {
const added = result.current.addMessageIfNew(
createMessage({
id: 99,
text: 'ws-arrived',
sender_timestamp: 1700000099,
received_at: 1700000099,
})
);
expect(added).toBe(true);
});
deferred.resolve([
createMessage({
id: 42,
text: 'rest-fetched',
sender_timestamp: 1700000000,
received_at: 1700000001,
}),
]);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages.some((msg) => msg.text === 'rest-fetched')).toBe(true);
expect(result.current.messages.some((msg) => msg.text === 'ws-arrived')).toBe(true);
});
it('keeps highest ACK state when out-of-order ACK updates arrive', async () => {
mockGetMessages.mockResolvedValueOnce([]);
@@ -224,6 +258,71 @@ describe('useConversationMessages conversation switch', () => {
});
});
describe('useConversationMessages background reconcile ordering', () => {
beforeEach(() => {
mockGetMessages.mockReset();
messageCache.clear();
});
it('ignores stale reconnect reconcile responses that finish after newer ones', async () => {
const conv = createConversation();
mockGetMessages.mockResolvedValueOnce([
createMessage({ id: 42, text: 'initial snapshot', acked: 0 }),
]);
const { result } = renderHook(() => useConversationMessages(conv));
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages[0].text).toBe('initial snapshot');
const firstReconcile = createDeferred<Message[]>();
const secondReconcile = createDeferred<Message[]>();
mockGetMessages
.mockReturnValueOnce(firstReconcile.promise)
.mockReturnValueOnce(secondReconcile.promise);
act(() => {
result.current.triggerReconcile();
result.current.triggerReconcile();
});
secondReconcile.resolve([createMessage({ id: 42, text: 'newer snapshot', acked: 2 })]);
await waitFor(() => expect(result.current.messages[0].text).toBe('newer snapshot'));
expect(result.current.messages[0].acked).toBe(2);
firstReconcile.resolve([createMessage({ id: 42, text: 'stale snapshot', acked: 1 })]);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.messages[0].text).toBe('newer snapshot');
expect(result.current.messages[0].acked).toBe(2);
});
it('clears stale hasOlderMessages when cached conversations reconcile to a short latest page', async () => {
const conv = createConversation();
const cachedMessage = createMessage({ id: 42, text: 'cached snapshot' });
messageCache.set(conv.id, {
messages: [cachedMessage],
seenContent: new Set([
`PRIV-${cachedMessage.conversation_key}-${cachedMessage.text}-${cachedMessage.sender_timestamp}`,
]),
hasOlderMessages: true,
});
mockGetMessages.mockResolvedValueOnce([cachedMessage]);
const { result } = renderHook(() => useConversationMessages(conv));
expect(result.current.messages).toHaveLength(1);
expect(result.current.hasOlderMessages).toBe(true);
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.hasOlderMessages).toBe(false));
});
});
describe('useConversationMessages forward pagination', () => {
beforeEach(() => {
mockGetMessages.mockReset();

View File

@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
messageCache: {
addMessage: vi.fn(),
remove: vi.fn(),
rename: vi.fn(),
updateAck: vi.fn(),
},
}));
@@ -77,6 +78,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
addMessageIfNew: vi.fn(),
trackNewMessage: vi.fn(),
incrementUnread: vi.fn(),
renameConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
@@ -193,6 +195,58 @@ describe('useRealtimeAppState', () => {
expect(pendingDeleteFallbackRef.current).toBe(true);
});
it('resolves a prefix-only contact into a full key and updates active conversation state', () => {
const previousPublicKey = 'abc123def456';
const resolvedContact: Contact = {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
};
const activeConversationRef = {
current: {
type: 'contact',
id: previousPublicKey,
name: 'abc123def456',
} satisfies Conversation,
};
const { args, fns } = createRealtimeArgs({
activeConversationRef,
});
const { result } = renderHook(() => useRealtimeAppState(args));
act(() => {
result.current.onContactResolved?.(previousPublicKey, resolvedContact);
});
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
expect(mocks.messageCache.rename).toHaveBeenCalledWith(
previousPublicKey,
resolvedContact.public_key
);
expect(args.renameConversationState).toHaveBeenCalledWith(
`contact-${previousPublicKey}`,
`contact-${resolvedContact.public_key}`
);
expect(args.setActiveConversation).toHaveBeenCalledWith({
type: 'contact',
id: resolvedContact.public_key,
name: '[unknown sender]',
});
});
it('appends raw packets using observation identity dedup', () => {
const { args, fns } = createRealtimeArgs();
const packet: RawPacket = {

View File

@@ -103,6 +103,39 @@ describe('useWebSocket dispatch', () => {
expect(onContact.mock.calls[0][0]).toHaveProperty('name');
});
it('routes contact_resolved event to onContactResolved', () => {
const onContactResolved = vi.fn();
renderHook(() => useWebSocket({ onContactResolved }));
const contact = {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
fireMessage({
type: 'contact_resolved',
data: {
previous_public_key: 'abc123def456',
contact,
},
});
expect(onContactResolved).toHaveBeenCalledOnce();
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
});
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
const onMessageAcked = vi.fn();
renderHook(() => useWebSocket({ onMessageAcked }));

View File

@@ -14,6 +14,58 @@ describe('wsEvents', () => {
});
});
it('parses contact_resolved events', () => {
const event = parseWsEvent(
JSON.stringify({
type: 'contact_resolved',
data: {
previous_public_key: 'abc123def456',
contact: {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
},
})
);
expect(event).toEqual({
type: 'contact_resolved',
data: {
previous_public_key: 'abc123def456',
contact: {
public_key: 'aa'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
},
});
});
it('parses channel_deleted events', () => {
const event = parseWsEvent(JSON.stringify({ type: 'channel_deleted', data: { key: 'bb' } }));

View File

@@ -79,7 +79,7 @@
--msg-incoming: 0 0% 92%;
--status-connected: 142 75% 32%;
--status-disconnected: 0 0% 35%;
--warning: 45 100% 45%;
--warning: 45 100% 38%;
--warning-foreground: 0 0% 0%;
--success: 142 75% 28%;
--success-foreground: 0 0% 100%;
@@ -130,6 +130,12 @@
inset -1px -1px 0 hsl(0 0% 34%);
}
[data-theme='windows-95'] .avatar-action-button {
background: transparent;
padding: 0;
box-shadow: none;
}
[data-theme='windows-95'] button:active,
[data-theme='windows-95'] button:active {
box-shadow:
@@ -153,6 +159,86 @@
inset -1px -1px 0 hsl(0 0% 34%);
}
[data-theme='windows-95'] .conversation-header {
background: hsl(240 100% 25%);
color: hsl(0 0% 100%);
box-shadow:
inset 1px 1px 0 hsl(0 0% 100% / 0.35),
inset -1px -1px 0 hsl(240 100% 14%);
}
[data-theme='windows-95'] .conversation-header .text-muted-foreground,
[data-theme='windows-95'] .conversation-header .text-foreground,
[data-theme='windows-95'] .conversation-header .text-primary,
[data-theme='windows-95'] .conversation-header button,
[data-theme='windows-95'] .conversation-header [role='button'] {
color: hsl(0 0% 100%);
}
[data-theme='windows-95'] .conversation-header button {
background: hsl(240 100% 25%);
box-shadow: none;
}
[data-theme='windows-95'] .conversation-header .lucide-info {
color: hsl(0 0% 100%);
}
[data-theme='windows-95'] .message-input-shell {
background: hsl(0 0% 75%);
box-shadow:
inset 1px 1px 0 hsl(0 0% 100%),
inset -1px -1px 0 hsl(0 0% 34%);
}
@media (min-width: 768px) {
[data-theme='windows-95'] * {
scrollbar-width: auto;
scrollbar-color: hsl(0 0% 75%) hsl(0 0% 84%);
}
[data-theme='windows-95'] ::-webkit-scrollbar {
width: 16px;
height: 16px;
}
[data-theme='windows-95'] ::-webkit-scrollbar-track,
[data-theme='windows-95'] ::-webkit-scrollbar-corner {
background: hsl(0 0% 84%);
box-shadow:
inset 1px 1px 0 hsl(0 0% 100%),
inset -1px -1px 0 hsl(0 0% 34%);
}
[data-theme='windows-95'] ::-webkit-scrollbar-thumb {
background: hsl(0 0% 75%);
border-radius: 0;
box-shadow:
inset 1px 1px 0 hsl(0 0% 100%),
inset -1px -1px 0 hsl(0 0% 34%);
}
[data-theme='windows-95'] ::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 80%);
}
[data-theme='windows-95'] ::-webkit-scrollbar-thumb:active {
box-shadow:
inset -1px -1px 0 hsl(0 0% 100%),
inset 1px 1px 0 hsl(0 0% 34%);
}
[data-theme='windows-95'] ::-webkit-scrollbar-button:single-button {
display: block;
background: hsl(0 0% 75%);
height: 16px;
width: 16px;
box-shadow:
inset 1px 1px 0 hsl(0 0% 100%),
inset -1px -1px 0 hsl(0 0% 34%);
}
}
/* ── iPhone / iOS ─────────────────────────────────────────── */
:root[data-theme='ios'] {
--background: 240 18% 96%;
@@ -255,7 +341,7 @@
--accent: 150 14% 12%;
--accent-foreground: 120 8% 82%;
--destructive: 340 100% 59%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 340 100% 6%;
--border: 135 30% 14%;
--input: 135 30% 14%;
--ring: 135 100% 50%;
@@ -294,7 +380,7 @@
--popover: 0 0% 8%;
--popover-foreground: 0 0% 100%;
--primary: 212 100% 62%;
--primary-foreground: 0 0% 100%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 12%;
--secondary-foreground: 0 0% 92%;
--muted: 0 0% 10%;
@@ -302,7 +388,7 @@
--accent: 0 0% 14%;
--accent-foreground: 0 0% 100%;
--destructive: 355 100% 50%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 0 0% 0%;
--border: 0 0% 22%;
--input: 0 0% 22%;
--ring: 212 100% 62%;
@@ -585,7 +671,7 @@
--accent: 205 46% 22%;
--accent-foreground: 42 33% 92%;
--destructive: 8 88% 61%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 196 60% 9%;
--border: 191 34% 24%;
--input: 191 34% 24%;
--ring: 175 72% 49%;
@@ -686,7 +772,7 @@
--accent: 251 28% 24%;
--accent-foreground: 302 30% 93%;
--destructive: 9 88% 66%;
--destructive-foreground: 0 0% 100%;
--destructive-foreground: 258 38% 12%;
--border: 256 24% 28%;
--input: 256 24% 28%;
--ring: 325 100% 74%;

View File

@@ -15,6 +15,7 @@ export interface RadioConfig {
radio: RadioSettings;
path_hash_mode: number;
path_hash_mode_supported: boolean;
advert_location_source?: 'off' | 'current';
}
export interface RadioConfigUpdate {
@@ -24,6 +25,7 @@ export interface RadioConfigUpdate {
tx_power?: number;
radio?: RadioSettings;
path_hash_mode?: number;
advert_location_source?: 'off' | 'current';
}
export interface FanoutStatusEntry {

View File

@@ -16,6 +16,7 @@ export interface UseWebSocketOptions {
onHealth?: (health: HealthStatus) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onContactResolved?: (previousPublicKey: string, contact: Contact) => void;
onContactDeleted?: (publicKey: string) => void;
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
@@ -102,6 +103,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
case 'contact':
handlers.onContact?.(msg.data as Contact);
break;
case 'contact_resolved': {
const resolved = msg.data as {
previous_public_key: string;
contact: Contact;
};
handlers.onContactResolved?.(resolved.previous_public_key, resolved.contact);
break;
}
case 'channel':
handlers.onChannel?.(msg.data as Channel);
break;

View File

@@ -41,6 +41,22 @@ export function setLastMessageTime(key: string, timestamp: number): Conversation
return { ...lastMessageTimesCache };
}
/**
* Move conversation timing state to a new key, preserving the most recent timestamp.
*/
export function renameConversationTimeKey(oldKey: string, newKey: string): ConversationTimes {
if (oldKey === newKey) return { ...lastMessageTimesCache };
const oldTimestamp = lastMessageTimesCache[oldKey];
const newTimestamp = lastMessageTimesCache[newKey];
if (oldTimestamp !== undefined) {
lastMessageTimesCache[newKey] =
newTimestamp === undefined ? oldTimestamp : Math.max(newTimestamp, oldTimestamp);
delete lastMessageTimesCache[oldKey];
}
return { ...lastMessageTimesCache };
}
/**
* Generate a state tracking key for message times.
*

View File

@@ -21,6 +21,20 @@ function getPubkeyPrefix(key: string): string {
/**
* Get a display name for a contact, falling back to pubkey prefix.
*/
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
return name || getPubkeyPrefix(pubkey);
export function getContactDisplayName(
name: string | null | undefined,
pubkey: string,
lastAdvert?: number | null
): string {
if (name) return name;
if (isUnknownFullKeyContact(pubkey, lastAdvert)) return '[unknown sender]';
return getPubkeyPrefix(pubkey);
}
export function isPrefixOnlyContact(pubkey: string): boolean {
return pubkey.length < 64;
}
export function isUnknownFullKeyContact(pubkey: string, lastAdvert?: number | null): boolean {
return pubkey.length === 64 && !lastAdvert;
}

View File

@@ -86,14 +86,19 @@ export function resolveChannelFromHashToken(token: string, channels: Channel[]):
export function resolveContactFromHashToken(token: string, contacts: Contact[]): Contact | null {
const normalizedToken = token.trim();
if (!normalizedToken) return null;
const lowerToken = normalizedToken.toLowerCase();
// Preferred path: stable identity by full public key.
const byKey = contacts.find((c) => c.public_key.toLowerCase() === normalizedToken.toLowerCase());
const byKey = contacts.find((c) => c.public_key.toLowerCase() === lowerToken);
if (byKey) return byKey;
// Backward compatibility for legacy name/prefix-based hashes.
return (
contacts.find((c) => getContactDisplayName(c.name, c.public_key) === normalizedToken) || null
contacts.find(
(c) =>
getContactDisplayName(c.name, c.public_key, c.last_advert) === normalizedToken ||
c.public_key.toLowerCase().startsWith(lowerToken)
) || null
);
}

View File

@@ -10,6 +10,11 @@ export interface ContactDeletedPayload {
public_key: string;
}
export interface ContactResolvedPayload {
previous_public_key: string;
contact: Contact;
}
export interface ChannelDeletedPayload {
key: string;
}
@@ -23,6 +28,7 @@ export type KnownWsEvent =
| { type: 'health'; data: HealthStatus }
| { type: 'message'; data: Message }
| { type: 'contact'; data: Contact }
| { type: 'contact_resolved'; data: ContactResolvedPayload }
| { type: 'channel'; data: Channel }
| { type: 'contact_deleted'; data: ContactDeletedPayload }
| { type: 'channel_deleted'; data: ChannelDeletedPayload }
@@ -55,6 +61,7 @@ export function parseWsEvent(raw: string): ParsedWsEvent {
case 'health':
case 'message':
case 'contact':
case 'contact_resolved':
case 'channel':
case 'contact_deleted':
case 'channel_deleted':

View File

@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.1.1"
version = "3.2.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -59,7 +59,7 @@ test.describe('Bot functionality', () => {
const triggerMessage = `!e2etest ${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(triggerMessage);
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Send', exact: true }).click();
// --- Step 4: Verify bot response appears ---
// Bot has ~2s delay before responding, plus radio send time

View File

@@ -21,7 +21,7 @@ test.describe('Channel messaging in #flightless', () => {
await input.fill(testMessage);
// Send it
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Send', exact: true }).click();
// Verify message appears in the message list
await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 });
@@ -35,7 +35,7 @@ test.describe('Channel messaging in #flightless', () => {
const testMessage = `ack-test-${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(testMessage);
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Send', exact: true }).click();
// Wait for the message to appear
const messageEl = page.getByText(testMessage);
@@ -56,7 +56,7 @@ test.describe('Channel messaging in #flightless', () => {
const testMessage = `resend-test-${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(testMessage);
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Send', exact: true }).click();
const messageEl = page.getByText(testMessage).first();
await expect(messageEl).toBeVisible({ timeout: 15_000 });

View File

@@ -310,3 +310,82 @@ class TestUnreadCountsBlockFiltering:
result = await MessageRepository.get_unread_counts()
assert result["counts"][f"contact-{blocked_key}"] == 1
assert result["counts"][f"channel-{chan_key}"] == 1
@pytest.mark.asyncio
async def test_last_message_times_exclude_blocked_key_conversations(self, test_db):
"""Blocked incoming key traffic should not reseed recent-sort timestamps."""
blocked_key = "aa" * 32
normal_key = "bb" * 32
chan_key = "CC" * 16
await ContactRepository.upsert({"public_key": blocked_key, "name": "Blocked"})
await ContactRepository.upsert({"public_key": normal_key, "name": "Normal"})
await ChannelRepository.upsert(key=chan_key, name="#test")
await MessageRepository.create(
msg_type="PRIV",
text="blocked dm",
received_at=1000,
conversation_key=blocked_key,
sender_timestamp=1000,
)
await MessageRepository.create(
msg_type="PRIV",
text="normal dm",
received_at=1001,
conversation_key=normal_key,
sender_timestamp=1001,
)
await MessageRepository.create(
msg_type="CHAN",
text="Blocked: spam",
received_at=1002,
conversation_key=chan_key,
sender_timestamp=1002,
sender_name="Blocked",
sender_key=blocked_key,
)
await MessageRepository.create(
msg_type="CHAN",
text="Normal: hello",
received_at=1003,
conversation_key=chan_key,
sender_timestamp=1003,
sender_name="Normal",
sender_key=normal_key,
)
result = await MessageRepository.get_unread_counts(blocked_keys=[blocked_key])
assert f"contact-{blocked_key}" not in result["last_message_times"]
assert result["last_message_times"][f"contact-{normal_key}"] == 1001
assert result["last_message_times"][f"channel-{chan_key}"] == 1003
@pytest.mark.asyncio
async def test_last_message_times_exclude_blocked_name_channel_msgs(self, test_db):
"""Blocked incoming names should not win the channel's recent timestamp."""
chan_key = "DD" * 16
await ChannelRepository.upsert(key=chan_key, name="#test2")
await MessageRepository.create(
msg_type="CHAN",
text="Spammer: buy stuff",
received_at=2000,
conversation_key=chan_key,
sender_timestamp=2000,
sender_name="Spammer",
sender_key="ee" * 32,
)
await MessageRepository.create(
msg_type="CHAN",
text="Friend: hello",
received_at=1999,
conversation_key=chan_key,
sender_timestamp=1999,
sender_name="Friend",
sender_key="ff" * 32,
)
result = await MessageRepository.get_unread_counts(blocked_names=["Spammer"])
assert result["last_message_times"][f"channel-{chan_key}"] == 1999

View File

@@ -312,6 +312,91 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name,
)
assert result == "outgoing=True"
def test_new_10_param_bot_receives_path_bytes_per_hop(self):
"""Bots that declare path_bytes_per_hop receive it positionally."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop):
return f"bytes={path_bytes_per_hop}"
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path="aabb",
path_bytes_per_hop=2,
)
assert result == "bytes=2"
def test_9_param_bot_with_path_bytes_only_receives_it(self):
"""Bots may opt into path_bytes_per_hop without also declaring is_outgoing."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, path_bytes_per_hop):
return f"bytes={path_bytes_per_hop}"
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path="aabb",
is_outgoing=True,
path_bytes_per_hop=2,
)
assert result == "bytes=2"
def test_legacy_bot_with_kwargs_receives_path_bytes_per_hop(self):
"""Bots using **kwargs receive the new path_bytes_per_hop field."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, **kwargs):
return f"bytes={kwargs.get('path_bytes_per_hop', 'missing')}"
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path="aabb",
path_bytes_per_hop=2,
)
assert result == "bytes=2"
def test_pure_kwargs_bot_receives_core_fields_and_optional_extras(self):
"""Pure **kwargs bots are first-class and receive the full payload by keyword."""
code = """
def bot(**kwargs):
return (
f"{kwargs.get('sender_name')}|{kwargs.get('message_text')}|"
f"{kwargs.get('is_outgoing')}|{kwargs.get('path_bytes_per_hop')}"
)
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path="aabb",
is_outgoing=True,
path_bytes_per_hop=2,
)
assert result == "Alice|Hi|True|2"
def test_channel_message_with_none_sender_key(self):
"""Channel messages correctly pass None for sender_key."""
code = """
@@ -419,7 +504,20 @@ class TestBotCodeValidation:
from app.routers.fanout import _validate_bot_config
# Should not raise
_validate_bot_config({"code": "def bot(): return 'hello'"})
_validate_bot_config(
{
"code": (
"def bot(sender_name, sender_key, message_text, is_dm, channel_key, "
"channel_name, sender_timestamp, path):\n return 'hello'"
)
}
)
def test_pure_kwargs_code_passes(self):
"""Pure **kwargs bots are valid."""
from app.routers.fanout import _validate_bot_config
_validate_bot_config({"code": "def bot(**kwargs):\n return kwargs.get('message_text')"})
def test_syntax_error_raises(self):
"""Syntax error in code raises HTTPException."""
@@ -456,6 +554,38 @@ class TestBotCodeValidation:
assert exc_info.value.status_code == 400
def test_missing_bot_function_raises(self):
"""Code must define a callable bot() function."""
from fastapi import HTTPException
from app.routers.fanout import _validate_bot_config
with pytest.raises(HTTPException) as exc_info:
_validate_bot_config({"code": "def helper():\n return 'hello'"})
assert exc_info.value.status_code == 400
assert "callable bot() function" in exc_info.value.detail
def test_unsupported_signature_raises(self):
"""Unsupported bot signatures are rejected with guidance."""
from fastapi import HTTPException
from app.routers.fanout import _validate_bot_config
with pytest.raises(HTTPException) as exc_info:
_validate_bot_config(
{
"code": (
"def bot(sender_name, sender_key, message_text, is_dm, channel_key, "
"channel_name, sender_timestamp, path, *, extra_required):\n"
" return extra_required"
)
}
)
assert exc_info.value.status_code == 400
assert "signature is not supported" in exc_info.value.detail.lower()
class TestBotMessageRateLimiting:
"""Test bot message rate limiting for repeater compatibility."""

View File

@@ -2,7 +2,7 @@
import pytest
from app.repository import MessageRepository
from app.repository import ContactRepository, MessageRepository
@pytest.mark.asyncio
@@ -36,6 +36,14 @@ async def test_backfill_sets_sender_key_on_matching_messages(test_db):
assert all(m.sender_key is None for m in messages)
# Contact becomes known
await ContactRepository.upsert(
{
"public_key": pub_key,
"name": "Alice",
"type": 1,
"flags": 0,
}
)
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
assert backfilled == 2
@@ -95,6 +103,14 @@ async def test_backfill_only_affects_matching_name(test_db):
sender_name="Bob",
)
await ContactRepository.upsert(
{
"public_key": pub_key,
"name": "Alice",
"type": 1,
"flags": 0,
}
)
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
assert backfilled == 1
@@ -138,8 +154,56 @@ async def test_backfill_idempotent(test_db):
sender_name="Alice",
)
await ContactRepository.upsert(
{
"public_key": pub_key,
"name": "Alice",
"type": 1,
"flags": 0,
}
)
first = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
assert first == 1
second = await MessageRepository.backfill_channel_sender_key(pub_key, "Alice")
assert second == 0
@pytest.mark.asyncio
async def test_backfill_skips_ambiguous_duplicate_names(test_db):
"""Duplicate contact names should leave channel sender_key unresolved."""
pub_key_a = "33" * 32
pub_key_b = "44" * 32
channel_key = "55" * 16
await ContactRepository.upsert(
{
"public_key": pub_key_a,
"name": "Alice",
"type": 1,
"flags": 0,
}
)
await ContactRepository.upsert(
{
"public_key": pub_key_b,
"name": "Alice",
"type": 1,
"flags": 0,
}
)
await MessageRepository.create(
msg_type="CHAN",
text="Alice: ambiguous",
conversation_key=channel_key,
sender_timestamp=100,
received_at=100,
sender_name="Alice",
)
backfilled = await MessageRepository.backfill_channel_sender_key(pub_key_a, "Alice")
assert backfilled == 0
messages = await MessageRepository.get_all(msg_type="CHAN", conversation_key=channel_key)
assert len(messages) == 1
assert messages[0].sender_key is None

View File

@@ -112,6 +112,33 @@ class TestSyncChannelsFromRadio:
assert secret_a.hex().upper() in keys
assert secret_b.hex().upper() in keys
@pytest.mark.asyncio
async def test_sync_broadcasts_channel_updates(self, test_db, client):
secret = bytes.fromhex("0123456789abcdef0123456789abcdef")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("#general", secret)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
patch("app.routers.channels.broadcast_event") as mock_broadcast,
):
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=3")
assert response.status_code == 200
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
assert mock_broadcast.call_args.args[1]["key"] == secret.hex().upper()
@pytest.mark.asyncio
async def test_sync_skips_empty_channels(self, test_db, client):
"""Empty channel slots are skipped during sync."""
@@ -278,6 +305,19 @@ class TestChannelFloodScopeOverride:
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
class TestCreateChannel:
@pytest.mark.asyncio
async def test_create_broadcasts_channel_update(self, test_db):
from app.routers.channels import CreateChannelRequest, create_channel
with patch("app.routers.channels.broadcast_event") as mock_broadcast:
result = await create_channel(CreateChannelRequest(name="#mychannel"))
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
assert mock_broadcast.call_args.args[1]["key"] == result.key
@pytest.mark.asyncio
async def test_existing_hash_is_not_doubled(self, test_db, client):
key = "CC" * 16

View File

@@ -108,20 +108,24 @@ class TestCreateContact:
@pytest.mark.asyncio
async def test_create_new_contact(self, test_db, client):
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "NewContact"},
)
with patch("app.websocket.broadcast_event") as mock_broadcast:
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "NewContact"},
)
assert response.status_code == 200
data = response.json()
assert data["public_key"] == KEY_A
assert data["name"] == "NewContact"
assert data["last_seen"] is not None
# Verify in DB
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
assert contact.name == "NewContact"
assert data["last_seen"] == contact.last_seen
mock_broadcast.assert_called_once_with("contact", contact.model_dump())
@pytest.mark.asyncio
async def test_create_invalid_hex(self, test_db, client):
@@ -660,7 +664,10 @@ class TestSyncContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
with _patch_require_connected(mock_mc):
with (
_patch_require_connected(mock_mc),
patch("app.websocket.broadcast_event") as mock_broadcast,
):
response = await client.post("/api/contacts/sync")
assert response.status_code == 200
@@ -670,6 +677,12 @@ class TestSyncContacts:
alice = await ContactRepository.get_by_key(KEY_A)
assert alice is not None
assert alice.name == "Alice"
assert mock_broadcast.call_count == 2
assert [call.args[0] for call in mock_broadcast.call_args_list] == ["contact", "contact"]
assert {call.args[1]["public_key"] for call in mock_broadcast.call_args_list} == {
KEY_A,
KEY_B,
}
@pytest.mark.asyncio
async def test_sync_requires_connection(self, test_db, client):
@@ -954,8 +967,8 @@ class TestAddRemoveRadio:
@pytest.mark.asyncio
async def test_add_already_on_radio(self, test_db, client):
"""Adding a contact already on radio returns ok without calling add_contact."""
await _insert_contact(KEY_A, on_radio=True)
"""Adding a contact already on radio repairs the DB flag and skips add_contact."""
await _insert_contact(KEY_A, on_radio=False)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # On radio
@@ -966,6 +979,10 @@ class TestAddRemoveRadio:
assert response.status_code == 200
assert "already" in response.json()["message"].lower()
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
assert contact.on_radio is True
mock_mc.commands.add_contact.assert_not_called()
@pytest.mark.asyncio
async def test_remove_from_radio(self, test_db, client):

View File

@@ -239,8 +239,9 @@ class TestContactMessageCLIFiltering:
assert len(messages) == 1
assert messages[0].text == "Hello, this is a normal message"
# SHOULD broadcast via WebSocket
mock_broadcast.assert_called_once()
# Placeholder contact is broadcast first, then the message
assert mock_broadcast.call_count == 2
assert mock_broadcast.call_args_list[-1].args[0] == "message"
@pytest.mark.asyncio
async def test_broadcast_payload_has_correct_acked_type(self, test_db):
@@ -259,9 +260,8 @@ class TestContactMessageCLIFiltering:
await on_contact_message(MockEvent())
# Verify broadcast was called
mock_broadcast.assert_called_once()
call_args = mock_broadcast.call_args
# Last broadcast is the message; first may be placeholder contact
call_args = mock_broadcast.call_args_list[-1]
# First arg is event type, second is payload dict
event_type, payload = call_args[0]
@@ -305,8 +305,7 @@ class TestContactMessageCLIFiltering:
await on_contact_message(MockEvent())
mock_broadcast.assert_called_once()
event_type, payload = mock_broadcast.call_args[0]
event_type, payload = mock_broadcast.call_args_list[-1][0]
assert event_type == "message"
assert set(payload.keys()) == EXPECTED_MESSAGE_KEYS
@@ -360,6 +359,158 @@ class TestContactMessageCLIFiltering:
messages = await MessageRepository.get_all()
assert len(messages) == 1
@pytest.mark.asyncio
async def test_unknown_full_key_dm_creates_placeholder_contact(self, test_db):
"""Fallback DMs with a full unknown key create a durable placeholder contact."""
from app.event_handlers import on_contact_message
full_key = "ab" * 32
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
"public_key": full_key,
"text": "hello from unknown full key",
"txt_type": 0,
"sender_timestamp": 1700000000,
}
await on_contact_message(MockEvent())
contact = await ContactRepository.get_by_key(full_key)
assert contact is not None
assert contact.public_key == full_key
assert contact.name is None
messages = await MessageRepository.get_all(conversation_key=full_key)
assert len(messages) == 1
assert mock_broadcast.call_args_list[0].args[0] == "contact"
assert mock_broadcast.call_args_list[-1].args[0] == "message"
@pytest.mark.asyncio
async def test_new_contact_promotes_prefix_placeholder_and_broadcasts_resolution(self, test_db):
"""NEW_CONTACT promotes prefix placeholders to the full key and emits contact_resolved."""
from app.event_handlers import on_new_contact
prefix = "abc123def456"
full_key = prefix + ("00" * 26)
await ContactRepository.upsert(
{
"public_key": prefix,
"type": 0,
"last_seen": 1700000000,
"last_contacted": 1700000000,
"first_seen": 1700000000,
"out_path_hash_mode": -1,
}
)
await MessageRepository.create(
msg_type="PRIV",
text="hello",
conversation_key=prefix,
sender_timestamp=1700000000,
received_at=1700000000,
)
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
"public_key": full_key,
"adv_name": "Resolved Sender",
"type": 1,
"flags": 0,
"adv_lat": 0.0,
"adv_lon": 0.0,
"last_advert": 1700000010,
"out_path": "",
"out_path_len": -1,
"out_path_hash_mode": -1,
}
await on_new_contact(MockEvent())
assert await ContactRepository.get_by_key(prefix) is None
resolved = await ContactRepository.get_by_key(full_key)
assert resolved is not None
assert resolved.name == "Resolved Sender"
messages = await MessageRepository.get_all(conversation_key=full_key)
assert len(messages) == 1
assert messages[0].conversation_key == full_key
event_types = [call.args[0] for call in mock_broadcast.call_args_list]
assert "contact" in event_types
assert "contact_resolved" in event_types
@pytest.mark.asyncio
async def test_new_contact_keeps_ambiguous_prefix_placeholder_unresolved(self, test_db):
"""Ambiguous prefix placeholders should not resolve until a unique full-key match exists."""
from app.event_handlers import on_new_contact
prefix = "abc123"
full_key = prefix + ("00" * 29)
conflicting_full_key = prefix + ("ff" * 29)
await ContactRepository.upsert(
{
"public_key": prefix,
"type": 0,
"last_seen": 1700000000,
"out_path_hash_mode": -1,
}
)
await ContactRepository.upsert(
{
"public_key": conflicting_full_key,
"name": "Conflicting Sender",
"type": 1,
"flags": 0,
}
)
await MessageRepository.create(
msg_type="PRIV",
text="hello from ambiguous prefix",
conversation_key=prefix,
sender_timestamp=1700000000,
received_at=1700000000,
)
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
"public_key": full_key,
"adv_name": "Resolved Sender",
"type": 1,
"flags": 0,
"adv_lat": 0.0,
"adv_lon": 0.0,
"last_advert": 1700000010,
"out_path": "",
"out_path_len": -1,
"out_path_hash_mode": -1,
}
await on_new_contact(MockEvent())
placeholder = await ContactRepository.get_by_key(prefix)
assert placeholder is not None
resolved = await ContactRepository.get_by_key(full_key)
assert resolved is not None
assert resolved.name == "Resolved Sender"
prefix_messages = await MessageRepository.get_all(conversation_key=prefix)
assert len(prefix_messages) == 1
resolved_messages = await MessageRepository.get_all(conversation_key=full_key)
assert resolved_messages == []
event_types = [call.args[0] for call in mock_broadcast.call_args_list]
assert "contact" in event_types
assert "contact_resolved" not in event_types
@pytest.mark.asyncio
async def test_ambiguous_prefix_stores_dm_under_prefix(self, test_db):
"""Ambiguous sender prefixes should still be stored under the prefix key."""
@@ -400,7 +551,9 @@ class TestContactMessageCLIFiltering:
assert len(messages) == 1
assert messages[0].conversation_key == "abc123"
mock_broadcast.assert_called_once()
assert mock_broadcast.call_count == 2
assert mock_broadcast.call_args_list[0].args[0] == "contact"
assert mock_broadcast.call_args_list[-1].args[0] == "message"
_, payload = mock_broadcast.call_args.args
assert payload["conversation_key"] == "abc123"

View File

@@ -36,6 +36,7 @@ class TestBotModuleParameterExtraction:
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
):
captured["is_outgoing"] = is_outgoing
captured["is_dm"] = is_dm
@@ -84,6 +85,7 @@ class TestBotModuleParameterExtraction:
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
):
captured["is_outgoing"] = is_outgoing
return None
@@ -129,8 +131,10 @@ class TestBotModuleParameterExtraction:
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
):
captured["path"] = path
captured["path_bytes_per_hop"] = path_bytes_per_hop
return None
mod = BotModule("test", {"code": "def bot(**k): pass"}, name="Test")
@@ -150,11 +154,12 @@ class TestBotModuleParameterExtraction:
"type": "PRIV",
"conversation_key": "pk1",
"text": "hello",
"paths": [{"path": "aabb", "rssi": -50}],
"paths": [{"path": "aabbccdd", "path_len": 2, "rssi": -50}],
}
)
assert captured["path"] == "aabb"
assert captured["path"] == "aabbccdd"
assert captured["path_bytes_per_hop"] == 2
@pytest.mark.asyncio
async def test_channel_sender_prefix_stripped(self):
@@ -174,6 +179,7 @@ class TestBotModuleParameterExtraction:
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
):
captured["message_text"] = message_text
captured["sender_name"] = sender_name
@@ -221,6 +227,7 @@ class TestBotModuleParameterExtraction:
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
):
captured["channel_name"] = channel_name
return None
@@ -267,6 +274,7 @@ class TestBotModuleParameterExtraction:
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
):
captured["sender_name"] = sender_name
captured["sender_key"] = sender_key

View File

@@ -32,6 +32,7 @@ def _mock_meshcore_with_info():
mc.commands.set_tx_power = AsyncMock()
mc.commands.set_radio = AsyncMock()
mc.commands.set_path_hash_mode = AsyncMock(return_value=_radio_result())
mc.commands.set_advert_loc_policy = AsyncMock(return_value=_radio_result())
mc.commands.send_appstart = AsyncMock()
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
return mc
@@ -68,6 +69,39 @@ class TestApplyRadioConfigUpdate:
sync_radio_time_fn.assert_awaited_once_with(mc)
mc.commands.send_appstart.assert_awaited_once()
@pytest.mark.asyncio
async def test_updates_advert_location_source(self):
mc = _mock_meshcore_with_info()
await apply_radio_config_update(
mc,
RadioConfigUpdate(advert_location_source="current"),
path_hash_mode_supported=True,
set_path_hash_mode=MagicMock(),
sync_radio_time_fn=AsyncMock(),
)
mc.commands.set_advert_loc_policy.assert_awaited_once_with(1)
mc.commands.send_appstart.assert_awaited_once()
@pytest.mark.asyncio
async def test_raises_when_radio_rejects_advert_location_source(self):
mc = _mock_meshcore_with_info()
mc.commands.set_advert_loc_policy = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"error": "nope"})
)
with pytest.raises(RadioCommandRejectedError):
await apply_radio_config_update(
mc,
RadioConfigUpdate(advert_location_source="off"),
path_hash_mode_supported=True,
set_path_hash_mode=MagicMock(),
sync_radio_time_fn=AsyncMock(),
)
mc.commands.send_appstart.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejects_unsupported_path_hash_mode(self):
mc = _mock_meshcore_with_info()

View File

@@ -2,7 +2,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.radio_lifecycle import prepare_connected_radio, reconnect_and_prepare_radio
from app.services.radio_lifecycle import (
prepare_connected_radio,
reconnect_and_prepare_radio,
run_post_connect_setup,
)
class TestPrepareConnectedRadio:
@@ -82,3 +86,58 @@ class TestReconnectAndPrepareRadio:
assert result is False
radio_manager.post_connect_setup.assert_not_awaited()
mock_broadcast.assert_not_called()
class TestRunPostConnectSetup:
@pytest.mark.asyncio
async def test_uses_current_meshcore_after_waiting_for_operation_lock(self):
initial_mc = MagicMock()
initial_mc.commands.send_device_query = AsyncMock(return_value=None)
initial_mc.commands.set_flood_scope = AsyncMock(return_value=None)
initial_mc._reader = MagicMock()
initial_mc._reader.handle_rx = AsyncMock()
initial_mc.start_auto_message_fetching = AsyncMock()
replacement_mc = MagicMock()
replacement_mc.commands.send_device_query = AsyncMock(return_value=None)
replacement_mc.commands.set_flood_scope = AsyncMock(return_value=None)
replacement_mc._reader = MagicMock()
replacement_mc._reader.handle_rx = AsyncMock()
replacement_mc.start_auto_message_fetching = AsyncMock()
radio_manager = MagicMock()
radio_manager.meshcore = initial_mc
radio_manager._setup_lock = None
radio_manager._setup_in_progress = False
radio_manager._setup_complete = False
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
async def _acquire(*args, **kwargs):
radio_manager.meshcore = replacement_mc
radio_manager._acquire_operation_lock = AsyncMock(side_effect=_acquire)
radio_manager._release_operation_lock = MagicMock()
with (
patch("app.event_handlers.register_event_handlers") as mock_register_handlers,
patch("app.keystore.export_and_store_private_key", new=AsyncMock()) as mock_export_key,
patch("app.radio_sync.sync_radio_time", new=AsyncMock()) as mock_sync_time,
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=MagicMock(flood_scope=None)),
),
patch("app.radio_sync.sync_and_offload_all", new=AsyncMock(return_value={"synced": 0})),
patch("app.radio_sync.send_advertisement", new=AsyncMock(return_value=False)),
patch("app.radio_sync.drain_pending_messages", new=AsyncMock(return_value=0)),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.start_message_polling"),
):
await run_post_connect_setup(radio_manager)
mock_register_handlers.assert_called_once_with(replacement_mc)
mock_export_key.assert_awaited_once_with(replacement_mc)
mock_sync_time.assert_awaited_once_with(replacement_mc)
replacement_mc.start_auto_message_fetching.assert_awaited_once()
initial_mc.start_auto_message_fetching.assert_not_called()

View File

@@ -70,12 +70,14 @@ def _mock_meshcore_with_info():
"radio_bw": 62.5,
"radio_sf": 7,
"radio_cr": 5,
"adv_loc_policy": 2,
}
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_advert_loc_policy = AsyncMock(return_value=_radio_result())
mc.commands.send_appstart = AsyncMock()
mc.commands.import_private_key = AsyncMock(return_value=_radio_result())
return mc
@@ -94,6 +96,17 @@ class TestGetRadioConfig:
assert response.lon == 20.0
assert response.radio.freq == 910.525
assert response.radio.cr == 5
assert response.advert_location_source == "current"
@pytest.mark.asyncio
async def test_maps_any_nonzero_advert_location_policy_to_current(self):
mc = _mock_meshcore_with_info()
mc.self_info["adv_loc_policy"] = 1
with patch("app.routers.radio.require_connected", return_value=mc):
response = await get_radio_config()
assert response.advert_location_source == "current"
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
@@ -138,6 +151,35 @@ class TestUpdateRadioConfig:
mock_sync_time.assert_awaited_once()
assert result == expected
@pytest.mark.asyncio
async def test_updates_advert_location_source(self):
mc = _mock_meshcore_with_info()
expected = RadioConfigResponse(
public_key="aa" * 32,
name="NodeA",
lat=10.0,
lon=20.0,
tx_power=17,
max_tx_power=22,
radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5),
path_hash_mode=0,
path_hash_mode_supported=False,
advert_location_source="current",
)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock),
patch(
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
),
):
result = await update_radio_config(RadioConfigUpdate(advert_location_source="current"))
mc.commands.set_advert_loc_policy.assert_awaited_once_with(1)
assert result == expected
def test_model_rejects_negative_path_hash_mode(self):
with pytest.raises(ValidationError):
RadioConfigUpdate(path_hash_mode=-1)

View File

@@ -1326,10 +1326,10 @@ class TestPeriodicAdvertLoopRaces:
is caught by the outer except — loop survives and continues."""
rm, _mc = _make_connected_manager()
_disconnect_on_acquire(rm)
# Advert loop: sleep first, then work. Sleep 1 (loop top) passes,
# work hits RadioDisconnectedError, error handler does sleep 2 (passes),
# next iteration sleep 3 cancels cleanly via except CancelledError.
mock_sleep, sleep_calls = _sleep_controller(cancel_after=3)
# Advert loop: sleep first, then work. Sleep 1 (loop top) passes,
# work hits RadioDisconnectedError, next iteration sleep 2 cancels
# cleanly via except CancelledError without an extra backoff sleep.
mock_sleep, sleep_calls = _sleep_controller(cancel_after=2)
with (
patch("app.radio_sync.radio_manager", rm),
@@ -1339,7 +1339,7 @@ class TestPeriodicAdvertLoopRaces:
await _periodic_advert_loop()
mock_advert.assert_not_called()
assert len(sleep_calls) == 3
assert len(sleep_calls) == 2
@pytest.mark.asyncio
async def test_busy_lock_skips_iteration(self):

View File

@@ -907,3 +907,35 @@ class TestConcurrentChannelSends:
msg_type="CHAN", conversation_key=chan_key.upper(), limit=10
)
assert len(msgs) == 2
class TestChannelSendLockScope:
"""Channel send should release the radio lock before DB persistence work."""
@pytest.mark.asyncio
async def test_channel_message_row_created_after_radio_lock_released(self, test_db):
mc = _make_mc(name="TestNode")
chan_key = "de" * 16
await ChannelRepository.upsert(key=chan_key, name="#lockscope")
observed_lock_states: list[bool] = []
original_create = MessageRepository.create
async def _assert_lock_then_create(*args, **kwargs):
observed_lock_states.append(bool(radio_manager._operation_lock.locked()))
return await original_create(*args, **kwargs)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.broadcast_event"),
patch(
"app.services.message_send.MessageRepository.create",
side_effect=_assert_lock_then_create,
),
):
await send_channel_message(
SendChannelMessageRequest(channel_key=chan_key, text="Lock scope test")
)
assert observed_lock_states == [False]

2
uv.lock generated
View File

@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.1.1"
version = "3.2.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },