diff --git a/data/mesh_ingestor/protocols/meshcore.py b/data/mesh_ingestor/protocols/meshcore.py
index ccf9d22..b1da264 100644
--- a/data/mesh_ingestor/protocols/meshcore.py
+++ b/data/mesh_ingestor/protocols/meshcore.py
@@ -35,8 +35,8 @@ Connection type is detected automatically from the target string:
Node identities are derived from the first four bytes (eight hex characters)
of each contact's 32-byte public key, formatted as ``!xxxxxxxx`` to match
the canonical node-ID schema used across the system. Ingested
-``user.shortName`` is the first four hex digits of that key (two bytes),
-not the advertised name.
+``user.shortName`` is the first two bytes (four hex characters) of the
+node ID, not the advertised name.
"""
from __future__ import annotations
@@ -165,23 +165,28 @@ def _meshcore_node_id(public_key_hex: str | None) -> str | None:
return "!" + public_key_hex[:8].lower()
-def _meshcore_short_name(public_key_hex: str | None) -> str:
- """Return the first four hex digits of a MeshCore public key as short name.
+def _meshcore_short_name(node_id: str | None) -> str:
+ """Derive a four-character short name from a canonical node ID.
- Meshtastic-style ``shortName`` fields are four characters wide; MeshCore
- ingest uses the leading two bytes of the 32-byte public key in lowercase
- hex so the label is stable and unique per key prefix.
+ Uses the first two bytes (four hex characters) of the ``!xxxxxxxx`` node
+ ID. This keeps the short name consistent with the node ID itself — if the
+ node ID is later replaced when the real public key is heard, the short name
+ will update alongside it.
Parameters:
- public_key_hex: Full public key as a hex string from the MeshCore API.
+ node_id: Canonical ``!xxxxxxxx`` node ID string (as returned by
+ :func:`_meshcore_node_id`).
Returns:
- Four lowercase hex characters (e.g. ``"aabb"``), or an empty string
- when the key is missing or shorter than four hex characters.
+ Four lowercase hex characters (e.g. ``"cafe"``), or an empty string
+ when the node ID is missing or too short.
"""
- if not public_key_hex or len(public_key_hex) < 4:
+ if not node_id:
return ""
- return public_key_hex[:4].lower()
+ raw = node_id.lstrip("!")
+ if len(raw) < 4:
+ return ""
+ return raw[:4].lower()
def _meshcore_adv_type_to_role(adv_type: object) -> str | None:
@@ -324,6 +329,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
Node dict compatible with the ``POST /api/nodes`` payload format.
"""
pub_key = contact.get("public_key", "")
+ node_id = _meshcore_node_id(pub_key)
name = (contact.get("adv_name") or "").strip()
role = _meshcore_adv_type_to_role(contact.get("type"))
node: dict = {
@@ -331,7 +337,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
"protocol": "meshcore",
"user": {
"longName": name,
- "shortName": _meshcore_short_name(pub_key),
+ "shortName": _meshcore_short_name(node_id),
"publicKey": pub_key,
**({"role": role} if role is not None else {}),
},
@@ -377,13 +383,14 @@ def _self_info_to_node_dict(self_info: dict) -> dict:
"""
name = (self_info.get("name") or "").strip()
pub_key = self_info.get("public_key", "")
+ node_id = _meshcore_node_id(pub_key)
role = _meshcore_adv_type_to_role(self_info.get("adv_type"))
node: dict = {
"lastHeard": int(time.time()),
"protocol": "meshcore",
"user": {
"longName": name,
- "shortName": _meshcore_short_name(pub_key),
+ "shortName": _meshcore_short_name(node_id),
"publicKey": pub_key,
**({"role": role} if role is not None else {}),
},
diff --git a/tests/test_provider_unit.py b/tests/test_provider_unit.py
index e61b9b2..cf5eeb6 100644
--- a/tests/test_provider_unit.py
+++ b/tests/test_provider_unit.py
@@ -661,21 +661,22 @@ def test_meshcore_node_id_none_on_empty():
assert _meshcore_node_id(None) is None # type: ignore[arg-type]
-def test_meshcore_short_name_first_four_hex_digits():
- """_meshcore_short_name returns the first four hex chars, lowercased."""
- assert _meshcore_short_name("AABBccdd" + "00" * 28) == "aabb"
+def test_meshcore_short_name_first_two_bytes_of_node_id():
+ """_meshcore_short_name returns the first four hex chars of the node ID."""
+ assert _meshcore_short_name("!aabbccdd") == "aabb"
+ assert _meshcore_short_name("!AABBccdd") == "aabb"
def test_meshcore_short_name_empty_when_too_short():
- """_meshcore_short_name returns '' when the key has fewer than four hex digits."""
+ """_meshcore_short_name returns '' when the node ID is missing or too short."""
assert _meshcore_short_name("") == ""
- assert _meshcore_short_name("abc") == ""
+ assert _meshcore_short_name("!ab") == ""
assert _meshcore_short_name(None) == "" # type: ignore[arg-type]
-def test_meshcore_short_name_exactly_four_chars():
- """_meshcore_short_name with exactly four hex chars returns those four chars."""
- assert _meshcore_short_name("abcd") == "abcd"
+def test_meshcore_short_name_without_bang_prefix():
+ """_meshcore_short_name handles node IDs without the leading '!' prefix."""
+ assert _meshcore_short_name("cafef00d") == "cafe"
# ---------------------------------------------------------------------------
diff --git a/web/lib/potato_mesh/application/helpers/node_helpers.rb b/web/lib/potato_mesh/application/helpers/node_helpers.rb
index 21084c6..6c1b644 100644
--- a/web/lib/potato_mesh/application/helpers/node_helpers.rb
+++ b/web/lib/potato_mesh/application/helpers/node_helpers.rb
@@ -86,10 +86,9 @@ module PotatoMesh
# space keeps the badge at four visual columns.
# 2. If the long name contains two or more whitespace-separated words, use
# the capitalised first letters of the first two words: ``" XY "``.
- # 3. If the long name is a single word, use its capitalised first letter:
- # ``" A "``.
- # 4. Return +nil+ when no short name can be derived (blank input, or a
- # word without extractable characters).
+ # 3. Return +nil+ — single-word names fall back to the raw short name stored
+ # in the database (typically the first two bytes of the node ID). A single
+ # initial looked poor and carried no more information than the raw value.
#
# @param long_name [String, nil] long name stored on the node.
# @return [String, nil] derived display short name or +nil+.
@@ -111,8 +110,7 @@ module PotatoMesh
return " #{first}#{second} " if first && second
end
- letter = words[0][0]&.upcase
- letter ? " #{letter} " : nil
+ nil
end
# Recursively coerce hash keys to strings and normalise nested arrays.
diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb
index 0a59846..10669b1 100644
--- a/web/lib/potato_mesh/application/queries/node_queries.rb
+++ b/web/lib/potato_mesh/application/queries/node_queries.rb
@@ -159,7 +159,16 @@ module PotatoMesh
r["role"] ||= "CLIENT"
if r["role"] == "COMPANION"
derived = meshcore_companion_display_short_name(r["long_name"])
- r["short_name"] = derived if derived
+ if derived
+ r["short_name"] = derived
+ elsif r["short_name"].nil? || r["short_name"].strip.empty?
+ # No derived name and no stored public-key hex — synthesise from
+ # the node ID (first four hex chars after the leading "!") so the
+ # badge is stable, unique, and consistent with how the ingestor
+ # builds short names from public keys.
+ node_id = r["node_id"].to_s.delete_prefix("!")
+ r["short_name"] = node_id[0, 4] unless node_id.empty?
+ end
end
lh = r["last_heard"]&.to_i
pt = r["position_time"]&.to_i
diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb
index 01e643a..af9f31b 100644
--- a/web/lib/potato_mesh/application/routes/root.rb
+++ b/web/lib/potato_mesh/application/routes/root.rb
@@ -19,23 +19,12 @@ module PotatoMesh
module Routes
module Root
module Helpers
- # Determine the initial theme from the request cookie and persist
- # sanitised values back to the client to avoid invalid states.
+ # Return the fixed dark theme identifier. Light mode is no longer
+ # supported; theme selection and cookie persistence have been removed.
#
- # @return [String] normalised theme value ('dark' or 'light').
+ # @return [String] always 'dark'.
def resolve_initial_theme
- raw_theme = request.cookies["theme"]
- theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
- if raw_theme != theme
- response.set_cookie(
- "theme",
- value: theme,
- path: "/",
- max_age: 60 * 60 * 24 * 7,
- same_site: :lax,
- )
- end
- theme
+ "dark"
end
# Render a dashboard-oriented ERB template within the shared layout.
diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js
index 25064b4..d7682e8 100644
--- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js
+++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js
@@ -92,14 +92,15 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
);
assert.equal(model.channels.length, 6);
- // All channels have 1 message each; ties are broken alphabetically by label.
+ // Primary channels (index 0) come first, secondary channels (index > 0) come last.
+ // Within each tier, ties on messageCount are broken alphabetically by label.
assert.deepEqual(model.channels.map(channel => channel.label), [
- '1',
- 'BerlinMesh',
'EnvDefault',
'Fallback',
'MediumFast',
- 'ShortFast'
+ 'ShortFast',
+ '1',
+ 'BerlinMesh',
]);
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
@@ -512,3 +513,70 @@ test('buildChatTabModel breaks messageCount ties alphabetically', () => {
assert.equal(model.channels[0].label, 'Apple');
assert.equal(model.channels[1].label, 'Zebra');
});
+
+test('buildChatTabModel puts primary channels (index 0) before secondary channels', () => {
+ const model = buildChatTabModel({
+ nodes: [],
+ messages: [
+ // Secondary channels with many messages
+ { id: 's1', rx_time: NOW - 30, channel: 2, channel_name: 'SecondaryA' },
+ { id: 's2', rx_time: NOW - 28, channel: 2, channel_name: 'SecondaryA' },
+ { id: 's3', rx_time: NOW - 26, channel: 2, channel_name: 'SecondaryA' },
+ // Primary channel (index 0) with fewer messages
+ { id: 'p1', rx_time: NOW - 20, channel: 0, channel_name: 'LongFast' },
+ ],
+ nowSeconds: NOW,
+ windowSeconds: WINDOW
+ });
+ assert.equal(model.channels.length, 2);
+ assert.equal(model.channels[0].label, 'LongFast', 'primary channel must come first regardless of activity');
+ assert.equal(model.channels[0].index, 0);
+ assert.equal(model.channels[1].label, 'SecondaryA', 'secondary channel must come second');
+});
+
+test('buildChatTabModel sorts primary channels by activity then alpha within the primary tier', () => {
+ const model = buildChatTabModel({
+ nodes: [],
+ messages: [
+ // LongFast: 1 message
+ { id: 'lf1', rx_time: NOW - 30, channel: 0, channel_name: 'LongFast' },
+ // MediumFast: 3 messages (most active primary)
+ { id: 'mf1', rx_time: NOW - 28, channel: 0, channel_name: 'MediumFast' },
+ { id: 'mf2', rx_time: NOW - 26, channel: 0, channel_name: 'MediumFast' },
+ { id: 'mf3', rx_time: NOW - 24, channel: 0, channel_name: 'MediumFast' },
+ // Public: 2 messages
+ { id: 'pb1', rx_time: NOW - 22, channel: 0, channel_name: 'Public' },
+ { id: 'pb2', rx_time: NOW - 20, channel: 0, channel_name: 'Public' },
+ ],
+ nowSeconds: NOW,
+ windowSeconds: WINDOW
+ });
+ assert.equal(model.channels.length, 3);
+ assert.equal(model.channels[0].label, 'MediumFast', 'most active primary first');
+ assert.equal(model.channels[1].label, 'Public', 'second most active primary second');
+ assert.equal(model.channels[2].label, 'LongFast', 'least active primary last');
+});
+
+test('buildChatTabModel sorts secondary channels by activity then alpha after all primaries', () => {
+ const model = buildChatTabModel({
+ nodes: [],
+ messages: [
+ // Primary with 1 message
+ { id: 'p1', rx_time: NOW - 50, channel: 0, channel_name: 'LongFast' },
+ // Secondary channels
+ { id: 'b1', rx_time: NOW - 40, channel: 3, channel_name: 'Beta' },
+ { id: 'a1', rx_time: NOW - 38, channel: 1, channel_name: 'Alpha' },
+ { id: 'a2', rx_time: NOW - 36, channel: 1, channel_name: 'Alpha' },
+ { id: 'a3', rx_time: NOW - 34, channel: 1, channel_name: 'Alpha' },
+ { id: 'g1', rx_time: NOW - 32, channel: 2, channel_name: 'Gamma' },
+ { id: 'g2', rx_time: NOW - 30, channel: 2, channel_name: 'Gamma' },
+ ],
+ nowSeconds: NOW,
+ windowSeconds: WINDOW
+ });
+ assert.equal(model.channels.length, 4);
+ assert.equal(model.channels[0].label, 'LongFast', 'primary always first');
+ assert.equal(model.channels[1].label, 'Alpha', 'most active secondary first');
+ assert.equal(model.channels[2].label, 'Gamma', 'second most active secondary second');
+ assert.equal(model.channels[3].label, 'Beta', 'least active secondary last');
+});
diff --git a/web/public/assets/js/app/__tests__/federation-page.test.js b/web/public/assets/js/app/__tests__/federation-page.test.js
index c9cebf1..728d59c 100644
--- a/web/public/assets/js/app/__tests__/federation-page.test.js
+++ b/web/public/assets/js/app/__tests__/federation-page.test.js
@@ -809,3 +809,136 @@ test('federation page sorts by full site names before truncating visible labels'
cleanup();
}
});
+
+test('federation table linkifies Matrix room aliases, user IDs, and bare domain paths', async () => {
+ const { tbodyEl, cleanup } = (() => {
+ const e = createBasicFederationPageHarness();
+ return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
+ })();
+
+ const fetchImpl = () => Promise.resolve({
+ ok: true,
+ json: async () => [
+ {
+ domain: 'mesh.example',
+ name: 'Room Test',
+ contactLink: '@jmrplens:matrix.jmrp.io',
+ channel: '#mesh:server.tld',
+ version: '1.0.0',
+ latitude: 0,
+ longitude: 0,
+ lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
+ nodesCount: 3
+ }
+ ]
+ });
+
+ try {
+ await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
+
+ const rowHtml = tbodyEl.childNodes[0].innerHTML;
+ // Matrix user ID: @jmrplens:matrix.jmrp.io → https://matrix.to/#/@jmrplens:matrix.jmrp.io
+ assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/@jmrplens:matrix\.jmrp\.io"/);
+ assert.match(rowHtml, /@jmrplens:matrix\.jmrp\.io/);
+ // Matrix room alias in channel cell: #mesh:server.tld → https://matrix.to/#/#mesh:server.tld
+ assert.match(rowHtml, /href="https:\/\/matrix\.to\/#\/#mesh:server\.tld"/);
+ assert.match(rowHtml, /#mesh:server\.tld/);
+ } finally {
+ cleanup();
+ }
+});
+
+test('federation table linkifies bare domain-with-path as https', async () => {
+ const { tbodyEl, cleanup } = (() => {
+ const e = createBasicFederationPageHarness();
+ return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
+ })();
+
+ const fetchImpl = () => Promise.resolve({
+ ok: true,
+ json: async () => [
+ {
+ domain: 'mesh.example',
+ contactLink: 'discord.gg/EGdbRKQnFk',
+ version: '1.0.0',
+ latitude: 0,
+ longitude: 0,
+ lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
+ nodesCount: 1
+ }
+ ]
+ });
+
+ try {
+ await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
+
+ const rowHtml = tbodyEl.childNodes[0].innerHTML;
+ assert.match(rowHtml, /href="https:\/\/discord\.gg\/EGdbRKQnFk"/);
+ assert.match(rowHtml, /discord\.gg\/EGdbRKQnFk/);
+ } finally {
+ cleanup();
+ }
+});
+
+test('federation table sanitises tags and strips other HTML in contact field', async () => {
+ const { tbodyEl, cleanup } = (() => {
+ const e = createBasicFederationPageHarness();
+ return { tbodyEl: e.tbodyEl, cleanup: e.cleanup.bind(e) };
+ })();
+
+ const contactWithHtml =
+ 'YO Telegram group Contact: YO3IBZ';
+ const contactViber =
+ 'Viber Group';
+
+ const fetchImpl = () => Promise.resolve({
+ ok: true,
+ json: async () => [
+ {
+ domain: 'a.mesh',
+ contactLink: contactWithHtml,
+ version: '1.0.0',
+ latitude: 0,
+ longitude: 0,
+ lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
+ nodesCount: 1
+ },
+ {
+ domain: 'b.mesh',
+ contactLink: contactViber,
+ version: '1.0.0',
+ latitude: 0,
+ longitude: 0,
+ lastUpdateTime: Math.floor(Date.now() / 1000) - 60,
+ nodesCount: 1
+ }
+ ]
+ });
+
+ try {
+ await initializeFederationPage({ config: {}, fetchImpl, leaflet: createBasicLeafletStub() });
+
+ const rows = tbodyEl.childNodes;
+ const aHtml = rows[0].innerHTML;
+ const bHtml = rows[1].innerHTML;
+
+ // Unquoted href extracted and normalised
+ assert.match(aHtml, /href="https:\/\/t\.me\/\+BpSW3no2mJgzM2I8"/);
+ assert.match(aHtml, /YO Telegram group/);
+ // tag stripped, text content preserved
+ assert.match(aHtml, /Contact:/);
+ assert.doesNotMatch(aHtml, //);
+ // Remaining plain text present
+ assert.match(aHtml, /YO3IBZ/);
+
+ // Quoted href passes through correctly
+ assert.match(bHtml, /href="https:\/\/invite\.viber\.com\/\?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6"/);
+ assert.match(bHtml, /Viber Group/);
+
+ // No raw HTML from input leaks into output
+ assert.doesNotMatch(aHtml, /target=_blank/);
+ assert.doesNotMatch(aHtml, /<\/b>/);
+ } finally {
+ cleanup();
+ }
+});
diff --git a/web/public/assets/js/app/__tests__/instance-selector.test.js b/web/public/assets/js/app/__tests__/instance-selector.test.js
index 4ba5e55..2bc5e7f 100644
--- a/web/public/assets/js/app/__tests__/instance-selector.test.js
+++ b/web/public/assets/js/app/__tests__/instance-selector.test.js
@@ -230,7 +230,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
const fetchImpl = async () => ({
ok: true,
async json() {
- return [{ domain: 'mesh.example' }];
+ return [{ domain: 'mesh.example' }, { domain: 'other.mesh' }];
}
});
@@ -249,7 +249,7 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
defaultLabel: 'Select region ...'
});
- assert.equal(select.options.length, 2);
+ assert.equal(select.options.length, 3);
assert.equal(select.options[1].value, 'mesh.example');
select.value = 'mesh.example';
@@ -261,6 +261,68 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async
}
});
+test('initializeInstanceSelector hides the selector container when fewer than 2 instances are available', async () => {
+ const env = createDomEnvironment();
+ const select = setupSelectElement(env.document);
+
+ // Simulate a parent container; mock elements lack closest() so we set
+ // parentElement directly so the hide logic falls back to it.
+ const container = env.document.createElement('div');
+ container.classList.add('header-federation');
+ select.parentElement = container;
+ env.document.body.appendChild(container);
+
+ const fetchImpl = async () => ({
+ ok: true,
+ async json() {
+ return [{ domain: 'only.mesh' }];
+ }
+ });
+
+ try {
+ await initializeInstanceSelector({
+ selectElement: select,
+ fetchImpl,
+ windowObject: env.window,
+ documentObject: env.document
+ });
+
+ assert.equal(container.hidden, true, 'container should be hidden with fewer than 2 instances');
+ } finally {
+ env.cleanup();
+ }
+});
+
+test('initializeInstanceSelector keeps the selector visible when 2 or more instances are available', async () => {
+ const env = createDomEnvironment();
+ const select = setupSelectElement(env.document);
+
+ const container = env.document.createElement('div');
+ container.classList.add('header-federation');
+ select.parentElement = container;
+ env.document.body.appendChild(container);
+
+ const fetchImpl = async () => ({
+ ok: true,
+ async json() {
+ return [{ domain: 'alpha.mesh' }, { domain: 'beta.mesh' }];
+ }
+ });
+
+ try {
+ await initializeInstanceSelector({
+ selectElement: select,
+ fetchImpl,
+ windowObject: env.window,
+ documentObject: env.document
+ });
+
+ assert.ok(!container.hidden, 'container should remain visible with 2 or more instances');
+ } finally {
+ env.cleanup();
+ }
+});
+
test('initializeInstanceSelector updates federation navigation labels with instance count', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
diff --git a/web/public/assets/js/app/__tests__/main-app-test-helpers.js b/web/public/assets/js/app/__tests__/main-app-test-helpers.js
index 7fe8994..9406d39 100644
--- a/web/public/assets/js/app/__tests__/main-app-test-helpers.js
+++ b/web/public/assets/js/app/__tests__/main-app-test-helpers.js
@@ -44,8 +44,6 @@ export const MINIMAL_CONFIG = Object.freeze({
*/
export function setupApp() {
const env = createDomEnvironment({ includeBody: true });
- // themeToggle is accessed without a null guard in initializeApp.
- env.createElement('button', 'themeToggle');
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
}
@@ -65,6 +63,24 @@ export function withApp(fn) {
}
}
+/**
+ * Spin up a DOM environment, optionally pre-register elements by id, then
+ * initialise the app with a custom config override. Returns the test utils,
+ * the environment (for DOM inspection), and a cleanup handle.
+ *
+ * @param {{ extraElements?: string[], configOverrides?: Object }} [opts]
+ * @returns {{ testUtils: Object, env: Object, cleanup: Function }}
+ */
+export function setupAppWithOptions({ extraElements = [], configOverrides = {} } = {}) {
+ const env = createDomEnvironment({ includeBody: true });
+ for (const id of extraElements) {
+ env.registerElement(id, env.createElement('span', id));
+ }
+ const config = { ...MINIMAL_CONFIG, ...configOverrides };
+ const { _testUtils } = initializeApp(config);
+ return { testUtils: _testUtils, env, cleanup: env.cleanup.bind(env) };
+}
+
/**
* Extract the serialised HTML string from a DOM element returned by the test
* utils. The stub environment exposes innerHTML as a plain string; this
diff --git a/web/public/assets/js/app/__tests__/main-stats.test.js b/web/public/assets/js/app/__tests__/main-stats.test.js
index 954db35..fc35967 100644
--- a/web/public/assets/js/app/__tests__/main-stats.test.js
+++ b/web/public/assets/js/app/__tests__/main-stats.test.js
@@ -183,28 +183,10 @@ test('fetchActiveNodeStats falls back to local counts on invalid payloads', asyn
assert.equal(stats.month, 0);
});
-test('formatActiveNodeStatsText emits expected dashboard string', () => {
+test('formatActiveNodeStatsText emits compact day/week/month footer string', () => {
const text = formatActiveNodeStatsText({
- channel: 'LongFast',
- frequency: '868MHz',
- stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
+ stats: { day: 2, week: 3, month: 4, sampled: false },
});
- assert.equal(
- text,
- 'LongFast (868MHz) — active nodes: 1/hour, 2/day, 3/week, 4/month.'
- );
-});
-
-test('formatActiveNodeStatsText appends sampled marker when local fallback is used', () => {
- const text = formatActiveNodeStatsText({
- channel: 'LongFast',
- frequency: '868MHz',
- stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
- });
-
- assert.equal(
- text,
- 'LongFast (868MHz) — active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
- );
+ assert.equal(text, '2/day \u00b7 3/week \u00b7 4/month');
});
diff --git a/web/public/assets/js/app/__tests__/main-update-counts.test.js b/web/public/assets/js/app/__tests__/main-update-counts.test.js
new file mode 100644
index 0000000..c6d28e7
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/main-update-counts.test.js
@@ -0,0 +1,245 @@
+/*
+ * Copyright © 2025-26 l5yth & contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { setupApp, setupAppWithOptions } from './main-app-test-helpers.js';
+
+const NOW = 1_700_000_000;
+
+// ---------------------------------------------------------------------------
+// updateLegendProtocolCounts
+// ---------------------------------------------------------------------------
+
+test('updateLegendProtocolCounts returns early when both count elements are null', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ // Default state: meshcoreCountEl and meshtasticCountEl are null — should not throw.
+ assert.doesNotThrow(() => {
+ testUtils.updateLegendProtocolCounts(
+ [{ last_heard: NOW - 100, protocol: 'meshcore' }],
+ NOW,
+ );
+ });
+ } finally {
+ cleanup();
+ }
+});
+
+test('updateLegendProtocolCounts sets per-protocol counts when elements are present', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const mcEl = { textContent: '' };
+ const mtEl = { textContent: '' };
+ testUtils._setProtocolCountElements(mcEl, mtEl);
+
+ const nodes = [
+ { last_heard: NOW - 100, protocol: 'meshcore' },
+ { last_heard: NOW - 200, protocol: 'meshcore' },
+ { last_heard: NOW - 300, protocol: 'meshtastic' },
+ { last_heard: NOW - (8 * 86_400) }, // outside 7-day window, should not count
+ ];
+ testUtils.updateLegendProtocolCounts(nodes, NOW);
+
+ assert.equal(mcEl.textContent, ' (2)', 'meshcore count should be 2');
+ assert.equal(mtEl.textContent, ' (1)', 'meshtastic count should be 1');
+ } finally {
+ cleanup();
+ }
+});
+
+test('updateLegendProtocolCounts bins unknown protocols into the meshtastic column', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const mcEl = { textContent: '' };
+ const mtEl = { textContent: '' };
+ testUtils._setProtocolCountElements(mcEl, mtEl);
+
+ const nodes = [
+ { last_heard: NOW - 100, protocol: 'reticulum' }, // unknown → meshtastic bucket
+ { last_heard: NOW - 200, protocol: 'meshcore' },
+ ];
+ testUtils.updateLegendProtocolCounts(nodes, NOW);
+
+ assert.equal(mcEl.textContent, ' (1)');
+ assert.equal(mtEl.textContent, ' (1)');
+ } finally {
+ cleanup();
+ }
+});
+
+test('updateLegendProtocolCounts works when only meshcoreCountEl is present', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const mcEl = { textContent: '' };
+ testUtils._setProtocolCountElements(mcEl, null);
+
+ testUtils.updateLegendProtocolCounts(
+ [{ last_heard: NOW - 100, protocol: 'meshcore' }],
+ NOW,
+ );
+ assert.equal(mcEl.textContent, ' (1)');
+ } finally {
+ cleanup();
+ }
+});
+
+test('updateLegendProtocolCounts works when only meshtasticCountEl is present', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ const mtEl = { textContent: '' };
+ testUtils._setProtocolCountElements(null, mtEl);
+
+ testUtils.updateLegendProtocolCounts(
+ [{ last_heard: NOW - 100, protocol: 'meshtastic' }],
+ NOW,
+ );
+ assert.equal(mtEl.textContent, ' (1)');
+ } finally {
+ cleanup();
+ }
+});
+
+// ---------------------------------------------------------------------------
+// updateFooterStats
+// ---------------------------------------------------------------------------
+
+test('updateFooterStats is a no-op when footerActiveNodes element is absent', () => {
+ const { testUtils, cleanup } = setupApp();
+ try {
+ assert.doesNotThrow(() => {
+ testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
+ });
+ } finally {
+ cleanup();
+ }
+});
+
+test('updateFooterStats populates the active-stats element when present', async () => {
+ const { testUtils, env, cleanup } = setupAppWithOptions({
+ extraElements: ['footerActiveNodes'],
+ });
+ try {
+ const el = env.document.getElementById('footerActiveNodes');
+ testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
+
+ // Drain the microtask queue so the async .then callback executes.
+ await new Promise(resolve => setImmediate(resolve));
+
+ assert.ok(
+ el.textContent.includes('/day'),
+ `expected footerActiveNodes to contain "/day", got: ${el.textContent}`,
+ );
+ } finally {
+ cleanup();
+ }
+});
+
+test('updateFooterStats discards stale responses when a newer request is in flight', async () => {
+ const { testUtils, env, cleanup } = setupAppWithOptions({
+ extraElements: ['footerActiveNodes'],
+ });
+ try {
+ const el = env.document.getElementById('footerActiveNodes');
+
+ // Fire two sequential updates; only the second should be applied.
+ testUtils.updateFooterStats([{ last_heard: NOW - 100 }], NOW);
+ testUtils.updateFooterStats([{ last_heard: NOW - 200 }], NOW);
+
+ await new Promise(resolve => setImmediate(resolve));
+
+ // Either one or neither result lands; the key invariant is no error thrown
+ // and the element text is a valid stats string or empty.
+ const text = el.textContent;
+ assert.ok(
+ text === '' || text.includes('/day'),
+ `unexpected footerActiveNodes content: ${text}`,
+ );
+ } finally {
+ cleanup();
+ }
+});
+
+// ---------------------------------------------------------------------------
+// restartAutoRefresh
+// ---------------------------------------------------------------------------
+
+test('restartAutoRefresh does not start a timer when refreshMs is 0', () => {
+ // MINIMAL_CONFIG has refreshMs: 0 — timer must not be armed.
+ const origSetInterval = globalThis.setInterval;
+ const calls = [];
+ globalThis.setInterval = (...args) => { calls.push(args); return origSetInterval(...args); };
+ try {
+ const { cleanup } = setupApp(); // uses refreshMs: 0
+ // restartAutoRefresh is called during init; no timer should have been started.
+ assert.equal(calls.length, 0, 'setInterval should not be called with refreshMs=0');
+ cleanup();
+ } finally {
+ globalThis.setInterval = origSetInterval;
+ }
+});
+
+test('restartAutoRefresh starts a timer when refreshMs > 0', () => {
+ const timers = [];
+ const origSetInterval = globalThis.setInterval;
+ const origClearInterval = globalThis.clearInterval;
+ globalThis.setInterval = (fn, ms) => {
+ const id = Symbol('timer');
+ timers.push({ fn, ms, id });
+ return id;
+ };
+ globalThis.clearInterval = () => {};
+
+ try {
+ const { cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
+ assert.equal(timers.length, 1, 'setInterval should be called once during init');
+ assert.equal(timers[0].ms, 30_000, 'interval should match configured refreshMs');
+ cleanup();
+ } finally {
+ globalThis.setInterval = origSetInterval;
+ globalThis.clearInterval = origClearInterval;
+ }
+});
+
+test('restartAutoRefresh clears the existing timer before starting a new one', () => {
+ const cleared = [];
+ const timers = [];
+ const origSetInterval = globalThis.setInterval;
+ const origClearInterval = globalThis.clearInterval;
+ globalThis.setInterval = (fn, ms) => {
+ const id = Symbol('timer');
+ timers.push(id);
+ return id;
+ };
+ globalThis.clearInterval = id => { cleared.push(id); };
+
+ try {
+ const { testUtils, cleanup } = setupAppWithOptions({ configOverrides: { refreshMs: 30_000 } });
+ // One timer started during init.
+ assert.equal(timers.length, 1);
+
+ // Calling restartAutoRefresh again must clear the first timer and start a new one.
+ testUtils.restartAutoRefresh();
+ assert.equal(cleared.length, 1, 'existing timer should be cleared');
+ assert.equal(cleared[0], timers[0], 'the original timer id should be cleared');
+ assert.equal(timers.length, 2, 'a new timer should be started');
+ cleanup();
+ } finally {
+ globalThis.setInterval = origSetInterval;
+ globalThis.clearInterval = origClearInterval;
+ }
+});
diff --git a/web/public/assets/js/app/__tests__/message-replies.test.js b/web/public/assets/js/app/__tests__/message-replies.test.js
index 3d079de..a7a15aa 100644
--- a/web/public/assets/js/app/__tests__/message-replies.test.js
+++ b/web/public/assets/js/app/__tests__/message-replies.test.js
@@ -21,6 +21,7 @@ import {
buildMessageBody,
buildMessageIndex,
normaliseMessageId,
+ renderLiteralWithLinks,
resolveReplyPrefix
} from '../message-replies.js';
@@ -279,3 +280,86 @@ test('buildMessageBody with renderMentionHtml: unclosed @[ treated as literal',
// @[ without closing ] does not match the pattern — treated as literal
assert.equal(body, 'ESC(hello @[unclosed)');
});
+
+// ---------------------------------------------------------------------------
+// renderLiteralWithLinks — URL detection
+// ---------------------------------------------------------------------------
+
+const e = v => `E(${v})`;
+
+test('renderLiteralWithLinks passes plain text through escapeHtml', () => {
+ assert.equal(renderLiteralWithLinks('hello world', e), 'E(hello world)');
+});
+
+test('renderLiteralWithLinks wraps http:// URL in an anchor element', () => {
+ const result = renderLiteralWithLinks('check http://example.com out', e);
+ assert.equal(result, 'E(check )E(http://example.com)E( out)');
+});
+
+test('renderLiteralWithLinks wraps https:// URL in an anchor element', () => {
+ const result = renderLiteralWithLinks('see https://example.com/path?q=1', e);
+ assert.ok(result.includes(' {
+ const result = renderLiteralWithLinks('visit https://example.com.', e);
+ assert.ok(result.includes('href="E(https://example.com)"'), 'period should not be in href');
+ assert.ok(result.includes('>E(https://example.com)<'), 'period should not be in link text');
+ assert.ok(result.endsWith('E(.)'), 'trailing period should appear as escaped text after the link');
+});
+
+test('renderLiteralWithLinks strips trailing comma from URL', () => {
+ const result = renderLiteralWithLinks('go to https://example.com, then stop', e);
+ assert.ok(result.includes('href="E(https://example.com)"'), 'comma must not be in href');
+});
+
+test('renderLiteralWithLinks handles URL at the start of text', () => {
+ const result = renderLiteralWithLinks('https://example.com is great', e);
+ assert.ok(result.startsWith(' {
+ const result = renderLiteralWithLinks('see https://example.com', e);
+ assert.ok(result.startsWith('E(see )'), 'text before URL should be escaped');
+ assert.ok(result.includes(' {
+ const result = renderLiteralWithLinks('a https://foo.com b https://bar.com c', e);
+ const matches = result.match(/ {
+ const result = renderLiteralWithLinks('ftp://example.com', e);
+ assert.ok(!result.includes(' {
+ assert.equal(renderLiteralWithLinks('', e), '');
+});
+
+test('buildMessageBody linkifies URLs in message text without renderMentionHtml', () => {
+ const body = buildMessageBody({
+ message: { text: 'visit https://example.com now' },
+ escapeHtml: e,
+ renderEmojiHtml: v => `EMOJI(${v})`,
+ });
+ assert.ok(body.includes(' {
+ const body = buildMessageBody({
+ message: { text: '@[Alice] see https://example.com' },
+ escapeHtml: e,
+ renderEmojiHtml: v => `EMOJI(${v})`,
+ renderMentionHtml: name => `BADGE(${name})`,
+ });
+ assert.ok(body.startsWith('BADGE(Alice)'), 'mention should be rendered as badge');
+ assert.ok(body.includes(' {
+test('formatActiveNodeStatsText emits compact day/week/month string', () => {
assert.equal(
formatActiveNodeStatsText({
- channel: 'LongFast',
- frequency: '868MHz',
- stats: { hour: 1, day: 2, week: 3, month: 4, sampled: false },
+ stats: { day: 2, week: 3, month: 4, sampled: false },
}),
- 'LongFast (868MHz) \u2014 active nodes: 1/hour, 2/day, 3/week, 4/month.'
- );
-});
-
-test('formatActiveNodeStatsText appends sampled marker', () => {
- assert.equal(
- formatActiveNodeStatsText({
- channel: 'LongFast',
- frequency: '868MHz',
- stats: { hour: 9, day: 8, week: 7, month: 6, sampled: true },
- }),
- 'LongFast (868MHz) \u2014 active nodes: 9/hour, 8/day, 7/week, 6/month (sampled).'
+ '2/day \u00b7 3/week \u00b7 4/month'
);
});
test('formatActiveNodeStatsText handles missing or null stats gracefully', () => {
- const text = formatActiveNodeStatsText({ channel: 'X', frequency: 'Y', stats: null });
- assert.ok(text.includes('0/hour'), 'defaults to zero counts for null stats');
+ const text = formatActiveNodeStatsText({ stats: null });
+ assert.equal(text, '0/day \u00b7 0/week \u00b7 0/month', 'defaults to zero counts for null stats');
});
diff --git a/web/public/assets/js/app/__tests__/theme-background.test.js b/web/public/assets/js/app/__tests__/theme-background.test.js
index d9a936d..746c4db 100644
--- a/web/public/assets/js/app/__tests__/theme-background.test.js
+++ b/web/public/assets/js/app/__tests__/theme-background.test.js
@@ -57,8 +57,6 @@ function executeInDom(source, url, env) {
test('theme and background modules behave correctly across scenarios', async t => {
const env = createDomEnvironment({ readyState: 'complete', cookie: '' });
try {
- const toggle = env.createElement('button', 'themeToggle');
- env.registerElement('themeToggle', toggle);
let filterInvocations = 0;
env.window.applyFiltersToAllTiles = () => {
filterInvocations += 1;
@@ -72,52 +70,27 @@ test('theme and background modules behave correctly across scenarios', async t =
const backgroundHelpers = env.window.__potatoBackground;
const backgroundHooks = backgroundHelpers.__testHooks;
- await t.test('initialises with a dark theme and persists cookies', () => {
+ await t.test('initialises with a dark theme', () => {
assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
assert.equal(env.document.body.classList.contains('dark'), true);
- assert.equal(toggle.textContent, '☀️');
- themeHelpers.persistTheme('light');
- themeHelpers.setCookie('bare', '1');
- themeHooks.exerciseSetCookieGuard();
- themeHelpers.setCookie('flag', 'true', { Secure: true });
- const cookieString = env.getCookieString();
- assert.equal(themeHelpers.getCookie('flag'), 'true');
- assert.equal(themeHelpers.getCookie('missing'), null);
- assert.match(cookieString, /theme=light/);
- assert.match(cookieString, /; path=\//);
- assert.match(cookieString, /; SameSite=Lax/);
- assert.match(cookieString, /; Secure/);
- });
-
- await t.test('serializeCookieOptions covers boolean and string attributes', () => {
- const withAttributes = themeHooks.serializeCookieOptions({ Secure: true, HttpOnly: '1' });
- assert.equal(withAttributes.includes('; Secure'), true);
- assert.equal(withAttributes.includes('; HttpOnly=1'), true);
- const secureOnly = themeHooks.serializeCookieOptions({ Secure: true });
- assert.equal(secureOnly.trim(), '; Secure');
- assert.equal(themeHooks.formatCookieOption(['HttpOnly', '1']), '; HttpOnly=1');
- assert.equal(themeHooks.formatCookieOption(['Secure', true]), '; Secure');
- assert.equal(themeHooks.serializeCookieOptions({}), '');
- assert.equal(themeHooks.serializeCookieOptions(), '');
});
await t.test('re-bootstrap handles DOMContentLoaded flow and filter hooks', () => {
env.document.readyState = 'loading';
filterInvocations = 0;
- env.setCookieString('theme=light');
themeHooks.bootstrap();
env.triggerDOMContentLoaded();
- assert.equal(env.document.documentElement.getAttribute('data-theme'), 'light');
- assert.equal(env.document.body.classList.contains('dark'), false);
- assert.equal(toggle.textContent, '🌙');
+ assert.equal(env.document.documentElement.getAttribute('data-theme'), 'dark');
+ assert.equal(env.document.body.classList.contains('dark'), true);
assert.equal(filterInvocations, 1);
env.document.removeEventListener('DOMContentLoaded', themeHooks.handleReady);
});
- await t.test('handleReady tolerates missing toggle button', () => {
- env.registerElement('themeToggle', null);
+ await t.test('handleReady calls applyFiltersToAllTiles', () => {
+ filterInvocations = 0;
+ env.document.readyState = 'complete';
themeHooks.handleReady();
- env.registerElement('themeToggle', toggle);
+ assert.equal(filterInvocations, 1);
});
await t.test('applyTheme copes with absent DOM nodes', () => {
@@ -125,10 +98,10 @@ test('theme and background modules behave correctly across scenarios', async t =
const originalRoot = env.document.documentElement;
env.document.body = null;
env.document.documentElement = null;
- assert.equal(themeHooks.applyTheme('dark'), true);
+ // Should not throw even when DOM nodes are absent
+ assert.doesNotThrow(() => themeHooks.applyTheme());
env.document.body = originalBody;
env.document.documentElement = originalRoot;
- assert.equal(themeHooks.applyTheme('light'), false);
});
await t.test('background bootstrap waits for DOM readiness', () => {
@@ -161,12 +134,12 @@ test('theme and background modules behave correctly across scenarios', async t =
env.document.body = originalBody;
});
- await t.test('theme changes trigger background updates', () => {
- env.document.body.classList.remove('dark');
- themeHooks.setTheme('light');
+ await t.test('themechange event triggers background update', () => {
+ env.document.body.classList.add('dark');
backgroundHooks.init();
env.dispatchWindowEvent('themechange');
- assert.equal(env.document.documentElement.style.backgroundColor, '#f6f3ee');
+ // Background should reflect dark mode
+ assert.ok(env.document.documentElement.style.backgroundColor !== '');
});
env.window.removeEventListener('themechange', backgroundHelpers.applyBackground);
diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js
index cefd8a6..989c313 100644
--- a/web/public/assets/js/app/chat-log-tabs.js
+++ b/web/public/assets/js/app/chat-log-tabs.js
@@ -311,10 +311,17 @@ export function buildChatTabModel({
channel.entries.sort((a, b) => a.ts - b.ts);
channel.messageCount = channel.entries.length;
}
- // Sort channels by activity (most messages first), then alphabetically on ties.
- const channels = Array.from(channelBuckets.values()).sort((a, b) =>
- b.messageCount - a.messageCount || a.label.localeCompare(b.label)
- );
+ // Sort channels into two tiers:
+ // 1. Primary channels (channel index 0 — LongFast, MediumFast, Public, etc.)
+ // ordered by activity desc so the most-active protocol leads within the tier.
+ // 2. Secondary channels (index > 0) ordered by activity desc, then alpha.
+ // Within each tier, ties on messageCount are broken alphabetically by label.
+ const channels = Array.from(channelBuckets.values()).sort((a, b) => {
+ const aTier = a.index === 0 ? 0 : 1;
+ const bTier = b.index === 0 ? 0 : 1;
+ if (aTier !== bTier) return aTier - bTier;
+ return b.messageCount - a.messageCount || a.label.localeCompare(b.label);
+ });
return { logEntries, channels };
}
diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js
index 95925c8..3b3c91c 100644
--- a/web/public/assets/js/app/federation-page.js
+++ b/web/public/assets/js/app/federation-page.js
@@ -113,38 +113,135 @@ function colorForNodeCount(count) {
}
/**
- * Render arbitrary contact text while hyperlinking recognised URL-like segments.
+ * Matches recognised link-like segments in plain text:
+ * - Absolute URLs (https?://, mailto:, matrix:)
+ * - Matrix room aliases (#room:domain.tld)
+ * - Matrix user IDs (@user:domain.tld)
+ * - Bare domain-with-path (discord.gg/..., t.me/...)
*
- * @param {*} contact Raw contact value from the API.
- * @returns {string} HTML markup safe for insertion.
+ * Character classes use possessive-style atomic groupings (no overlap) so the
+ * regex engine cannot backtrack into super-linear runtime.
*/
-function renderContactHtml(contact) {
- if (typeof contact !== 'string') return '';
- const trimmed = contact.trim();
- if (!trimmed) return '';
- const urlPattern = /(https?:\/\/[^\s]+|mailto:[^\s]+|matrix:[^\s]+)/gi;
+const CONTACT_LINK_PATTERN =
+ /(https?:\/\/\S+|mailto:\S+|matrix:\S+|[@#][a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}|[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\/\S+)/gi;
+
+/**
+ * Regex matching `` elements (including malformed unquoted attributes) and
+ * any other HTML tags so they can be handled separately from plain text.
+ *
+ * The `` branch uses `[^<]*` (no nesting) instead of `[\s\S]*?` to avoid
+ * super-linear backtracking when nested or malformed tags are present.
+ */
+const HTML_SEGMENT_PATTERN = /(]*>[^<]*<\/a\s*>|<[^>]+>)/gi;
+
+/** Extracts the href value from an `` opening tag, quoted or unquoted. */
+const HREF_ATTR_PATTERN = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/i;
+
+/** Protocols allowed in whitelisted `` hrefs. */
+const SAFE_HREF_RE = /^(?:https?:\/\/|matrix:|mailto:)/i;
+
+/**
+ * Resolve a raw matched token to an href string.
+ *
+ * @param {string} raw Matched token from CONTACT_LINK_PATTERN.
+ * @returns {string} Absolute href suitable for use in an anchor element.
+ */
+function resolveHref(raw) {
+ if (raw.startsWith('#') || raw.startsWith('@')) {
+ // Matrix room alias or user ID → matrix.to permalink
+ return `https://matrix.to/#/${raw}`;
+ }
+ if (/^[a-zA-Z0-9-]+\./.test(raw) && !raw.includes('://')) {
+ // Bare domain-with-path (e.g. discord.gg/…) — prepend https://
+ return `https://${raw}`;
+ }
+ return raw;
+}
+
+/**
+ * Linkify URL-like tokens in a plain-text (already HTML-free) segment.
+ *
+ * @param {string} text Plain text with no HTML tags.
+ * @returns {string} HTML-safe string with recognised links wrapped in anchors.
+ */
+function renderPlainSegment(text) {
const parts = [];
let lastIndex = 0;
+ const pattern = new RegExp(CONTACT_LINK_PATTERN.source, 'gi');
+ let match;
+ while ((match = pattern.exec(text)) !== null) {
+ const before = text.slice(lastIndex, match.index);
+ if (before) parts.push(escapeHtml(before));
+ const raw = match[0];
+ const href = resolveHref(raw);
+ parts.push(`${escapeHtml(raw)}`);
+ lastIndex = match.index + raw.length;
+ }
+ const trailing = text.slice(lastIndex);
+ if (trailing) parts.push(escapeHtml(trailing));
+ return parts.join('');
+}
+
+/**
+ * Render arbitrary contact or channel text as safe HTML.
+ *
+ * - Existing `` tags are sanitised: href is validated against an allowlist
+ * of safe protocols (https, http, matrix:, mailto:), link text is escaped,
+ * and `target="_blank" rel="noopener noreferrer"` is always applied.
+ * - Other HTML tags (e.g. ``) are stripped; their text content is kept.
+ * - Plain-text segments are scanned for URLs, Matrix aliases/user-IDs, and
+ * bare domain-with-path references (e.g. discord.gg/…) and linkified.
+ * - Line breaks are converted to `
`.
+ *
+ * @param {*} text Raw value from the API (may contain HTML).
+ * @returns {string} HTML markup safe for insertion.
+ */
+function renderContactHtml(text) {
+ if (typeof text !== 'string') return '';
+ const trimmed = text.trim();
+ if (!trimmed) return '';
+
+ const parts = [];
+ let lastIndex = 0;
+ const segPattern = new RegExp(HTML_SEGMENT_PATTERN.source, 'gi');
let match;
- while ((match = urlPattern.exec(trimmed)) !== null) {
- const textBefore = trimmed.slice(lastIndex, match.index);
- if (textBefore) {
- parts.push(escapeHtml(textBefore));
+ while ((match = segPattern.exec(trimmed)) !== null) {
+ // Linkify plain text before this HTML segment
+ const before = trimmed.slice(lastIndex, match.index);
+ if (before) parts.push(renderPlainSegment(before));
+
+ const tag = match[0];
+ if (/^ tag — extract href, validate, re-render safely
+ const hrefMatch = HREF_ATTR_PATTERN.exec(tag);
+ const href = hrefMatch ? (hrefMatch[1] ?? hrefMatch[2] ?? hrefMatch[3] ?? '') : '';
+ // Strip HTML tags to derive plain link text; content is still escaped below.
+ // The replace runs in a loop to handle residual tags left after the first
+ // pass — this is safe because a single-pass replace of `<…>` can leave
+ // behind a reconstructed tag when the input contains e.g. `<
-<% body_classes = []
- body_classes << "dark" if initial_theme == "dark"
+<% body_classes = ["dark"]
view_mode = (defined?(current_view_mode) && current_view_mode) ? current_view_mode.to_sym : :dashboard
- full_screen_view = view_mode != :dashboard
+ full_screen_view = !%i[dashboard charts federation].include?(view_mode)
body_classes << "view-#{view_mode}"
shell_classes = ["page-shell"]
shell_classes << "page-shell--full-screen" if full_screen_view
@@ -76,25 +75,13 @@
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = true
- show_meta_info = true
- show_auto_refresh_controls = view_mode != :federation
- show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
- map_zoom_override = defined?(map_zoom) ? map_zoom : nil
- show_info_button = true
- show_footer = !full_screen_view
+ show_footer = !full_screen_view || %i[charts federation].include?(view_mode)
+ footer_slim = %i[charts federation].include?(view_mode)
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
- show_auto_refresh_toggle = show_auto_refresh_controls
- show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
+ show_meta_row = !%i[charts federation].include?(view_mode)
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
- controls_classes = ["controls"]
- controls_classes << "controls--full-screen" if full_screen_view
- refresh_row_classes = ["refresh-row"]
- refresh_info_text = full_screen_view ? nil : "#{channel} (#{frequency}) — active nodes: …"
- refresh_row_classes << "refresh-row--no-info" if refresh_info_text.nil?
- refresh_info_classes = ["refresh-info"]
- refresh_info_classes << "refresh-info--hidden" if refresh_info_text.nil?
announcement_markup = announcement_html %>
"
@@ -110,7 +97,7 @@
<% end %>