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 %>