mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e00373aa | ||
|
|
2f0d35748a | ||
|
|
7db2974481 | ||
|
|
0a20929df6 | ||
|
|
30f6f95d8e | ||
|
|
9e8cf56b31 | ||
|
|
fb535298be | ||
|
|
1f2903fc2d | ||
|
|
6466a5c355 | ||
|
|
f8e88b3737 | ||
|
|
a13e241636 | ||
|
|
8c1a58b293 | ||
|
|
bf53e8a4cb | ||
|
|
c6cd209192 | ||
|
|
e5c7ebb388 | ||
|
|
e37632de3f | ||
|
|
d36c5e3e32 | ||
|
|
bc7506b0d9 | ||
|
|
38c7277c9d | ||
|
|
20d0bd92bb | ||
|
|
e0df30b5f0 | ||
|
|
83635845b6 | ||
|
|
2e705538fd | ||
|
|
4363fd2a73 | ||
|
|
5bd3205de5 | ||
|
|
bcde3bd9d5 | ||
|
|
15a8c637e4 | ||
|
|
d38efc0421 |
15
AGENTS.md
15
AGENTS.md
@@ -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.
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.1.1",
|
||||
"version": "3.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
22
frontend/src/components/DirectTraceIcon.tsx
Normal file
22
frontend/src/components/DirectTraceIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" /> <1h
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> <1d
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> <3d
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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 && (
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
55
frontend/src/test/mapView.test.tsx
Normal file
55
frontend/src/test/mapView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
101
frontend/src/test/unreadMarkerBackfill.test.ts
Normal file
101
frontend/src/test/unreadMarkerBackfill.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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' } }));
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user