diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index cc51db2f..fa07349d 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -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 diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py index 9da3a098..f7160085 100644 --- a/meshchatx/src/backend/rrc/manager.py +++ b/meshchatx/src/backend/rrc/manager.py @@ -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: diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py index c41db8e2..c366df8c 100644 --- a/meshchatx/src/backend/rrc/server.py +++ b/meshchatx/src/backend/rrc/server.py @@ -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( diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py index 09d33568..c61c9f60 100644 --- a/meshchatx/src/backend/self_check.py +++ b/meshchatx/src/backend/self_check.py @@ -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", diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue index 300f5817..33c47b5f 100644 --- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue +++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue @@ -75,7 +75,7 @@
+
+ -
- - - {{ $t("relay_chat.announced_ago", { time: timeAgo(node.updated_at) }) }} - - - - {{ $t("relay_chat.hops_away", { count: node.hops }) }} - -
- - @@ -779,6 +796,14 @@ {{ $t("relay_chat.host_stop") }} + + + + + + +
{ + 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")); diff --git a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue index 92e837ea..696c8603 100644 --- a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue +++ b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue @@ -15,6 +15,30 @@
+
+ +
+
+ {{ msg.text }} +
+
+
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); +}); diff --git a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue index ca7aa230..31d4a6d5 100644 --- a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue +++ b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue @@ -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}`; } diff --git a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js index cf71f7ae..387e51fb 100644 --- a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js +++ b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js @@ -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) { diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue index dca03f1f..baaad073 100644 --- a/meshchatx/src/frontend/components/settings/SettingsPage.vue +++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue @@ -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 }, diff --git a/meshchatx/src/frontend/js/announceIntervalSliderMap.js b/meshchatx/src/frontend/js/announceIntervalSliderMap.js new file mode 100644 index 00000000..247cbd54 --- /dev/null +++ b/meshchatx/src/frontend/js/announceIntervalSliderMap.js @@ -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); +} diff --git a/meshchatx/src/frontend/js/relayMessageTimeline.js b/meshchatx/src/frontend/js/relayMessageTimeline.js index 1a709aa3..07b01cae 100644 --- a/meshchatx/src/frontend/js/relayMessageTimeline.js +++ b/meshchatx/src/frontend/js/relayMessageTimeline.js @@ -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; } diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json index c1f8584e..507a85c8 100644 --- a/meshchatx/src/frontend/locales/de.json +++ b/meshchatx/src/frontend/locales/de.json @@ -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", diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 51f7b385..bb0f8646 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -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", diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json index 096b1fc7..fe18064d 100644 --- a/meshchatx/src/frontend/locales/es.json +++ b/meshchatx/src/frontend/locales/es.json @@ -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", diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json index 105bb605..9d3ec933 100644 --- a/meshchatx/src/frontend/locales/fi.json +++ b/meshchatx/src/frontend/locales/fi.json @@ -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", diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json index b12a0de6..6586d453 100644 --- a/meshchatx/src/frontend/locales/fr.json +++ b/meshchatx/src/frontend/locales/fr.json @@ -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", diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json index d60c1a25..3f3ad21b 100644 --- a/meshchatx/src/frontend/locales/it.json +++ b/meshchatx/src/frontend/locales/it.json @@ -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", diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json index 9eca8cb3..67ab8656 100644 --- a/meshchatx/src/frontend/locales/nl.json +++ b/meshchatx/src/frontend/locales/nl.json @@ -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", diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json index d2510226..52aae246 100644 --- a/meshchatx/src/frontend/locales/ru.json +++ b/meshchatx/src/frontend/locales/ru.json @@ -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", diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json index c1f894dc..6543bbd5 100644 --- a/meshchatx/src/frontend/locales/zh.json +++ b/meshchatx/src/frontend/locales/zh.json @@ -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", diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py index 2820128e..700492b1 100644 --- a/tests/backend/api_json_contract_schemas.py +++ b/tests/backend/api_json_contract_schemas.py @@ -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, diff --git a/tests/backend/test_reticulum_instance_settings.py b/tests/backend/test_reticulum_instance_settings.py index 8ad3bf5e..74ed93a7 100644 --- a/tests/backend/test_reticulum_instance_settings.py +++ b/tests/backend/test_reticulum_instance_settings.py @@ -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"] diff --git a/tests/backend/test_rrc_protocol.py b/tests/backend/test_rrc_protocol.py index 6062eb4f..2138eaf0 100644 --- a/tests/backend/test_rrc_protocol.py +++ b/tests/backend/test_rrc_protocol.py @@ -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) diff --git a/tests/backend/test_rrc_server.py b/tests/backend/test_rrc_server.py index d30b0eca..0e119204 100644 --- a/tests/backend/test_rrc_server.py +++ b/tests/backend/test_rrc_server.py @@ -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 diff --git a/tests/backend/test_self_check.py b/tests/backend/test_self_check.py index 2ab2ee4f..d67d63ae 100644 --- a/tests/backend/test_self_check.py +++ b/tests/backend/test_self_check.py @@ -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() diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js index 50594b08..65565202 100644 --- a/tests/frontend/ConversationViewerButtons.test.js +++ b/tests/frontend/ConversationViewerButtons.test.js @@ -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: { diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js index fdbff628..e1dacf6a 100644 --- a/tests/frontend/RelayChatPage.test.js +++ b/tests/frontend/RelayChatPage.test.js @@ -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)); diff --git a/tests/frontend/announceIntervalSliderMap.test.js b/tests/frontend/announceIntervalSliderMap.test.js new file mode 100644 index 00000000..d4d54b93 --- /dev/null +++ b/tests/frontend/announceIntervalSliderMap.test.js @@ -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; + } + }); +}); diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js index 4ae225ea..4a26f392 100644 --- a/tests/frontend/behaviorContracts.test.js +++ b/tests/frontend/behaviorContracts.test.js @@ -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"); + }); +}); diff --git a/tests/frontend/relayMessageSearch.test.js b/tests/frontend/relayMessageSearch.test.js index 8b3bb753..6abe26e8 100644 --- a/tests/frontend/relayMessageSearch.test.js +++ b/tests/frontend/relayMessageSearch.test.js @@ -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));