mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
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:
parent
63189f4ed0
commit
56eaeafeec
30 changed files with 1439 additions and 94 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
25
meshchatx/src/frontend/js/announceIntervalSliderMap.js
Normal file
25
meshchatx/src/frontend/js/announceIntervalSliderMap.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
31
tests/frontend/announceIntervalSliderMap.test.js
Normal file
31
tests/frontend/announceIntervalSliderMap.test.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue