+
{{ $t("relay_chat.discovery_title") }}
@@ -644,7 +654,7 @@
@@ -658,49 +668,56 @@
{{ $t("relay_chat.discovery_empty") }}
-
-
-
-
-
{{ nodeName(node) }}
+
+
+
+
+
+ {{ nodeName(node) }}
+
+
+
+
+
+ {{ $t("relay_chat.announced_ago", { time: timeAgo(node.updated_at) }) }}
+
+
+
+ {{ $t("relay_chat.hops_away", { count: node.hops }) }}
+
+
+
-
-
-
- {{ $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") }}
+
-
+
+
+
+
{{ $t("relay_chat.host_hub_settings") }}
+
+
+
+
{
+ 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 @@
+
+
+
+
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));