feat(Reticulum): improve hub management with configurable announce intervals and presence group handling, including UI updates for better user experience.

This commit is contained in:
Ivan 2026-07-09 09:20:55 -05:00
parent 63189f4ed0
commit 56eaeafeec
No known key found for this signature in database
30 changed files with 1439 additions and 94 deletions

View file

@ -8830,12 +8830,17 @@ class ReticulumMeshChat:
greeting = (data.get("greeting") or "").strip() or None
announce = bool(data.get("announce", True))
enabled = bool(data.get("enabled", True))
hub = manager.create_hub(
name=name,
greeting=greeting,
announce=announce,
enabled=enabled,
)
create_kwargs = {
"name": name,
"greeting": greeting,
"announce": announce,
"enabled": enabled,
}
if "announce_interval_seconds" in data:
create_kwargs["announce_interval_seconds"] = data.get(
"announce_interval_seconds",
)
hub = manager.create_hub(**create_kwargs)
return web.json_response({"hub": hub.to_dict()})
@routes.delete("/api/v1/rrc/servers/{hub_id}")
@ -8861,6 +8866,11 @@ class ReticulumMeshChat:
name=(data.get("name") if "name" in data else None),
greeting=(data.get("greeting") if "greeting" in data else None),
announce=(data.get("announce") if "announce" in data else None),
announce_interval_seconds=(
data.get("announce_interval_seconds")
if "announce_interval_seconds" in data
else None
),
trusted_identities=(
data.get("trusted_identities")
if "trusted_identities" in data

View file

@ -88,6 +88,7 @@ class RRCHub:
self._manual_disconnect = False
self._reconnect_attempts = 0
self._reconnect_timer = None
self._had_session = False
self._pending_pings = {}
self._last_history_clean = 0
self.clean_last_removed = 0
@ -418,6 +419,9 @@ class RRCHub:
def _on_closed(self, link):
self._stop_hello.set()
with self._lock:
was_welcomed = self.welcomed
rooms = list(self.rooms)
manual = self._manual_disconnect
self.link = None
self.welcomed = False
self.motd = None
@ -428,6 +432,9 @@ class RRCHub:
self._silent_joins.clear()
self._silent_who_rooms.clear()
should_reconnect = self.auto_reconnect and not self._manual_disconnect
if was_welcomed and rooms:
text = "Disconnected from hub" if manual else "Connection lost"
self._record_connection_event(text, rooms=rooms)
self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected")
if should_reconnect:
self._schedule_reconnect()
@ -882,6 +889,15 @@ class RRCHub:
self._append_history(room, msg)
self._clean_history()
def _record_connection_event(self, text, rooms=None):
"""Write a connection status line into each joined room timeline."""
if rooms is None:
with self._lock:
rooms = list(self.rooms)
for room in rooms:
with contextlib.suppress(Exception):
self._record_system(room, text)
def _record_notice(self, msg):
target_room = msg.room
if not target_room:
@ -964,9 +980,14 @@ class RRCHub:
limits = body.get(proto.B_WELCOME_LIMITS)
if isinstance(limits, dict):
self._apply_limits(limits)
self._set_status(RRCHub.STATUS_CONNECTED, "Connected")
with self._lock:
was_reconnect = self._had_session
self._reconnect_attempts = 0
self._had_session = True
rooms = list(self.rooms)
self._set_status(RRCHub.STATUS_CONNECTED, "Connected")
if was_reconnect and rooms:
self._record_connection_event("Reconnected to hub", rooms=rooms)
self.manager._on_welcome(self)
if self.auto_list:
self._request_room_list()
@ -1029,7 +1050,9 @@ class RRCHub:
self.nicks[jh] = joiner_nick
if self_join:
if not silent:
if silent:
self._record_system(r, "You rejoined #" + r)
else:
self._record_system(r, "You joined #" + r)
if self.auto_who:
try:

View file

@ -29,6 +29,26 @@ MESSAGE_LOG_CAP = 5000
STORE_FILENAME = "hubs"
HUB_CONFIG_FILENAME = "hub.toml"
ROOMS_FILENAME = "rooms.toml"
DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900
MIN_ANNOUNCE_INTERVAL_SECONDS = 60
MAX_ANNOUNCE_INTERVAL_SECONDS = 86400
def normalize_announce_interval_seconds(
value, default=DEFAULT_ANNOUNCE_INTERVAL_SECONDS
):
"""Clamp announce interval to a supported range, or 0 to disable periodic announces."""
if value is None:
return int(default)
try:
seconds = int(value)
except (TypeError, ValueError):
return int(default)
if seconds <= 0:
return 0
return max(
MIN_ANNOUNCE_INTERVAL_SECONDS, min(MAX_ANNOUNCE_INTERVAL_SECONDS, seconds)
)
class _LoopbackEndpoint:
@ -90,6 +110,7 @@ class RRCHubServer:
name=None,
greeting=None,
announce=True,
announce_interval_seconds=DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
enabled=True,
):
self.manager = manager
@ -98,6 +119,9 @@ class RRCHubServer:
self.name = name or ("Hub " + identity.hash.hex()[:8])
self.greeting = greeting
self.announce = announce
self.announce_interval_seconds = normalize_announce_interval_seconds(
announce_interval_seconds,
)
self.enabled = enabled
self.max_nick_bytes = proto.DEFAULT_MAX_NICK_BYTES
@ -109,6 +133,7 @@ class RRCHubServer:
self.destination = None
self.running = False
self._started_at = None
self._announce_timer = None
self._lock = threading.RLock()
self._sessions = {}
@ -149,6 +174,7 @@ class RRCHubServer:
self._started_at = time.time()
if self.announce:
self.announce_now()
self._sync_announce_timer()
self._log("hub started at " + self.dest_hash.hex())
def announce_now(self):
@ -159,7 +185,47 @@ class RRCHubServer:
app_data=proto.encode({"proto": "rrc", "v": 1, "hub": self.name}),
)
def _cancel_announce_timer(self):
timer = self._announce_timer
self._announce_timer = None
if timer is not None:
with contextlib.suppress(Exception):
timer.cancel()
def _sync_announce_timer(self):
self._cancel_announce_timer()
if not self.running or not self.announce:
return
interval = normalize_announce_interval_seconds(
self.announce_interval_seconds,
default=0,
)
if interval <= 0:
return
timer = threading.Timer(interval, self._announce_timer_fire)
timer.daemon = True
self._announce_timer = timer
timer.start()
def _announce_timer_fire(self):
self._announce_timer = None
if not self.running or not self.announce:
return
with contextlib.suppress(Exception):
self.announce_now()
self._sync_announce_timer()
def set_announce_settings(self, announce=None, announce_interval_seconds=None):
if announce is not None:
self.announce = bool(announce)
if announce_interval_seconds is not None:
self.announce_interval_seconds = normalize_announce_interval_seconds(
announce_interval_seconds,
)
self._sync_announce_timer()
def stop(self):
self._cancel_announce_timer()
with self._lock:
links = list(self._sessions.keys())
self._sessions.clear()
@ -919,6 +985,7 @@ class RRCHubServer:
"running": self.running,
"uptime_seconds": uptime_seconds,
"announce": self.announce,
"announce_interval_seconds": self.announce_interval_seconds,
"greeting": self.greeting,
"clients": sum(1 for s in self._sessions.values() if s.welcomed),
"trusted_identities": policy["trusted_identities"],
@ -949,6 +1016,7 @@ class RRCHubServer:
"name": self.name,
"enabled": self.enabled,
"announce": self.announce,
"announce_interval_seconds": self.announce_interval_seconds,
"greeting": self.greeting,
"trusted_identities": policy["trusted_identities"],
"banned_identities": policy["banned_identities"],
@ -1004,7 +1072,14 @@ class RRCServerManager:
return hub
return None
def create_hub(self, name=None, greeting=None, announce=True, enabled=True):
def create_hub(
self,
name=None,
greeting=None,
announce=True,
announce_interval_seconds=DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
enabled=True,
):
identity = RNS.Identity()
os.makedirs(self._server_dir(), exist_ok=True)
identity.to_file(self._identity_path(identity.hash.hex()))
@ -1014,6 +1089,7 @@ class RRCServerManager:
name=name,
greeting=greeting,
announce=announce,
announce_interval_seconds=announce_interval_seconds,
enabled=enabled,
)
trusted = self._owner_trusted_hex_list()
@ -1082,6 +1158,7 @@ class RRCServerManager:
name=None,
greeting=None,
announce=None,
announce_interval_seconds=None,
trusted_identities=None,
banned_identities=None,
):
@ -1092,8 +1169,11 @@ class RRCServerManager:
hub.name = name
if greeting is not None:
hub.greeting = greeting or None
if announce is not None:
hub.announce = bool(announce)
if announce is not None or announce_interval_seconds is not None:
hub.set_announce_settings(
announce=announce,
announce_interval_seconds=announce_interval_seconds,
)
if trusted_identities is not None or banned_identities is not None:
hub.policy.apply_config(
trusted_list=trusted_identities,
@ -1178,6 +1258,10 @@ class RRCServerManager:
name=entry.get("name"),
greeting=entry.get("greeting"),
announce=bool(entry.get("announce", True)),
announce_interval_seconds=entry.get(
"announce_interval_seconds",
DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
),
enabled=bool(entry.get("enabled", True)),
)
hub.configure_storage(

View file

@ -58,6 +58,7 @@ SELF_CHECK_LABELS = {
"http_bots_status_good": "HTTP Bots Status ",
"http_security_good": "HTTP Server Security ",
"http_interfaces_good": "HTTP RNS Interfaces ",
"http_reticulum_instance_good": "HTTP RNS Instance ",
"http_identities_good": "HTTP Identities ",
"http_favourites_good": "HTTP Favourites ",
"http_telephone_good": "HTTP Telephone Status ",
@ -607,6 +608,7 @@ _WEB_PROBE_KEYS = (
"http_bots_status_good",
"http_security_good",
"http_interfaces_good",
"http_reticulum_instance_good",
"http_identities_good",
"http_favourites_good",
"http_telephone_good",
@ -747,6 +749,18 @@ async def _run_web_api_probes(app: Any) -> dict[str, dict[str, str]]:
"/api/v1/reticulum/interfaces",
require_nested=(("interfaces", dict),),
)
results["http_reticulum_instance_good"] = await _probe_json_get(
client,
"/api/v1/reticulum/instance",
require_nested=(("instance", dict),),
validate=lambda body: (
None
if isinstance(body.get("instance"), dict)
and "share_instance" in body["instance"]
and "local_hops_delta" in body["instance"]
else "instance missing share_instance/local_hops_delta"
),
)
results["http_identities_good"] = await _probe_json_get(
client,
"/api/v1/identities",

View file

@ -75,7 +75,7 @@
<div v-if="effectiveSidebarCollapsed" class="flex flex-1 flex-col items-center gap-1 py-2 px-1">
<button
type="button"
class="rounded-xl border-2 border-dashed border-sem-border p-2 text-sem-fg-muted hover:border-sem-accent hover:text-sem-accent"
class="rounded-xl p-2 text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
:title="$t('relay_chat.add_hub')"
@click="openAddHub"
>
@ -244,12 +244,22 @@
</ul>
<div v-if="availableRoomsFor(hub).length > 0" class="space-y-0.5">
<div
class="px-2.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted"
<button
type="button"
class="flex w-full items-center gap-1 px-2.5 pt-1 text-left text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted transition-colors hover:text-sem-fg"
@click="toggleAvailableRooms(hub.hub_hash)"
>
{{ $t("relay_chat.available_rooms") }}
</div>
<ul class="space-y-0.5">
<MaterialDesignIcon
:icon-name="
isAvailableRoomsExpanded(hub.hub_hash)
? 'chevron-down'
: 'chevron-right'
"
class="size-3.5 shrink-0"
/>
<span class="truncate">{{ $t("relay_chat.available_rooms") }}</span>
</button>
<ul v-show="isAvailableRoomsExpanded(hub.hub_hash)" class="space-y-0.5">
<li
v-for="availableRoom in availableRoomsFor(hub)"
:key="availableRoom.name"
@ -266,7 +276,7 @@
</span>
<button
type="button"
:class="btnIconSm"
class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
:title="$t('relay_chat.join')"
@click.stop="joinAvailableRoom(hub, availableRoom.name)"
>
@ -285,7 +295,7 @@
/>
<button
type="submit"
class="inline-flex size-7 shrink-0 items-center justify-center border border-sem-border bg-sem-canvas text-sem-fg transition hover:bg-sem-surface/60"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
:title="$t('relay_chat.join_room')"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
@ -615,7 +625,7 @@
<!-- discovery view -->
<div v-show="view === 'discovery'" class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div class="mx-auto w-full max-w-3xl space-y-4">
<div class="mx-auto w-full max-w-3xl space-y-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">{{ $t("relay_chat.discovery_title") }}</h2>
@ -644,7 +654,7 @@
<input
v-model="discoverySearch"
type="text"
:placeholder="$t('relay_chat.discovery_search')"
:placeholder="$t('relay_chat.discovery_search', { count: discovered.length })"
class="input-field !pl-9"
@input="onDiscoverySearch"
/>
@ -658,49 +668,56 @@
{{ $t("relay_chat.discovery_empty") }}
</div>
<div
v-for="node in discovered"
:key="node.destination_hash"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sem-border bg-sem-canvas p-4 transition-colors hover:border-sem-border-strong"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="forum-outline" class="size-4 shrink-0 text-sem-accent" />
<span class="truncate font-semibold">{{ nodeName(node) }}</span>
<div class="space-y-2">
<div
v-for="node in discovered"
:key="node.destination_hash"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sem-border bg-sem-canvas p-4 transition-colors hover:border-sem-border-strong"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<MaterialDesignIcon
icon-name="forum-outline"
class="size-4 shrink-0 text-sem-accent"
/>
<span class="truncate font-semibold">{{ nodeName(node) }}</span>
</div>
<button
type="button"
class="mt-1 flex items-center gap-1.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
:title="$t('relay_chat.copy_hash')"
@click="copyHash(node.destination_hash)"
>
<MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
<span class="truncate">{{ formatHash(node.destination_hash) }}</span>
</button>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-sem-fg-muted"
>
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="clock-outline" class="size-3.5" />
{{ $t("relay_chat.announced_ago", { time: timeAgo(node.updated_at) }) }}
</span>
<span v-if="node.hops != null" class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="transit-connection-variant" class="size-3.5" />
{{ $t("relay_chat.hops_away", { count: node.hops }) }}
</span>
</div>
</div>
<button
v-if="isHubAdded(node.destination_hash)"
type="button"
class="mt-1 flex items-center gap-1.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
:title="$t('relay_chat.copy_hash')"
@click="copyHash(node.destination_hash)"
:class="btnSecondary"
@click="openDiscovered(node)"
>
<MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
<span class="truncate">{{ formatHash(node.destination_hash) }}</span>
<MaterialDesignIcon icon-name="open-in-app" class="size-4" />
{{ $t("relay_chat.discovery_open") }}
</button>
<button v-else type="button" :class="btnPrimary" @click="addFromDiscovery(node)">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("relay_chat.discovery_add") }}
</button>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-sem-fg-muted">
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="clock-outline" class="size-3.5" />
{{ $t("relay_chat.announced_ago", { time: timeAgo(node.updated_at) }) }}
</span>
<span v-if="node.hops != null" class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="transit-connection-variant" class="size-3.5" />
{{ $t("relay_chat.hops_away", { count: node.hops }) }}
</span>
</div>
</div>
<button
v-if="isHubAdded(node.destination_hash)"
type="button"
:class="btnSecondary"
@click="openDiscovered(node)"
>
<MaterialDesignIcon icon-name="open-in-app" class="size-4" />
{{ $t("relay_chat.discovery_open") }}
</button>
<button v-else type="button" :class="btnPrimary" @click="addFromDiscovery(node)">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("relay_chat.discovery_add") }}
</button>
</div>
</div>
</div>
@ -779,6 +796,14 @@
<MaterialDesignIcon icon-name="stop" class="size-4" />
{{ $t("relay_chat.host_stop") }}
</button>
<button
type="button"
:class="btnIcon"
:title="$t('relay_chat.host_hub_settings')"
@click="openHostHubSettings(hub)"
>
<MaterialDesignIcon icon-name="cog" class="size-4" />
</button>
<button
type="button"
:class="btnIcon"
@ -802,12 +827,20 @@
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-sem-fg-muted">
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="account-group" class="size-3.5" />
{{ hub.clients }} {{ $t("relay_chat.host_clients") }}
{{ hub.clients }} {{ $t("relay_chat.host_users") }}
</span>
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="pound" class="size-3.5" />
{{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }}
</span>
<span v-if="hub.running" class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="clock-outline" class="size-3.5" />
{{
$t("relay_chat.host_moderation_uptime", {
time: formatUptime(hostedHubUptimeSeconds(hub)),
})
}}
</span>
</div>
<button
@ -850,10 +883,53 @@
class="input-field"
/>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="createHubForm.announce" type="checkbox" class="size-4" />
{{ $t("relay_chat.host_announce_periodically") }}
<label class="setting-toggle flex items-start gap-3">
<Toggle id="rrc-create-announce" v-model="createHubForm.announce" />
<span class="min-w-0 text-sm">
<span class="font-medium text-sem-fg">{{
$t("relay_chat.host_announce_periodically")
}}</span>
</span>
</label>
<div v-if="createHubForm.announce" class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="rrc-create-announce-interval"
class="text-sm font-semibold text-sem-fg-secondary"
>{{ $t("relay_chat.host_announce_interval") }}</label
>
<input
id="rrc-create-announce-interval-input"
type="text"
inputmode="numeric"
autocomplete="off"
maxlength="5"
class="w-16 shrink-0 rounded-lg border border-sem-border bg-sem-canvas px-1.5 py-1 text-center text-xs font-bold text-sem-accent tabular-nums shadow-xs focus:border-sem-accent focus:outline-hidden focus:ring-1 focus:ring-sem-accent/40"
:value="createAnnounceIntervalMinutesShown"
:aria-label="$t('relay_chat.host_announce_interval')"
@focus="onCreateAnnounceIntervalFocus"
@input="onCreateAnnounceIntervalInput"
@blur="onCreateAnnounceIntervalBlur"
/>
</div>
<input
id="rrc-create-announce-interval"
type="range"
min="0"
:max="announceSliderPosMax"
step="1"
:value="createAnnounceIntervalSliderPos"
class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-sem-surface-muted accent-sem-accent"
@input="onCreateAnnounceIntervalSlider"
/>
<p class="text-xs text-sem-fg-muted">
{{
$t("relay_chat.host_announce_interval_hint", {
minutes: createAnnounceIntervalMinutes,
})
}}
</p>
</div>
<div class="flex justify-end gap-2 pt-1">
<button type="button" :class="btnSecondary" @click="showCreateHub = false">
{{ $t("common.cancel") }}
@ -864,6 +940,79 @@
</div>
</div>
<!-- hosted hub settings dialog -->
<div v-if="showHostHubSettings" :class="RELAY_HOST_MODAL_OVERLAY" @click.self="showHostHubSettings = false">
<div :class="RELAY_HOST_MODAL_PANEL_COMPACT" @click.stop>
<h2 class="mb-4 text-lg font-semibold text-sem-fg">{{ $t("relay_chat.host_hub_settings") }}</h2>
<form class="space-y-4" @submit.prevent="saveHostHubSettings">
<div class="space-y-1.5">
<label class="block text-sm font-semibold text-sem-fg-secondary">{{
$t("relay_chat.hub_name")
}}</label>
<input
v-model="hostHubSettingsForm.name"
type="text"
:placeholder="$t('relay_chat.hub_name_placeholder')"
class="input-field"
/>
</div>
<label class="setting-toggle flex items-start gap-3">
<Toggle id="rrc-host-announce" v-model="hostHubSettingsForm.announce" />
<span class="min-w-0 text-sm">
<span class="font-medium text-sem-fg">{{
$t("relay_chat.host_announce_periodically")
}}</span>
</span>
</label>
<div v-if="hostHubSettingsForm.announce" class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="rrc-host-announce-interval"
class="text-sm font-semibold text-sem-fg-secondary"
>{{ $t("relay_chat.host_announce_interval") }}</label
>
<input
id="rrc-host-announce-interval-input"
type="text"
inputmode="numeric"
autocomplete="off"
maxlength="5"
class="w-16 shrink-0 rounded-lg border border-sem-border bg-sem-canvas px-1.5 py-1 text-center text-xs font-bold text-sem-accent tabular-nums shadow-xs focus:border-sem-accent focus:outline-hidden focus:ring-1 focus:ring-sem-accent/40"
:value="hostAnnounceIntervalMinutesShown"
:aria-label="$t('relay_chat.host_announce_interval')"
@focus="onHostAnnounceIntervalFocus"
@input="onHostAnnounceIntervalInput"
@blur="onHostAnnounceIntervalBlur"
/>
</div>
<input
id="rrc-host-announce-interval"
type="range"
min="0"
:max="announceSliderPosMax"
step="1"
:value="hostAnnounceIntervalSliderPos"
class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-sem-surface-muted accent-sem-accent"
@input="onHostAnnounceIntervalSlider"
/>
<p class="text-xs text-sem-fg-muted">
{{
$t("relay_chat.host_announce_interval_hint", {
minutes: hostAnnounceIntervalMinutes,
})
}}
</p>
</div>
<div class="flex justify-end gap-2 pt-1">
<button type="button" :class="btnSecondary" @click="showHostHubSettings = false">
{{ $t("common.cancel") }}
</button>
<button type="submit" :class="btnPrimary">{{ $t("relay_chat.save") }}</button>
</div>
</form>
</div>
</div>
<!-- add hub dialog -->
<div
v-if="showAddHub"
@ -1115,6 +1264,11 @@ import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import {
ANNOUNCE_SLIDER_POS_MAX,
announceMinutesToSliderPos,
announceSliderPosToMinutes,
} from "../../js/announceIntervalSliderMap.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
import RelayHostModerationPage from "./RelayHostModerationPage.vue";
@ -1142,6 +1296,25 @@ const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b
const RELAY_MESSAGES_INITIAL_PAGE_SIZE = 150;
const RELAY_MESSAGES_PREVIOUS_PAGE_SIZE = 100;
const LOAD_PREVIOUS_SCROLL_EDGE_PX = 200;
const DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900;
const ANNOUNCE_INTERVAL_MIN_MINUTES = 1;
const ANNOUNCE_INTERVAL_MAX_MINUTES = 1440;
function clampAnnounceIntervalMinutes(value) {
const n = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(n)) {
return Math.round(DEFAULT_ANNOUNCE_INTERVAL_SECONDS / 60);
}
return Math.max(ANNOUNCE_INTERVAL_MIN_MINUTES, Math.min(ANNOUNCE_INTERVAL_MAX_MINUTES, n));
}
function secondsToAnnounceMinutes(seconds) {
const s = Number(seconds);
if (!Number.isFinite(s) || s <= 0) {
return Math.round(DEFAULT_ANNOUNCE_INTERVAL_SECONDS / 60);
}
return clampAnnounceIntervalMinutes(Math.round(s / 60));
}
export default {
name: "RelayChatPage",
@ -1186,16 +1359,32 @@ export default {
discoveryLoading: false,
serverHubs: [],
roomForms: {},
announceSliderPosMax: ANNOUNCE_SLIDER_POS_MAX,
showCreateHub: false,
createHubForm: {
name: "",
greeting: "",
announce: true,
announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
},
createAnnounceIntervalDraft: null,
showHostHubSettings: false,
hostHubSettingsId: null,
hostHubSettingsForm: {
name: "",
announce: true,
announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
},
hostAnnounceIntervalDraft: null,
hubs: [],
selectedHubHash: null,
selectedRoom: null,
expandedHubs: {},
availableRoomsExpanded: {},
hostUptimeTick: 0,
hostUptimeAnchorMs: 0,
hostUptimeTimer: null,
expandedPresenceGroups: {},
relaySidebarCollapsed: loadFeatureSidebarCollapsed("relayChat") ?? false,
smUp: false,
smMq: null,
@ -1286,6 +1475,30 @@ export default {
const status = this.settingsHub?.status ?? 0;
return this.statusIconColor(status);
},
createAnnounceIntervalMinutes() {
return secondsToAnnounceMinutes(this.createHubForm.announce_interval_seconds);
},
createAnnounceIntervalSliderPos() {
return announceMinutesToSliderPos(this.createAnnounceIntervalMinutes);
},
createAnnounceIntervalMinutesShown() {
if (this.createAnnounceIntervalDraft != null) {
return this.createAnnounceIntervalDraft;
}
return String(this.createAnnounceIntervalMinutes);
},
hostAnnounceIntervalMinutes() {
return secondsToAnnounceMinutes(this.hostHubSettingsForm.announce_interval_seconds);
},
hostAnnounceIntervalSliderPos() {
return announceMinutesToSliderPos(this.hostAnnounceIntervalMinutes);
},
hostAnnounceIntervalMinutesShown() {
if (this.hostAnnounceIntervalDraft != null) {
return this.hostAnnounceIntervalDraft;
}
return String(this.hostAnnounceIntervalMinutes);
},
messageTimeline() {
return buildRelayMessageTimeline(this.messages);
},
@ -1354,6 +1567,12 @@ export default {
this.smMq = window.matchMedia("(min-width: 640px)");
this.smUp = this.smMq.matches;
this.smMq.addEventListener("change", this.onSmMqChange);
this.hostUptimeAnchorMs = Date.now();
this.hostUptimeTimer = window.setInterval(() => {
if (this.view === "host" && this.serverHubs.some((h) => h.running)) {
this.hostUptimeTick += 1;
}
}, 1000);
this.fetchHubs().then(() => {
this.restoreRelayLayout();
this.applyPopoutRoute();
@ -1369,6 +1588,10 @@ export default {
if (this.discoverySearchTimer) {
clearTimeout(this.discoverySearchTimer);
}
if (this.hostUptimeTimer) {
clearInterval(this.hostUptimeTimer);
this.hostUptimeTimer = null;
}
if (this.smMq) {
this.smMq.removeEventListener("change", this.onSmMqChange);
}
@ -1395,6 +1618,7 @@ export default {
selectedHubHash: this.selectedHubHash,
selectedRoom: this.selectedRoom,
expandedHubs: { ...this.expandedHubs },
availableRoomsExpanded: { ...this.availableRoomsExpanded },
relaySidebarCollapsed: this.relaySidebarCollapsed,
});
},
@ -1415,6 +1639,9 @@ export default {
if (saved.expandedHubs && typeof saved.expandedHubs === "object") {
this.expandedHubs = { ...saved.expandedHubs };
}
if (saved.availableRoomsExpanded && typeof saved.availableRoomsExpanded === "object") {
this.availableRoomsExpanded = { ...saved.availableRoomsExpanded };
}
if (saved.selectedHubHash && this.hubs.some((h) => h.hub_hash === saved.selectedHubHash)) {
this.selectedHubHash = saved.selectedHubHash;
this.expandedHubs[saved.selectedHubHash] = true;
@ -1459,6 +1686,145 @@ export default {
this.expandedHubs[hubHash] = !this.expandedHubs[hubHash];
this.persistRelayLayout();
},
isAvailableRoomsExpanded(hubHash) {
return this.availableRoomsExpanded[hubHash] !== false;
},
toggleAvailableRooms(hubHash) {
this.availableRoomsExpanded[hubHash] = !this.isAvailableRoomsExpanded(hubHash);
this.persistRelayLayout();
},
hostedHubUptimeSeconds(hub) {
void this.hostUptimeTick;
if (!hub?.running) {
return 0;
}
const base = Number(hub.uptime_seconds);
if (!Number.isFinite(base) || base < 0) {
return 0;
}
if (!this.hostUptimeAnchorMs) {
return Math.floor(base);
}
return Math.floor(base + (Date.now() - this.hostUptimeAnchorMs) / 1000);
},
formatUptime(seconds) {
if (seconds == null || seconds < 0) {
return "—";
}
let s = Math.floor(seconds);
if (s < 60) {
return `${s}s`;
}
if (s < 3600) {
return `${Math.floor(s / 60)}m`;
}
if (s < 86400) {
return `${Math.floor(s / 3600)}h`;
}
if (s < 30 * 86400) {
return `${Math.floor(s / 86400)}d`;
}
const yearSec = 365 * 86400;
const monthSec = 30 * 86400;
const years = Math.floor(s / yearSec);
s -= years * yearSec;
const months = Math.floor(s / monthSec);
s -= months * monthSec;
const days = Math.floor(s / 86400);
const parts = [];
if (years) {
parts.push(`${years}y`);
}
if (months) {
parts.push(`${months}mo`);
}
if (days) {
parts.push(`${days}d`);
}
return parts.length ? parts.join(" ") : "0d";
},
onCreateAnnounceIntervalSlider(event) {
const minutes = announceSliderPosToMinutes(event?.target?.value);
this.createHubForm.announce_interval_seconds = minutes * 60;
this.createAnnounceIntervalDraft = null;
},
onCreateAnnounceIntervalFocus() {
this.createAnnounceIntervalDraft = String(this.createAnnounceIntervalMinutes);
},
onCreateAnnounceIntervalInput(event) {
const raw = String(event?.target?.value ?? "").replace(/\D/g, "");
this.createAnnounceIntervalDraft = raw;
if (raw === "") {
return;
}
const minutes = Number.parseInt(raw, 10);
if (Number.isFinite(minutes)) {
this.createHubForm.announce_interval_seconds = clampAnnounceIntervalMinutes(minutes) * 60;
}
},
onCreateAnnounceIntervalBlur() {
const minutes = clampAnnounceIntervalMinutes(
this.createAnnounceIntervalDraft || this.createAnnounceIntervalMinutes
);
this.createHubForm.announce_interval_seconds = minutes * 60;
this.createAnnounceIntervalDraft = null;
},
onHostAnnounceIntervalSlider(event) {
const minutes = announceSliderPosToMinutes(event?.target?.value);
this.hostHubSettingsForm.announce_interval_seconds = minutes * 60;
this.hostAnnounceIntervalDraft = null;
},
onHostAnnounceIntervalFocus() {
this.hostAnnounceIntervalDraft = String(this.hostAnnounceIntervalMinutes);
},
onHostAnnounceIntervalInput(event) {
const raw = String(event?.target?.value ?? "").replace(/\D/g, "");
this.hostAnnounceIntervalDraft = raw;
if (raw === "") {
return;
}
const minutes = Number.parseInt(raw, 10);
if (Number.isFinite(minutes)) {
this.hostHubSettingsForm.announce_interval_seconds = clampAnnounceIntervalMinutes(minutes) * 60;
}
},
onHostAnnounceIntervalBlur() {
const minutes = clampAnnounceIntervalMinutes(
this.hostAnnounceIntervalDraft || this.hostAnnounceIntervalMinutes
);
this.hostHubSettingsForm.announce_interval_seconds = minutes * 60;
this.hostAnnounceIntervalDraft = null;
},
openHostHubSettings(hub) {
this.hostHubSettingsId = hub.id;
this.hostHubSettingsForm = {
name: hub.name || "",
announce: hub.announce !== false,
announce_interval_seconds:
hub.announce_interval_seconds > 0
? hub.announce_interval_seconds
: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
};
this.hostAnnounceIntervalDraft = null;
this.showHostHubSettings = true;
},
async saveHostHubSettings() {
if (!this.hostHubSettingsId) {
return;
}
try {
await window.api.patch(`/api/v1/rrc/servers/${this.hostHubSettingsId}`, {
name: this.hostHubSettingsForm.name.trim() || undefined,
announce: this.hostHubSettingsForm.announce,
announce_interval_seconds: this.hostHubSettingsForm.announce_interval_seconds,
});
this.showHostHubSettings = false;
ToastUtils.success(this.$t("relay_chat.settings_saved"));
await this.fetchServers();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
statusLabel(status) {
switch (status) {
case 1:
@ -1834,9 +2200,42 @@ export default {
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}-${index}`;
}
if (entry.type === "presenceGroup") {
return `presence-${entry.id}-${index}`;
}
const msgKey = this.messageKey(entry.msg);
return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
},
isPresenceGroupExpanded(groupId) {
return !!this.expandedPresenceGroups[groupId];
},
togglePresenceGroup(groupId) {
if (!groupId) {
return;
}
this.expandedPresenceGroups[groupId] = !this.expandedPresenceGroups[groupId];
},
formatPresenceGroupSummary(entry) {
const joined = Number(entry?.joinedCount) || 0;
const left = Number(entry?.leftCount) || 0;
const connection = Number(entry?.connectionCount) || 0;
const parts = [];
if (joined > 0) {
parts.push(this.$t("relay_chat.presence_joined", { count: joined }));
}
if (left > 0) {
parts.push(this.$t("relay_chat.presence_left", { count: left }));
}
if (connection > 0) {
parts.push(this.$t("relay_chat.presence_connection", { count: connection }));
}
if (parts.length === 0) {
return this.$t("relay_chat.presence_events", {
count: Array.isArray(entry?.messages) ? entry.messages.length : 0,
});
}
return parts.join(" · ");
},
formatDateDividerLabel(dayKey) {
if (!dayKey || typeof dayKey !== "string") {
return "";
@ -1955,6 +2354,7 @@ export default {
this.selectedRoom = room;
this.expandedHubs[hubHash] = true;
this.hasMorePrevious = false;
this.expandedPresenceGroups = {};
// Clear before fetch so only websocket arrivals during the request are merged back.
this.messages = [];
this.members = [];
@ -2364,12 +2764,20 @@ export default {
}
}
this.serverHubs = hubs;
this.hostUptimeAnchorMs = Date.now();
this.hostUptimeTick = 0;
} catch {
// relay chat hosting may be unavailable for this identity
}
},
openCreateHub() {
this.createHubForm = { name: "", greeting: "", announce: true };
this.createHubForm = {
name: "",
greeting: "",
announce: true,
announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
};
this.createAnnounceIntervalDraft = null;
this.showCreateHub = true;
},
async createServerHub() {
@ -2378,6 +2786,7 @@ export default {
name: this.createHubForm.name.trim() || undefined,
greeting: this.createHubForm.greeting.trim() || undefined,
announce: this.createHubForm.announce,
announce_interval_seconds: this.createHubForm.announce_interval_seconds,
});
this.showCreateHub = false;
ToastUtils.success(this.$t("relay_chat.host_hub_created"));

View file

@ -15,6 +15,30 @@
</span>
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
</div>
<div v-else-if="entry.type === 'presenceGroup'" class="px-2 py-1">
<button
type="button"
class="mx-auto flex max-w-full items-center gap-1 rounded-md px-2 py-0.5 text-xs italic text-sem-fg-muted transition-colors hover:bg-sem-surface/50 hover:text-sem-fg"
:aria-expanded="expanded"
@click="page.togglePresenceGroup(entry.id)"
>
<MaterialDesignIcon
:icon-name="expanded ? 'chevron-down' : 'chevron-right'"
class="size-3.5 shrink-0 opacity-70"
/>
<span class="truncate">{{ page.formatPresenceGroupSummary(entry) }}</span>
</button>
<div v-if="expanded" class="mt-1 space-y-0.5">
<div
v-for="(msg, idx) in entry.messages"
:key="page.messageKey(msg) || idx"
class="py-0.5 text-center text-xs italic text-sem-fg-muted"
:data-msg-key="page.messageKey(msg)"
>
{{ msg.text }}
</div>
</div>
</div>
<div
v-else-if="isSystemLike"
class="py-0.5 text-center text-xs italic"
@ -47,6 +71,7 @@
<script setup>
import { computed } from "vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
const props = defineProps({
entry: {
@ -63,4 +88,11 @@ const isSystemLike = computed(() => {
const kind = props.entry?.msg?.kind;
return kind === "system" || kind === "notice" || kind === "error";
});
const expanded = computed(() => {
if (props.entry?.type !== "presenceGroup") {
return false;
}
return props.page.isPresenceGroupExpanded(props.entry.id);
});
</script>

View file

@ -55,6 +55,9 @@ function entryKey(entry, index) {
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}-${index}`;
}
if (entry.type === "presenceGroup") {
return `presence-${entry.id}-${index}`;
}
const msgKey = props.page.messageKey(entry.msg);
return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
}

View file

@ -12,6 +12,9 @@ export function estimateRelayEntryHeight(entry) {
if (entry.type === "dateDivider") {
return 44;
}
if (entry.type === "presenceGroup") {
return 28;
}
const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
let height = 32;
if (text) {

View file

@ -3275,6 +3275,7 @@ export default {
item("http_bots_status_good", "selftest.http_bots_status_good"),
item("http_security_good", "selftest.http_security_good"),
item("http_interfaces_good", "selftest.http_interfaces_good"),
item("http_reticulum_instance_good", "selftest.http_reticulum_instance_good"),
item("http_identities_good", "selftest.http_identities_good"),
item("http_favourites_good", "selftest.http_favourites_good"),
item("http_telephone_good", "selftest.http_telephone_good"),
@ -3505,6 +3506,7 @@ export default {
http_bots_status_good: { ...failed },
http_security_good: { ...failed },
http_interfaces_good: { ...failed },
http_reticulum_instance_good: { ...failed },
http_identities_good: { ...failed },
http_favourites_good: { ...failed },
http_telephone_good: { ...failed },

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: 0BSD AND MIT
/** High-resolution slider positions for smooth dragging (minutes 1..1440). */
export const ANNOUNCE_SLIDER_POS_MAX = 2047;
const MIN_MINUTES = 1;
const MAX_MINUTES = 1440;
/**
* @param {number} pos
* @returns {number} Announce interval in minutes (1..1440).
*/
export function announceSliderPosToMinutes(pos) {
const p = Math.max(0, Math.min(ANNOUNCE_SLIDER_POS_MAX, Math.round(Number(pos) || 0)));
return Math.round(MIN_MINUTES + (p / ANNOUNCE_SLIDER_POS_MAX) * (MAX_MINUTES - MIN_MINUTES));
}
/**
* @param {number} minutes
* @returns {number} Slider position 0 .. ANNOUNCE_SLIDER_POS_MAX
*/
export function announceMinutesToSliderPos(minutes) {
const m = Math.max(MIN_MINUTES, Math.min(MAX_MINUTES, Math.round(Number(minutes) || MIN_MINUTES)));
return Math.round(((m - MIN_MINUTES) / (MAX_MINUTES - MIN_MINUTES)) * ANNOUNCE_SLIDER_POS_MAX);
}

View file

@ -15,7 +15,9 @@ export function relayMessageKey(msg) {
}
const src = msg.src || "";
const text = typeof msg.text === "string" ? msg.text : "";
return `${msg.kind || "msg"}-${msg.ts || 0}-${src}-${text.length}-${text.slice(0, 24)}`;
// Keep keys attribute-safe: never embed raw message text (XSS-shaped payloads).
const textFrag = encodeURIComponent(text.slice(0, 24));
return `${msg.kind || "msg"}-${msg.ts || 0}-${src}-${text.length}-${textFrag}`;
}
/**
@ -59,9 +61,77 @@ export function mergeRelayMessages(base, extras) {
return out;
}
const CONNECTION_EVENT_TEXTS = new Set(["Connection lost", "Disconnected from hub", "Reconnected to hub"]);
/**
* Join/leave/connection system lines produced by the RRC client.
* @param {object} msg
* @returns {boolean}
*/
export function isRelayPresenceSystemMessage(msg) {
if (!msg || msg.kind !== "system") {
return false;
}
const text = typeof msg.text === "string" ? msg.text.trim() : "";
if (!text) {
return false;
}
if (CONNECTION_EVENT_TEXTS.has(text)) {
return true;
}
if (text.endsWith(" joined") || text.endsWith(" left")) {
return true;
}
return /^You (?:re)?joined #/i.test(text);
}
/**
* @param {object} msg
* @returns {"joined"|"left"|"connection"}
*/
export function relayPresenceEventKind(msg) {
const text = typeof msg?.text === "string" ? msg.text.trim() : "";
if (CONNECTION_EVENT_TEXTS.has(text)) {
return "connection";
}
if (text.endsWith(" left")) {
return "left";
}
return "joined";
}
/**
* @param {object[]} presenceMessages
* @returns {{ type: string, id: string, messages: object[], joinedCount: number, leftCount: number, connectionCount: number }}
*/
function buildPresenceGroup(presenceMessages) {
let joinedCount = 0;
let leftCount = 0;
let connectionCount = 0;
for (const msg of presenceMessages) {
const kind = relayPresenceEventKind(msg);
if (kind === "left") {
leftCount += 1;
} else if (kind === "connection") {
connectionCount += 1;
} else {
joinedCount += 1;
}
}
const firstKey = relayMessageKey(presenceMessages[0]);
return {
type: "presenceGroup",
id: firstKey || `presence-${presenceMessages.length}`,
messages: presenceMessages,
joinedCount,
leftCount,
connectionCount,
};
}
/**
* @param {object[]} messages
* @returns {{ type: string, dayKey?: string, msg?: object }[]}
* @returns {{ type: string, dayKey?: string, msg?: object, id?: string, messages?: object[], joinedCount?: number, leftCount?: number, connectionCount?: number }[]}
*/
export function buildRelayMessageTimeline(messages) {
if (!Array.isArray(messages) || messages.length === 0) {
@ -69,6 +139,21 @@ export function buildRelayMessageTimeline(messages) {
}
const out = [];
let prevDayKey = null;
/** @type {object[]} */
let presenceBuffer = [];
const flushPresence = () => {
if (presenceBuffer.length === 0) {
return;
}
if (presenceBuffer.length === 1) {
out.push({ type: "message", msg: presenceBuffer[0] });
} else {
out.push(buildPresenceGroup(presenceBuffer));
}
presenceBuffer = [];
};
for (const msg of messages) {
let dayKey = null;
if (msg?.ts != null) {
@ -79,10 +164,17 @@ export function buildRelayMessageTimeline(messages) {
}
}
if (dayKey && dayKey !== prevDayKey) {
flushPresence();
out.push({ type: "dateDivider", dayKey });
prevDayKey = dayKey;
}
if (isRelayPresenceSystemMessage(msg)) {
presenceBuffer.push(msg);
continue;
}
flushPresence();
out.push({ type: "message", msg });
}
flushPresence();
return out;
}

View file

@ -763,7 +763,8 @@
"failed": "Fehlgeschlagen",
"status_label": "Status",
"reason_label": "Grund",
"checks_completed": "Alle Prüfungen erfolgreich bestanden."
"checks_completed": "Alle Prüfungen erfolgreich bestanden.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Wartung & Daten",
@ -3203,7 +3204,7 @@
"discovery_title": "Entdeckte Hubs",
"discovery_subtitle": "Relay-Chat-Hubs, die im Netzwerk angekündigt wurden.",
"discovery_empty": "Noch keine Hubs entdeckt. Sie erscheinen hier, sobald sie sich ankündigen.",
"discovery_search": "Hubs suchen",
"discovery_search": "{count} Hubs durchsuchen",
"discovery_add": "Hinzufügen",
"discovery_added": "Hub aus Entdeckung hinzugefügt",
"discovery_open": "Öffnen",
@ -3272,7 +3273,15 @@
"search_messages": "Suchen",
"search_no_results": "Keine Nachrichten entsprechen der Suche",
"popout_channel": "In neuem Fenster öffnen",
"new_message_toast": "Neue Nachricht in #{room}"
"new_message_toast": "Neue Nachricht in #{room}",
"host_users": "Benutzer",
"host_announce_interval": "Announce-Intervall (Minuten)",
"host_announce_interval_hint": "Alle {minutes} Minuten",
"host_hub_settings": "Hub-Einstellungen",
"presence_joined": "{count} beigetreten",
"presence_left": "{count} verlassen",
"presence_events": "{count} Beitritts-/Austrittsereignisse",
"presence_connection": "{count} Verbindungsereignisse"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -725,7 +725,7 @@
},
"selftest": {
"title": "System Self-Test",
"description": "Run diagnostic checks for the network stack, database, identity, imports, storage, LXMF, subprocess/run-module, SQLite, identity files, loopback TCP, unicode paths, RNode helpers, bot launcher argv, HTTP status/config/health/auth/bots/security/interfaces/identities/favourites/telephone APIs, WebSocket, and bot lifecycle.",
"description": "Run diagnostic checks for the network stack, database, identity, imports, storage, LXMF, subprocess/run-module, SQLite, identity files, loopback TCP, unicode paths, RNode helpers, bot launcher argv, HTTP status/config/health/auth/bots/security/interfaces/reticulum-instance/identities/favourites/telephone APIs, WebSocket, and bot lifecycle.",
"run_test_btn": "Run Diagnostics",
"running": "Running Diagnostics...",
"stack_up": "Network Stack",
@ -754,6 +754,7 @@
"http_bots_status_good": "HTTP Bots Status",
"http_security_good": "HTTP Server Security",
"http_interfaces_good": "HTTP RNS Interfaces",
"http_reticulum_instance_good": "HTTP RNS Instance Settings",
"http_identities_good": "HTTP Identities",
"http_favourites_good": "HTTP Favourites",
"http_telephone_good": "HTTP Telephone Status",
@ -2602,7 +2603,7 @@
"discovery_title": "Discovered Hubs",
"discovery_subtitle": "Relay Chat hubs heard announcing on the network.",
"discovery_empty": "No hubs discovered yet. They appear here as they announce.",
"discovery_search": "Search hubs",
"discovery_search": "Search {count} hubs",
"discovery_add": "Add",
"discovery_added": "Hub added from discovery",
"discovery_open": "Open",
@ -2631,7 +2632,15 @@
"members_search_placeholder": "Search members...",
"members_search_no_results": "No members match your search.",
"popout_channel": "Open in new window",
"new_message_toast": "New message in #{room}"
"new_message_toast": "New message in #{room}",
"host_users": "users",
"host_announce_interval": "Announce interval (minutes)",
"host_announce_interval_hint": "Every {minutes} minutes",
"host_hub_settings": "Hub settings",
"presence_joined": "{count} joined",
"presence_left": "{count} left",
"presence_events": "{count} join/leave events",
"presence_connection": "{count} connection events"
},
"rncp": {
"file_transfer": "File Transfer",

View file

@ -763,7 +763,8 @@
"failed": "Fallido",
"status_label": "Estado",
"reason_label": "Motivo",
"checks_completed": "Todas las comprobaciones se completaron con éxito."
"checks_completed": "Todas las comprobaciones se completaron con éxito.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Datos de mantenimiento",
@ -3203,7 +3204,7 @@
"discovery_title": "Hubs descubiertos",
"discovery_subtitle": "Hubs de Relay Chat anunciados en la red.",
"discovery_empty": "Aún no se han descubierto hubs. Aparecerán aquí cuando se anuncien.",
"discovery_search": "Buscar hubs",
"discovery_search": "Buscar {count} hubs",
"discovery_add": "Añadir",
"discovery_added": "Hub añadido desde el descubrimiento",
"discovery_open": "Abrir",
@ -3272,7 +3273,15 @@
"search_messages": "Buscar",
"search_no_results": "Ningún mensaje coincide con la búsqueda",
"popout_channel": "Abrir en una ventana nueva",
"new_message_toast": "Nuevo mensaje en #{room}"
"new_message_toast": "Nuevo mensaje en #{room}",
"host_users": "usuarios",
"host_announce_interval": "Intervalo de anuncio (minutos)",
"host_announce_interval_hint": "Cada {minutes} minutos",
"host_hub_settings": "Ajustes del hub",
"presence_joined": "{count} se unieron",
"presence_left": "{count} salieron",
"presence_events": "{count} eventos de entrada/salida",
"presence_connection": "{count} eventos de conexión"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -763,7 +763,8 @@
"failed": "Epäonnistunut",
"status_label": "Tila",
"reason_label": "Syy",
"checks_completed": "Kaikki tarkistukset suoritettiin onnistuneesti."
"checks_completed": "Kaikki tarkistukset suoritettiin onnistuneesti.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Ylläpito ja tiedot",
@ -2602,7 +2603,7 @@
"discovery_title": "Discovered Hubs",
"discovery_subtitle": "Relay Chat hubs heard announcing on the network.",
"discovery_empty": "No hubs discovered yet. They appear here as they announce.",
"discovery_search": "Search hubs",
"discovery_search": "Hae {count} hubia",
"discovery_add": "Lisää",
"discovery_added": "Hub added from discovery",
"discovery_open": "Avaa",
@ -2631,7 +2632,15 @@
"members_search_placeholder": "Search members...",
"members_search_no_results": "No members match your search.",
"popout_channel": "Open in new window",
"new_message_toast": "New message in #{room}"
"new_message_toast": "New message in #{room}",
"host_users": "käyttäjää",
"host_announce_interval": "Ilmoitusväli (minuuttia)",
"host_announce_interval_hint": "Joka {minutes}. minuutti",
"host_hub_settings": "Hubin asetukset",
"presence_joined": "{count} liittyi",
"presence_left": "{count} lähti",
"presence_events": "{count} liittymis-/lähtötapahtumaa",
"presence_connection": "{count} yhteystapahtumaa"
},
"rncp": {
"file_transfer": "File Transfer",

View file

@ -763,7 +763,8 @@
"failed": "Échoué",
"status_label": "Statut",
"reason_label": "Raison",
"checks_completed": "Tous les contrôles ont réussi."
"checks_completed": "Tous les contrôles ont réussi.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Maintenance et données",
@ -3203,7 +3204,7 @@
"discovery_title": "Hubs découverts",
"discovery_subtitle": "Hubs Relay Chat annoncés sur le réseau.",
"discovery_empty": "Aucun hub découvert pour l'instant. Ils apparaissent ici lorsqu'ils s'annoncent.",
"discovery_search": "Rechercher des hubs",
"discovery_search": "Rechercher {count} hubs",
"discovery_add": "Ajouter",
"discovery_added": "Hub ajouté depuis la découverte",
"discovery_open": "Ouvrir",
@ -3272,7 +3273,15 @@
"search_messages": "Rechercher",
"search_no_results": "Aucun message ne correspond à la recherche",
"popout_channel": "Ouvrir dans une nouvelle fenêtre",
"new_message_toast": "Nouveau message dans #{room}"
"new_message_toast": "Nouveau message dans #{room}",
"host_users": "utilisateurs",
"host_announce_interval": "Intervalle d'annonce (minutes)",
"host_announce_interval_hint": "Toutes les {minutes} minutes",
"host_hub_settings": "Paramètres du hub",
"presence_joined": "{count} ont rejoint",
"presence_left": "{count} sont partis",
"presence_events": "{count} événements entrée/sortie",
"presence_connection": "{count} événements de connexion"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -763,7 +763,8 @@
"failed": "Fallito",
"status_label": "Stato",
"reason_label": "Motivo",
"checks_completed": "Tutti i controlli sono stati superati con successo."
"checks_completed": "Tutti i controlli sono stati superati con successo.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Manutenzione e Dati",
@ -3203,7 +3204,7 @@
"discovery_title": "Hub scoperti",
"discovery_subtitle": "Hub Relay Chat annunciati sulla rete.",
"discovery_empty": "Nessun hub ancora scoperto. Appariranno qui quando si annunciano.",
"discovery_search": "Cerca hub",
"discovery_search": "Cerca {count} hub",
"discovery_add": "Aggiungi",
"discovery_added": "Hub aggiunto dalla scoperta",
"discovery_open": "Apri",
@ -3272,7 +3273,15 @@
"search_messages": "Cerca",
"search_no_results": "Nessun messaggio corrisponde alla ricerca",
"popout_channel": "Apri in una nuova finestra",
"new_message_toast": "Nuovo messaggio in #{room}"
"new_message_toast": "Nuovo messaggio in #{room}",
"host_users": "utenti",
"host_announce_interval": "Intervallo annuncio (minuti)",
"host_announce_interval_hint": "Ogni {minutes} minuti",
"host_hub_settings": "Impostazioni hub",
"presence_joined": "{count} entrati",
"presence_left": "{count} usciti",
"presence_events": "{count} eventi entrata/uscita",
"presence_connection": "{count} eventi di connessione"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -763,7 +763,8 @@
"failed": "Mislukt",
"status_label": "Status",
"reason_label": "Reden",
"checks_completed": "Alle controles zijn succesvol geslaagd."
"checks_completed": "Alle controles zijn succesvol geslaagd.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Onderhoud & gegevens",
@ -3203,7 +3204,7 @@
"discovery_title": "Ontdekte hubs",
"discovery_subtitle": "Relay Chat-hubs die op het netwerk aankondigen.",
"discovery_empty": "Nog geen hubs ontdekt. Ze verschijnen hier zodra ze aankondigen.",
"discovery_search": "Hubs zoeken",
"discovery_search": "{count} hubs zoeken",
"discovery_add": "Toevoegen",
"discovery_added": "Hub toegevoegd via ontdekking",
"discovery_open": "Openen",
@ -3272,7 +3273,15 @@
"search_messages": "Zoeken",
"search_no_results": "Geen berichten komen overeen met je zoekopdracht",
"popout_channel": "Openen in nieuw venster",
"new_message_toast": "Nieuw bericht in #{room}"
"new_message_toast": "Nieuw bericht in #{room}",
"host_users": "gebruikers",
"host_announce_interval": "Aankondigingsinterval (minuten)",
"host_announce_interval_hint": "Elke {minutes} minuten",
"host_hub_settings": "Hub-instellingen",
"presence_joined": "{count} toegetreden",
"presence_left": "{count} vertrokken",
"presence_events": "{count} join/leave-gebeurtenissen",
"presence_connection": "{count} verbindingsgebeurtenissen"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -763,7 +763,8 @@
"failed": "Ошибка",
"status_label": "Статус",
"reason_label": "Причина",
"checks_completed": "Все проверки выполнены успешно."
"checks_completed": "Все проверки выполнены успешно.",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Обслуживание и данные",
@ -3203,7 +3204,7 @@
"discovery_title": "Обнаруженные хабы",
"discovery_subtitle": "Хабы Relay Chat, объявленные в сети.",
"discovery_empty": "Пока не обнаружено ни одного хаба. Они появятся здесь после объявления.",
"discovery_search": "Поиск хабов",
"discovery_search": "Поиск среди {count} хабов",
"discovery_add": "Добавить",
"discovery_added": "Хаб добавлен из обзора",
"discovery_open": "Открыть",
@ -3272,7 +3273,15 @@
"search_messages": "Поиск",
"search_no_results": "Сообщения по запросу не найдены",
"popout_channel": "Открыть в новом окне",
"new_message_toast": "Новое сообщение в #{room}"
"new_message_toast": "Новое сообщение в #{room}",
"host_users": "пользователей",
"host_announce_interval": "Интервал анонса (минуты)",
"host_announce_interval_hint": "Каждые {minutes} мин.",
"host_hub_settings": "Настройки хаба",
"presence_joined": "{count} вошли",
"presence_left": "{count} вышли",
"presence_events": "{count} событий входа/выхода",
"presence_connection": "{count} событий соединения"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -763,7 +763,8 @@
"failed": "已失败",
"status_label": "状态",
"reason_label": "原因",
"checks_completed": "所有检查已成功通过。"
"checks_completed": "所有检查已成功通过。",
"http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "数据维护",
@ -3203,7 +3204,7 @@
"discovery_title": "已发现的中枢",
"discovery_subtitle": "在网络上通告的 Relay Chat 中枢。",
"discovery_empty": "尚未发现中枢。它们在通告后会出现在此处。",
"discovery_search": "搜索中枢",
"discovery_search": "搜索 {count} 个枢纽",
"discovery_add": "添加",
"discovery_added": "已从发现添加中枢",
"discovery_open": "打开",
@ -3272,7 +3273,15 @@
"search_messages": "搜索",
"search_no_results": "没有匹配搜索的消息",
"popout_channel": "在新窗口中打开",
"new_message_toast": "#{room} 中有新消息"
"new_message_toast": "#{room} 中有新消息",
"host_users": "用户",
"host_announce_interval": "通告间隔(分钟)",
"host_announce_interval_hint": "每 {minutes} 分钟",
"host_hub_settings": "枢纽设置",
"presence_joined": "{count} 人加入",
"presence_left": "{count} 人离开",
"presence_events": "{count} 条加入/离开事件",
"presence_connection": "{count} 条连接事件"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",

View file

@ -226,6 +226,7 @@ SELF_TEST_SCHEMA: dict = {
"http_bots_status_good",
"http_security_good",
"http_interfaces_good",
"http_reticulum_instance_good",
"http_identities_good",
"http_favourites_good",
"http_telephone_good",
@ -259,6 +260,7 @@ SELF_TEST_SCHEMA: dict = {
"http_bots_status_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_security_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_interfaces_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_reticulum_instance_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_identities_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_favourites_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_telephone_good": SELF_TEST_STATUS_ITEM_SCHEMA,

View file

@ -7,6 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from meshchatx.meshchat import ReticulumMeshChat
@ -54,6 +56,39 @@ def test_parse_rns_config_bool():
assert ReticulumMeshChat._format_rns_config_bool(False) == "No"
@given(
raw=st.one_of(
st.booleans(),
st.sampled_from(
[
"Yes",
"No",
"yes",
"no",
"TRUE",
"false",
"1",
"0",
"on",
"off",
"",
" Yes ",
]
),
st.integers(min_value=-3, max_value=3),
st.none(),
),
default=st.booleans(),
)
@settings(max_examples=80)
def test_parse_rns_config_bool_fuzz(raw, default):
result = ReticulumMeshChat._parse_rns_config_bool(raw, default=default)
assert isinstance(result, bool)
formatted = ReticulumMeshChat._format_rns_config_bool(result)
assert formatted in ("Yes", "No")
assert ReticulumMeshChat._parse_rns_config_bool(formatted) is result
@pytest.mark.asyncio
async def test_reticulum_instance_get_and_patch(temp_dir):
config = ConfigDict(
@ -177,3 +212,247 @@ async def test_reticulum_instance_rejects_bad_type(temp_dir):
response = await patch_handler(PatchRequest())
assert response.status == 400
@pytest.mark.asyncio
async def test_reticulum_instance_rejects_bad_instance_name(temp_dir):
config = ConfigDict({"reticulum": {"share_instance": "Yes"}, "interfaces": {}})
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.share_instance = True
mock_reticulum.transport_enabled.return_value = False
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app_instance.reload_reticulum = AsyncMock(return_value=True)
patch_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/instance",
"PATCH",
)
class PatchRequest:
@staticmethod
async def json():
return {"instance_name": "bad name"}
response = await patch_handler(PatchRequest())
assert response.status == 400
app_instance.reload_reticulum.assert_not_awaited()
@pytest.mark.asyncio
async def test_reticulum_instance_empty_patch_noop(temp_dir):
config = ConfigDict(
{
"reticulum": {
"share_instance": "Yes",
"local_hops_delta": "No",
},
"interfaces": {},
},
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.share_instance = True
mock_reticulum.transport_enabled.return_value = False
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app_instance.reload_reticulum = AsyncMock(return_value=True)
patch_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/instance",
"PATCH",
)
class PatchRequest:
@staticmethod
async def json():
return {}
response = await patch_handler(PatchRequest())
assert response.status == 200
assert config.write_called is False
app_instance.reload_reticulum.assert_not_awaited()
@pytest.mark.asyncio
async def test_reticulum_instance_clears_optional_fields(temp_dir):
config = ConfigDict(
{
"reticulum": {
"share_instance": "Yes",
"shared_instance_type": "tcp",
"instance_name": "custom",
},
"interfaces": {},
},
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.share_instance = True
mock_reticulum.transport_enabled.return_value = False
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app_instance.reload_reticulum = AsyncMock(return_value=True)
patch_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/instance",
"PATCH",
)
class PatchRequest:
@staticmethod
async def json():
return {"shared_instance_type": "", "instance_name": ""}
response = await patch_handler(PatchRequest())
assert response.status == 200
assert "shared_instance_type" not in config["reticulum"]
assert "instance_name" not in config["reticulum"]
@given(
payload=st.fixed_dictionaries(
{},
optional={
"share_instance": st.one_of(
st.booleans(), st.sampled_from(["Yes", "No", 1, 0])
),
"local_hops_delta": st.one_of(
st.booleans(), st.sampled_from(["yes", "no"])
),
"respond_to_probes": st.booleans(),
"enable_remote_management": st.booleans(),
"shared_instance_type": st.one_of(
st.none(),
st.sampled_from(["tcp", "unix", "TCP", "Unix", "udp", "quic", ""]),
),
"instance_name": st.one_of(
st.none(),
st.sampled_from(
["default", "meshchatx", "a", "bad name", "x" * 65, ""]
),
st.text(min_size=0, max_size=80),
),
},
)
)
@settings(
max_examples=60,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
@pytest.mark.asyncio
async def test_reticulum_instance_patch_fuzz_never_500(payload, temp_dir):
config = ConfigDict(
{
"reticulum": {
"share_instance": "Yes",
"local_hops_delta": "No",
},
"interfaces": {},
},
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
):
mock_reticulum = mock_rns.return_value
mock_reticulum.config = config
mock_reticulum.configpath = "/tmp/mock_config"
mock_reticulum.is_connected_to_shared_instance = False
mock_reticulum.share_instance = True
mock_reticulum.transport_enabled.return_value = False
app_instance = ReticulumMeshChat(
identity=build_identity(),
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
app_instance.reload_reticulum = AsyncMock(return_value=True)
patch_handler = await find_route_handler(
app_instance,
"/api/v1/reticulum/instance",
"PATCH",
)
class PatchRequest:
@staticmethod
async def json():
return payload
response = await patch_handler(PatchRequest())
assert response.status in (200, 400)
body = json.loads(response.body)
assert isinstance(body, dict)
if response.status == 200:
assert "instance" in body
assert isinstance(body["instance"].get("share_instance"), bool)
assert isinstance(body["instance"].get("local_hops_delta"), bool)
def test_build_reticulum_instance_settings_prefers_config_over_live():
app = MagicMock()
app._get_reticulum_section = lambda: {
"share_instance": "No",
"local_hops_delta": "No",
"rpc_key": "aa" * 32,
"instance_name": "default",
}
app._parse_rns_config_bool = ReticulumMeshChat._parse_rns_config_bool
app._get_reticulum_rpc_key_hex = lambda: (
ReticulumMeshChat._get_reticulum_rpc_key_hex(app)
)
app.reticulum = MagicMock()
app.reticulum.share_instance = True
app.reticulum.is_connected_to_shared_instance = False
app.reticulum.transport_enabled = MagicMock(return_value=True)
app.reticulum.rpc_key = None
settings = ReticulumMeshChat._build_reticulum_instance_settings(app)
assert settings["share_instance"] is False
assert settings["local_hops_delta"] is False
assert settings["rpc_config_snippet"]
assert "rpc_key = " in settings["rpc_config_snippet"]

View file

@ -263,6 +263,87 @@ def test_welcome_sends_list_when_auto_list_enabled(tmp_path):
assert calls == ["/list"]
def test_silent_self_join_records_rejoined_system_line(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub._pending_joins.add("lobby")
hub._silent_joins.add("lobby")
env = proto.make_envelope(
proto.T_JOINED,
src=None,
room="lobby",
body=[manager.identity.hash],
nick="me",
)
hub._handle_joined(env)
msgs = hub.messages.get("lobby", [])
assert any(m.kind == "system" and m.text == "You rejoined #lobby" for m in msgs)
def test_manual_self_join_records_joined_system_line(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub._pending_joins.add("lobby")
env = proto.make_envelope(
proto.T_JOINED,
src=None,
room="lobby",
body=[manager.identity.hash],
nick="me",
)
hub._handle_joined(env)
msgs = hub.messages.get("lobby", [])
assert any(m.kind == "system" and m.text == "You joined #lobby" for m in msgs)
def test_connection_lost_records_system_line_in_joined_rooms(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub.welcomed = True
hub.rooms.add("lobby")
hub.messages["lobby"] = []
hub.auto_reconnect = False
hub._on_closed(object())
msgs = hub.messages.get("lobby", [])
assert any(m.kind == "system" and m.text == "Connection lost" for m in msgs)
def test_manual_disconnect_records_disconnected_system_line(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub.welcomed = True
hub.rooms.add("lobby")
hub.messages["lobby"] = []
hub._manual_disconnect = True
hub.auto_reconnect = False
hub._on_closed(object())
msgs = hub.messages.get("lobby", [])
assert any(m.kind == "system" and m.text == "Disconnected from hub" for m in msgs)
def test_welcome_after_session_records_reconnected(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub.rooms.add("lobby")
hub.messages["lobby"] = []
hub._had_session = True
hub.auto_list = False
hub.auto_who = False
hub._handle_welcome({})
msgs = hub.messages.get("lobby", [])
assert any(m.kind == "system" and m.text == "Reconnected to hub" for m in msgs)
def test_set_auto_list_requests_list_immediately_when_already_connected(tmp_path):
"""Enabling auto-list mid-session must not require a reconnect to take effect."""
manager = make_manager(tmp_path)

View file

@ -446,3 +446,37 @@ def test_manager_save_roundtrip(tmp_path):
assert obj["hubs"][0]["name"] == "Saved Hub"
assert obj["hubs"][0]["rooms"][0]["name"] == "general"
assert manager.find_hub(HUB_HASH.hex()) is hub
def test_announce_interval_defaults_and_clamps():
from meshchatx.src.backend.rrc.server import (
DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
normalize_announce_interval_seconds,
)
assert (
normalize_announce_interval_seconds(None) == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
)
assert normalize_announce_interval_seconds(0) == 0
assert normalize_announce_interval_seconds(-5) == 0
assert normalize_announce_interval_seconds(30) == 60
assert normalize_announce_interval_seconds(999999) == 86400
server = make_server()
assert server.announce_interval_seconds == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
assert (
server.to_dict()["announce_interval_seconds"]
== DEFAULT_ANNOUNCE_INTERVAL_SECONDS
)
def test_set_announce_settings_syncs_timer():
server = make_running_server()
server.set_announce_settings(announce=True, announce_interval_seconds=120)
assert server.announce is True
assert server.announce_interval_seconds == 120
assert server._announce_timer is not None
server.set_announce_settings(announce=False)
assert server.announce is False
assert server._announce_timer is None
server.set_announce_settings(announce=True, announce_interval_seconds=0)
assert server._announce_timer is None

View file

@ -134,8 +134,23 @@ def test_check_web_stack_ok(mock_app, require_loopback_tcp):
mock_app.current_context.message_router.propagation_destination.hexhash = "b" * 32
if getattr(mock_app, "reticulum", None) is not None:
mock_app.reticulum.is_connected_to_shared_instance = False
mock_app.reticulum.share_instance = True
mock_app.reticulum.transport_enabled = MagicMock(return_value=False)
mock_app.reticulum.get_path_table = MagicMock(return_value=[])
class _Cfg(dict):
def write(self):
return True
mock_app.reticulum.config = _Cfg(
{
"reticulum": {
"share_instance": "Yes",
"local_hops_delta": "No",
},
"interfaces": {},
},
)
# WebSocket handler awaits these.
mock_app.send_config_to_websocket_clients = AsyncMock(return_value=None)
mock_app.websocket_broadcast = AsyncMock()

View file

@ -6,7 +6,7 @@ import DialogUtils from "@/js/DialogUtils";
import GlobalEmitter from "@/js/GlobalEmitter";
import GlobalState from "@/js/GlobalState";
const RENDER_THRESHOLD_MS = 500;
const RENDER_THRESHOLD_MS = 1500;
vi.mock("@/js/DialogUtils", () => ({
default: {

View file

@ -14,6 +14,8 @@ function makeHostedHub(overrides = {}) {
enabled: true,
running: true,
announce: true,
announce_interval_seconds: 900,
uptime_seconds: 120,
greeting: null,
clients: 0,
rooms: [{ name: "lobby", topic: "Chat", private: false, registered: true, members: 0 }],
@ -377,16 +379,65 @@ describe("RelayChatPage.vue", () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
wrapper.vm.createHubForm = { name: "Fresh Hub", greeting: "hi", announce: true };
wrapper.vm.createHubForm = {
name: "Fresh Hub",
greeting: "hi",
announce: true,
announce_interval_seconds: 1800,
};
await wrapper.vm.createServerHub();
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/rrc/servers", {
name: "Fresh Hub",
greeting: "hi",
announce: true,
announce_interval_seconds: 1800,
});
});
it("saves hosted hub settings via the API", async () => {
axiosMock.patch.mockResolvedValueOnce({ data: { hub: makeHostedHub({ name: "Renamed" }) } });
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
wrapper.vm.openHostHubSettings(wrapper.vm.serverHubs[0]);
wrapper.vm.hostHubSettingsForm = {
name: "Renamed",
announce: false,
announce_interval_seconds: 900,
};
await wrapper.vm.saveHostHubSettings();
expect(axiosMock.patch).toHaveBeenCalledWith(`/api/v1/rrc/servers/${HOSTED_HUB_ID}`, {
name: "Renamed",
announce: false,
announce_interval_seconds: 900,
});
});
it("toggles available rooms collapse and persists layout", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/rrc/hubs") {
return Promise.resolve({
data: { hubs: [makeHub({ available_rooms: { lobby: "Main", random: null } })] },
});
}
if (url === "/api/v1/rrc/servers") {
return Promise.resolve({ data: { hubs: [] } });
}
if (url === "/api/v1/announces") {
return Promise.resolve({ data: { announces: [] } });
}
return Promise.resolve({ data: {} });
});
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
expect(wrapper.vm.isAvailableRoomsExpanded(HUB_HASH)).toBe(true);
wrapper.vm.toggleAvailableRooms(HUB_HASH);
expect(wrapper.vm.isAvailableRoomsExpanded(HUB_HASH)).toBe(false);
expect(wrapper.vm.availableRoomsExpanded[HUB_HASH]).toBe(false);
});
it("creates a room on a hosted hub via the API", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
ANNOUNCE_SLIDER_POS_MAX,
announceMinutesToSliderPos,
announceSliderPosToMinutes,
} from "@/js/announceIntervalSliderMap.js";
describe("announceIntervalSliderMap", () => {
it("maps endpoints", () => {
expect(announceSliderPosToMinutes(0)).toBe(1);
expect(announceSliderPosToMinutes(ANNOUNCE_SLIDER_POS_MAX)).toBe(1440);
expect(announceMinutesToSliderPos(1)).toBeGreaterThanOrEqual(0);
expect(announceMinutesToSliderPos(1440)).toBeLessThanOrEqual(ANNOUNCE_SLIDER_POS_MAX);
});
it("round-trips common minute values", () => {
for (const minutes of [1, 5, 15, 30, 60, 120, 360, 720, 1440]) {
const pos = announceMinutesToSliderPos(minutes);
expect(announceSliderPosToMinutes(pos)).toBe(minutes);
}
});
it("increases monotonically across the track", () => {
let prev = announceSliderPosToMinutes(0);
for (let pos = 1; pos <= ANNOUNCE_SLIDER_POS_MAX; pos += 17) {
const next = announceSliderPosToMinutes(pos);
expect(next).toBeGreaterThanOrEqual(prev);
prev = next;
}
});
});

View file

@ -164,3 +164,32 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
expect(initPy.length).toBeGreaterThan(0);
});
});
describe("behavior contracts: Reticulum instance settings", () => {
it("Settings transport section wires Sideband-parity instance controls", () => {
const page = readSource("meshchatx/src/frontend/components/settings/SettingsPage.vue");
expect(page).toContain("share-reticulum-instance");
expect(page).toContain("obfuscate-hops");
expect(page).toContain("copyRpcConfigSnippet");
expect(page).toContain("fetchReticulumInstanceSettings");
expect(page).toContain("applyReticulumInstanceSettings");
const service = readSource("meshchatx/src/frontend/js/settings/settingsReticulumInstanceService.js");
expect(service).toContain("/api/v1/reticulum/instance");
const selfCheck = readSource("meshchatx/src/backend/self_check.py");
expect(selfCheck).toContain("http_reticulum_instance_good");
expect(selfCheck).toContain("/api/v1/reticulum/instance");
});
});
describe("behavior contracts: network visualiser performance", () => {
it("keeps lean physics and edge-hide options for large meshes", () => {
const src = readSource("meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue");
expect(src).toContain("hideEdgesOnDrag: true");
expect(src).toContain("hideEdgesOnZoom: true");
expect(src).toMatch(/avoidOverlap:\s*0/);
expect(src).toContain('solver: "barnesHut"');
const perf = readSource("meshchatx/src/frontend/js/networkVisualiserPerf.js");
expect(perf).toContain("dedupeIconQueueEntries");
expect(perf).toContain("pickAdaptiveFetchConcurrency");
});
});

View file

@ -81,6 +81,51 @@ describe("relayMessageTimeline", () => {
expect(timeline.filter((e) => e.type === "message")).toHaveLength(2);
});
it("collapses consecutive join/leave system lines into a presence group", () => {
const day = new Date("2026-06-02T12:00:00").getTime();
const timeline = buildRelayMessageTimeline([
{ kind: "msg", ts: day, src: "a", text: "hi", seq: 1 },
{ kind: "system", ts: day + 1, text: "nickie left", seq: 2 },
{ kind: "system", ts: day + 2, text: "jrrz left", seq: 3 },
{ kind: "system", ts: day + 3, text: "jrrz joined", seq: 4 },
{ kind: "msg", ts: day + 4, src: "b", text: "yo", seq: 5 },
{ kind: "system", ts: day + 5, text: "solo joined", seq: 6 },
]);
const types = timeline.filter((e) => e.type !== "dateDivider").map((e) => e.type);
expect(types).toEqual(["message", "presenceGroup", "message", "message"]);
const group = timeline.find((e) => e.type === "presenceGroup");
expect(group.joinedCount).toBe(1);
expect(group.leftCount).toBe(2);
expect(group.messages).toHaveLength(3);
expect(timeline.filter((e) => e.type === "message").at(-1).msg.text).toBe("solo joined");
});
it("treats You joined / You rejoined as presence events", () => {
const day = new Date("2026-06-02T12:00:00").getTime();
const timeline = buildRelayMessageTimeline([
{ kind: "system", ts: day, text: "You rejoined #lobby", seq: 1 },
{ kind: "system", ts: day + 1, text: "alice joined", seq: 2 },
{ kind: "system", ts: day + 2, text: "You joined #lobby", seq: 3 },
]);
const group = timeline.find((e) => e.type === "presenceGroup");
expect(group).toBeTruthy();
expect(group.joinedCount).toBe(3);
expect(group.leftCount).toBe(0);
});
it("collapses connection lost/reconnected lines with presence events", () => {
const day = new Date("2026-06-02T12:00:00").getTime();
const timeline = buildRelayMessageTimeline([
{ kind: "system", ts: day, text: "Connection lost", seq: 1 },
{ kind: "system", ts: day + 1, text: "Reconnected to hub", seq: 2 },
{ kind: "system", ts: day + 2, text: "You rejoined #lobby", seq: 3 },
]);
const group = timeline.find((e) => e.type === "presenceGroup");
expect(group).toBeTruthy();
expect(group.connectionCount).toBe(2);
expect(group.joinedCount).toBe(1);
});
it("relayMessageKey is stable", () => {
const msg = { kind: "msg", ts: 1, src: "ab", text: "x" };
expect(relayMessageKey(msg)).toBe(relayMessageKey(msg));