mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 02:42:49 +02:00
web: facelift (#716)
* web: facelift * web: facelift * web: facelift * web: address review comments * web: address review comments * web: address review comments * web: address review comments * web: address review comments * web: address review comments * web: more css magic * web: link parsing for chat contact * web: remove one-letter fallback for shortnames * Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * web: fix fallback for shortnames * web: address review comments --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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 {}),
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 <a> 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 =
|
||||
'<a href=https://t.me/+BpSW3no2mJgzM2I8 target=_blank>YO Telegram group</a><b> Contact:</b> YO3IBZ';
|
||||
const contactViber =
|
||||
'<a href="https://invite.viber.com/?g=64h1QIFIC1Unai6DS6SE2Ot8ks9xoTm6">Viber Group</a>';
|
||||
|
||||
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/);
|
||||
// <b> tag stripped, text content preserved
|
||||
assert.match(aHtml, /Contact:/);
|
||||
assert.doesNotMatch(aHtml, /<b>/);
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 )<a href="E(http://example.com)" target="_blank" rel="noopener noreferrer">E(http://example.com)</a>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('<a href='), 'should produce an anchor');
|
||||
assert.ok(result.includes('target="_blank"'), 'should open in new tab');
|
||||
assert.ok(result.includes('rel="noopener noreferrer"'), 'should include noopener rel');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks strips trailing period from URL', () => {
|
||||
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('<a href='), 'anchor should be at start');
|
||||
assert.ok(result.endsWith('E( is great)'), 'text after URL should be escaped');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks handles URL at the end of text', () => {
|
||||
const result = renderLiteralWithLinks('see https://example.com', e);
|
||||
assert.ok(result.startsWith('E(see )'), 'text before URL should be escaped');
|
||||
assert.ok(result.includes('<a href='), 'URL should be linked');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks handles multiple URLs in text', () => {
|
||||
const result = renderLiteralWithLinks('a https://foo.com b https://bar.com c', e);
|
||||
const matches = result.match(/<a href=/g) || [];
|
||||
assert.equal(matches.length, 2, 'should produce two anchors');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks does not linkify non-http schemes', () => {
|
||||
const result = renderLiteralWithLinks('ftp://example.com', e);
|
||||
assert.ok(!result.includes('<a href='), 'ftp:// should not be linkified');
|
||||
assert.equal(result, 'E(ftp://example.com)');
|
||||
});
|
||||
|
||||
test('renderLiteralWithLinks returns empty string for empty input', () => {
|
||||
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('<a href='), 'URL should be linkified');
|
||||
assert.ok(body.includes('target="_blank"'), 'should open in new tab');
|
||||
});
|
||||
|
||||
test('buildMessageBody linkifies URLs alongside @[Name] mentions', () => {
|
||||
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('<a href='), 'URL should be linkified');
|
||||
});
|
||||
|
||||
@@ -237,29 +237,16 @@ test('fetchActiveNodeStats concurrent calls share a single in-flight request', a
|
||||
// formatActiveNodeStatsText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('formatActiveNodeStatsText emits expected dashboard sentence', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 `<a>` elements (including malformed unquoted attributes) and
|
||||
* any other HTML tags so they can be handled separately from plain text.
|
||||
*
|
||||
* The `<a>` 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\b[^>]*>[^<]*<\/a\s*>|<[^>]+>)/gi;
|
||||
|
||||
/** Extracts the href value from an `<a>` opening tag, quoted or unquoted. */
|
||||
const HREF_ATTR_PATTERN = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))/i;
|
||||
|
||||
/** Protocols allowed in whitelisted `<a>` 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(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(raw)}</a>`);
|
||||
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 `<a>` 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. `<b>`) 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 `<br>`.
|
||||
*
|
||||
* @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 (/^<a\b/i.test(tag)) {
|
||||
// Whitelisted <a> 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. `<<script>`.
|
||||
let linkText = tag;
|
||||
let prev;
|
||||
do { prev = linkText; linkText = linkText.replace(/<[^>]*>/g, ''); } while (linkText !== prev);
|
||||
linkText = linkText.trim();
|
||||
if (SAFE_HREF_RE.test(href)) {
|
||||
parts.push(`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(linkText || href)}</a>`);
|
||||
} else {
|
||||
// Unsafe or missing href — render link text only
|
||||
parts.push(escapeHtml(linkText));
|
||||
}
|
||||
}
|
||||
const url = match[0];
|
||||
const safeUrl = escapeHtml(url);
|
||||
parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`);
|
||||
lastIndex = match.index + url.length;
|
||||
// Other tags (<b>, </b>, etc.) are stripped; their text content falls
|
||||
// through as plain text in subsequent iterations.
|
||||
|
||||
lastIndex = match.index + tag.length;
|
||||
}
|
||||
|
||||
// Linkify any remaining plain text after the last HTML segment
|
||||
const trailing = trimmed.slice(lastIndex);
|
||||
if (trailing) {
|
||||
parts.push(escapeHtml(trailing));
|
||||
}
|
||||
if (trailing) parts.push(renderPlainSegment(trailing));
|
||||
|
||||
const html = parts.join('');
|
||||
return html.replace(/\r?\n/g, '<br>');
|
||||
return parts.join('').replace(/\r?\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +484,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
<td class="instances-col instances-col--domain mono">${domainHtml}</td>
|
||||
<td class="instances-col instances-col--contact">${contactHtml || '<em>—</em>'}</td>
|
||||
<td class="instances-col instances-col--version mono">${escapeHtml(instance.version || '')}</td>
|
||||
<td class="instances-col instances-col--channel">${escapeHtml(instance.channel || '')}</td>
|
||||
<td class="instances-col instances-col--channel">${renderContactHtml(instance.channel) || ''}</td>
|
||||
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
|
||||
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
|
||||
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
|
||||
|
||||
@@ -205,6 +205,18 @@ export async function initializeInstanceSelector(options) {
|
||||
const visibleEntries = filterDisplayableFederationInstances(payload);
|
||||
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
|
||||
|
||||
// Hide the selector when fewer than two instances are available — a single
|
||||
// entry (only the current instance) offers no meaningful navigation choice.
|
||||
if (visibleEntries.length < 2) {
|
||||
const selectorContainer = (typeof selectElement.closest === 'function'
|
||||
? selectElement.closest('.header-federation')
|
||||
: null) || selectElement.parentElement;
|
||||
if (selectorContainer) {
|
||||
selectorContainer.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
|
||||
|
||||
const sortedEntries = visibleEntries
|
||||
|
||||
+102
-239
@@ -131,30 +131,20 @@ import {
|
||||
*/
|
||||
export function initializeApp(config) {
|
||||
const statusEl = document.getElementById('status');
|
||||
const fitBoundsEl = document.getElementById('fitBounds');
|
||||
const autoRefreshEl = document.getElementById('autoRefresh');
|
||||
const footerActiveNodes = document.getElementById('footerActiveNodes');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const filterInput = document.getElementById('filterInput');
|
||||
const filterClearButton = document.getElementById('filterClear');
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const infoBtn = document.getElementById('infoBtn');
|
||||
const infoOverlay = document.getElementById('infoOverlay');
|
||||
const infoClose = document.getElementById('infoClose');
|
||||
const infoDialog = infoOverlay ? infoOverlay.querySelector('.info-dialog') : null;
|
||||
const shortInfoTemplate = document.getElementById('shortInfoOverlayTemplate');
|
||||
const overlayStack = createShortInfoOverlayStack({ document, window, template: shortInfoTemplate });
|
||||
const titleEl = document.querySelector('title');
|
||||
const headerEl = document.querySelector('h1');
|
||||
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
|
||||
const chatEl = document.getElementById('chat');
|
||||
const refreshInfo = document.getElementById('refreshInfo');
|
||||
const instanceSelect = document.getElementById('instanceSelect');
|
||||
const baseTitle = document.title;
|
||||
const nodesTable = document.getElementById('nodes');
|
||||
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
|
||||
const infoOverlayHome = infoOverlay
|
||||
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
|
||||
: null;
|
||||
const bodyClassList = document.body ? document.body.classList : null;
|
||||
const isPrivateMode = document.body && document.body.dataset
|
||||
? String(document.body.dataset.privateMode).toLowerCase() === 'true'
|
||||
@@ -245,13 +235,6 @@ export function initializeApp(config) {
|
||||
const REFRESH_MS = config.refreshMs;
|
||||
const CHAT_ENABLED = Boolean(config.chatEnabled);
|
||||
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
|
||||
if (refreshInfo) {
|
||||
if (isDashboardView) {
|
||||
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
|
||||
} else {
|
||||
refreshInfo.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceSelectorEnabled && instanceSelect) {
|
||||
void initializeInstanceSelector({
|
||||
@@ -265,7 +248,7 @@ export function initializeApp(config) {
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let refreshTimer = null;
|
||||
let refreshInfoRequestId = 0;
|
||||
let activeStatsRequestId = 0;
|
||||
|
||||
/**
|
||||
* Close any open short-info overlays that do not contain the provided anchor.
|
||||
@@ -463,24 +446,18 @@ export function initializeApp(config) {
|
||||
*/
|
||||
function restartAutoRefresh() {
|
||||
// Tear down any existing timer so the interval never double-fires when
|
||||
// the user toggles auto-refresh or the config is re-applied.
|
||||
// the config is re-applied.
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
// Only arm the timer when the auto-refresh checkbox is checked; a
|
||||
// disabled checkbox (e.g. during an active fetch) keeps it off.
|
||||
if (autoRefreshEl && autoRefreshEl.checked) {
|
||||
// Only arm the timer when a positive interval is configured; a zero or
|
||||
// negative value means auto-refresh is intentionally disabled.
|
||||
if (REFRESH_MS > 0) {
|
||||
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (fitBoundsEl && mapZoomOverride !== null) {
|
||||
fitBoundsEl.checked = false;
|
||||
fitBoundsEl.disabled = true;
|
||||
fitBoundsEl.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
const MAP_CENTER_COORDS = Object.freeze({ lat: config.mapCenter.lat, lon: config.mapCenter.lon });
|
||||
const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
|
||||
const mapContainer = document.getElementById('map');
|
||||
@@ -524,7 +501,7 @@ export function initializeApp(config) {
|
||||
];
|
||||
|
||||
const autoFitController = createMapAutoFitController({
|
||||
toggleEl: fitBoundsEl,
|
||||
toggleEl: null,
|
||||
windowObject: typeof window !== 'undefined' ? window : undefined,
|
||||
defaultPaddingPx: AUTO_FIT_PADDING_PX
|
||||
});
|
||||
@@ -542,7 +519,6 @@ export function initializeApp(config) {
|
||||
const centerResetHandler = createMapCenterResetHandler({
|
||||
getMap: () => map,
|
||||
autoFitController,
|
||||
fitBoundsEl,
|
||||
fitMapToBounds,
|
||||
mapCenterCoords: MAP_CENTER_COORDS,
|
||||
mapZoomOverride,
|
||||
@@ -739,62 +715,6 @@ export function initializeApp(config) {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Append the informational modal overlay to the fullscreen container when active.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachInfoOverlayToFullscreenHost() {
|
||||
if (!infoOverlay || !fullscreenContainer) return;
|
||||
if (infoOverlay.parentNode !== fullscreenContainer) {
|
||||
fullscreenContainer.appendChild(infoOverlay);
|
||||
}
|
||||
if (infoOverlay.classList) {
|
||||
infoOverlay.classList.add('info-overlay--fullscreen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the informational overlay to its original DOM position.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function restoreInfoOverlayToHome() {
|
||||
if (!infoOverlay || !infoOverlayHome || !infoOverlayHome.parent) return;
|
||||
if (infoOverlay.parentNode === infoOverlayHome.parent) {
|
||||
if (infoOverlay.classList) {
|
||||
infoOverlay.classList.remove('info-overlay--fullscreen');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
infoOverlayHome.nextSibling &&
|
||||
infoOverlayHome.nextSibling.parentNode === infoOverlayHome.parent &&
|
||||
typeof infoOverlayHome.parent.insertBefore === 'function'
|
||||
) {
|
||||
infoOverlayHome.parent.insertBefore(infoOverlay, infoOverlayHome.nextSibling);
|
||||
} else if (typeof infoOverlayHome.parent.appendChild === 'function') {
|
||||
infoOverlayHome.parent.appendChild(infoOverlay);
|
||||
}
|
||||
if (infoOverlay.classList) {
|
||||
infoOverlay.classList.remove('info-overlay--fullscreen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the informational overlay participates in the active fullscreen subtree.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncInfoOverlayHost() {
|
||||
if (!infoOverlay) return;
|
||||
if (isMapInFullscreen()) {
|
||||
attachInfoOverlayToFullscreenHost();
|
||||
} else {
|
||||
restoreInfoOverlayToHome();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to fullscreen change events originating from the browser.
|
||||
*
|
||||
@@ -825,7 +745,6 @@ export function initializeApp(config) {
|
||||
mapContainer.style.minHeight = '';
|
||||
}
|
||||
}
|
||||
syncInfoOverlayHost();
|
||||
updateFullscreenToggleState();
|
||||
refreshMapSize();
|
||||
}
|
||||
@@ -853,8 +772,6 @@ export function initializeApp(config) {
|
||||
});
|
||||
}
|
||||
|
||||
syncInfoOverlayHost();
|
||||
|
||||
/** @type {Set<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
|
||||
const activeRoleFilters = new Set();
|
||||
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
|
||||
@@ -1395,6 +1312,8 @@ export function initializeApp(config) {
|
||||
|
||||
let legendContainer = null;
|
||||
let legendToggleControl = null;
|
||||
let meshcoreCountEl = null;
|
||||
let meshtasticCountEl = null;
|
||||
let legendToggleButton = null;
|
||||
let legendVisible = true;
|
||||
|
||||
@@ -1479,7 +1398,9 @@ export function initializeApp(config) {
|
||||
if (!neighborLinesToggleButton) return;
|
||||
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
|
||||
neighborLinesToggleButton.textContent = label;
|
||||
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
|
||||
// aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are
|
||||
// currently hidden). When lines are visible (default), the button is unpressed.
|
||||
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true');
|
||||
neighborLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
@@ -1511,7 +1432,8 @@ export function initializeApp(config) {
|
||||
if (!traceLinesToggleButton) return;
|
||||
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
|
||||
traceLinesToggleButton.textContent = label;
|
||||
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
|
||||
// aria-pressed reflects whether the user has *activated* the toggle (lines hidden).
|
||||
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true');
|
||||
traceLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
@@ -1629,14 +1551,30 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
if (map && hasLeaflet) {
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
// Single combined control: [toggle button | legend panel] in a flex row.
|
||||
// The toggle sits to the left so it remains accessible when the legend is collapsed.
|
||||
const legendControl = L.control({ position: 'bottomright' });
|
||||
/**
|
||||
* Leaflet control factory that renders the legend UI.
|
||||
* Leaflet control factory that renders the toggle button and legend panel
|
||||
* as a single side-by-side control.
|
||||
*
|
||||
* @returns {HTMLElement} Legend DOM element.
|
||||
* @returns {HTMLElement} Wrapper element containing both children.
|
||||
*/
|
||||
legend.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'legend');
|
||||
legendControl.onAdd = function () {
|
||||
const wrapper = L.DomUtil.create('div', 'legend-outer');
|
||||
|
||||
// --- Toggle button (left) ---
|
||||
const button = L.DomUtil.create('button', 'legend-toggle-button', wrapper);
|
||||
button.type = 'button';
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
button.setAttribute('aria-controls', 'mapLegend');
|
||||
button.addEventListener('click', legendClickHandler(() => {
|
||||
setLegendVisibility(!legendVisible);
|
||||
}));
|
||||
legendToggleButton = button;
|
||||
|
||||
// --- Legend panel (right) ---
|
||||
const div = L.DomUtil.create('div', 'legend', wrapper);
|
||||
div.id = 'mapLegend';
|
||||
div.setAttribute('role', 'region');
|
||||
div.setAttribute('aria-label', 'Map legend');
|
||||
@@ -1648,13 +1586,16 @@ export function initializeApp(config) {
|
||||
|
||||
const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div);
|
||||
|
||||
// --- MeshCore column (left) ---
|
||||
const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
|
||||
// --- MeshCore column (left, bottom-aligned) ---
|
||||
const meshcoreCol = L.DomUtil.create('div', 'legend-column legend-column--bottom', itemsContainer);
|
||||
const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol);
|
||||
meshcoreColHeader.appendChild(buildMeshcoreIconImg());
|
||||
const meshcoreColTitle = document.createElement('span');
|
||||
meshcoreColTitle.textContent = 'MeshCore';
|
||||
meshcoreColHeader.appendChild(meshcoreColTitle);
|
||||
meshcoreCountEl = document.createElement('span');
|
||||
meshcoreCountEl.className = 'legend-protocol-count';
|
||||
meshcoreColHeader.appendChild(meshcoreCountEl);
|
||||
|
||||
// --- Meshtastic column (right) ---
|
||||
const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
|
||||
@@ -1663,18 +1604,17 @@ export function initializeApp(config) {
|
||||
const meshtasticColTitle = document.createElement('span');
|
||||
meshtasticColTitle.textContent = 'Meshtastic';
|
||||
meshtasticColHeader.appendChild(meshtasticColTitle);
|
||||
meshtasticCountEl = document.createElement('span');
|
||||
meshtasticCountEl.className = 'legend-protocol-count';
|
||||
meshtasticColHeader.appendChild(meshtasticCountEl);
|
||||
|
||||
legendRoleButtons.clear();
|
||||
buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore');
|
||||
buildRoleButtons(meshtasticCol, roleColors, 'meshtastic');
|
||||
|
||||
// --- Protocol hide toggles — one per column footer ---
|
||||
// --- MeshCore column footer: protocol hide toggle ---
|
||||
legendProtocolButtons.clear();
|
||||
const protocolColDefs = [
|
||||
{ protocol: 'meshcore', col: meshcoreCol },
|
||||
{ protocol: 'meshtastic', col: meshtasticCol },
|
||||
];
|
||||
for (const { protocol, col } of protocolColDefs) {
|
||||
const buildProtocolToggle = (protocol, col) => {
|
||||
const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
|
||||
const btn = L.DomUtil.create('button', 'legend-item legend-protocol-toggle', col);
|
||||
btn.type = 'button';
|
||||
@@ -1690,9 +1630,10 @@ export function initializeApp(config) {
|
||||
applyFilter();
|
||||
}));
|
||||
legendProtocolButtons.set(protocol, btn);
|
||||
}
|
||||
};
|
||||
buildProtocolToggle('meshcore', meshcoreCol);
|
||||
|
||||
// --- Line toggles in the Meshtastic column ---
|
||||
// --- Meshtastic column: line toggles then protocol hide toggle at bottom ---
|
||||
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol);
|
||||
neighborLinesToggleButton.type = 'button';
|
||||
neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => {
|
||||
@@ -1707,12 +1648,15 @@ export function initializeApp(config) {
|
||||
}));
|
||||
updateTraceLinesToggleState();
|
||||
|
||||
// Hide Meshtastic toggle at the very bottom of the Meshtastic column.
|
||||
buildProtocolToggle('meshtastic', meshtasticCol);
|
||||
|
||||
updateLegendRoleFiltersUI();
|
||||
|
||||
// --- Clear filters — full-width below the two columns ---
|
||||
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
|
||||
const filterToggle = L.DomUtil.create('div', 'legend-toggle', div);
|
||||
|
||||
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
|
||||
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', filterToggle);
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Clear filters';
|
||||
resetButton.addEventListener('click', legendClickHandler(() => {
|
||||
@@ -1722,38 +1666,12 @@ export function initializeApp(config) {
|
||||
applyFilter();
|
||||
}));
|
||||
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
L.DomEvent.disableScrollPropagation(div);
|
||||
|
||||
return div;
|
||||
};
|
||||
legend.addTo(map);
|
||||
legendContainer = legend.getContainer();
|
||||
|
||||
legendToggleControl = L.control({ position: 'bottomright' });
|
||||
/**
|
||||
* Leaflet control factory for the legend visibility toggle.
|
||||
*
|
||||
* @returns {HTMLElement} Toggle button element.
|
||||
*/
|
||||
legendToggleControl.onAdd = function () {
|
||||
const container = L.DomUtil.create('div', 'leaflet-control legend-toggle');
|
||||
const button = L.DomUtil.create('button', 'legend-toggle-button', container);
|
||||
button.type = 'button';
|
||||
button.textContent = 'Hide legend (filters)';
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
button.setAttribute('aria-label', 'Hide map legend');
|
||||
button.setAttribute('aria-controls', 'mapLegend');
|
||||
button.addEventListener('click', legendClickHandler(() => {
|
||||
setLegendVisibility(!legendVisible);
|
||||
}));
|
||||
legendToggleButton = button;
|
||||
updateLegendToggleState();
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
L.DomEvent.disableScrollPropagation(container);
|
||||
return container;
|
||||
L.DomEvent.disableClickPropagation(wrapper);
|
||||
L.DomEvent.disableScrollPropagation(wrapper);
|
||||
return wrapper;
|
||||
};
|
||||
legendToggleControl.addTo(map);
|
||||
legendControl.addTo(map);
|
||||
|
||||
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
|
||||
const initialLegendVisible = resolveLegendVisibility({
|
||||
@@ -1770,72 +1688,6 @@ export function initializeApp(config) {
|
||||
setLegendVisibility(false);
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const dark = document.body.classList.toggle('dark');
|
||||
const themeValue = dark ? 'dark' : 'light';
|
||||
document.body.setAttribute('data-theme', themeValue);
|
||||
if (document.documentElement) {
|
||||
document.documentElement.setAttribute('data-theme', themeValue);
|
||||
}
|
||||
themeToggle.textContent = dark ? '☀️' : '🌙';
|
||||
if (window.__themeCookie) {
|
||||
if (typeof window.__themeCookie.persistTheme === 'function') {
|
||||
window.__themeCookie.persistTheme(themeValue);
|
||||
} else if (typeof window.__themeCookie.setCookie === 'function') {
|
||||
window.__themeCookie.setCookie('theme', themeValue);
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeValue } }));
|
||||
if (typeof window.applyFiltersToAllTiles === 'function') window.applyFiltersToAllTiles();
|
||||
});
|
||||
|
||||
let lastFocusBeforeInfo = null;
|
||||
|
||||
/**
|
||||
* Display the modal overlay containing site information.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function openInfoOverlay() {
|
||||
if (!infoOverlay || !infoDialog) return;
|
||||
syncInfoOverlayHost();
|
||||
lastFocusBeforeInfo = document.activeElement;
|
||||
infoOverlay.hidden = false;
|
||||
document.body.style.setProperty('overflow', 'hidden');
|
||||
infoDialog.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the site information overlay and restore focus.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function closeInfoOverlay() {
|
||||
if (!infoOverlay || !infoDialog) return;
|
||||
infoOverlay.hidden = true;
|
||||
document.body.style.removeProperty('overflow');
|
||||
const target = lastFocusBeforeInfo && typeof lastFocusBeforeInfo.focus === 'function' ? lastFocusBeforeInfo : infoBtn;
|
||||
if (target && typeof target.focus === 'function') {
|
||||
target.focus();
|
||||
}
|
||||
lastFocusBeforeInfo = null;
|
||||
}
|
||||
|
||||
if (infoBtn && infoOverlay && infoClose) {
|
||||
infoBtn.addEventListener('click', openInfoOverlay);
|
||||
infoClose.addEventListener('click', closeInfoOverlay);
|
||||
infoOverlay.addEventListener('click', event => {
|
||||
if (event.target === infoOverlay) {
|
||||
closeInfoOverlay();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !infoOverlay.hidden) {
|
||||
closeInfoOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
|
||||
document,
|
||||
privateMode: isPrivateMode,
|
||||
@@ -4435,10 +4287,6 @@ export function initializeApp(config) {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
|
||||
const bounds = computeBoundsForPoints(pts, { ...autoFitBoundsConfig });
|
||||
fitMapToBounds(bounds, { animate: false, paddingPx: AUTO_FIT_PADDING_PX });
|
||||
}
|
||||
overlayStack.cleanupOrphans();
|
||||
}
|
||||
|
||||
@@ -4521,8 +4369,11 @@ export function initializeApp(config) {
|
||||
const nowSec = Date.now()/1000;
|
||||
renderTable(sortedNodes, nowSec);
|
||||
renderMap(sortedNodes, nowSec);
|
||||
updateCount(sortedNodes, nowSec);
|
||||
updateRefreshInfo(sortedNodes, nowSec);
|
||||
// Title and legend counts are intentionally global — they reflect the whole
|
||||
// network, not just the nodes visible under the current filter.
|
||||
updateTitleCount(allNodes, nowSec);
|
||||
updateLegendProtocolCounts(allNodes, nowSec);
|
||||
updateFooterStats(sortedNodes, nowSec);
|
||||
updateSortIndicators();
|
||||
// Pass the raw filterQuery (not the normalised form) so the chat log can
|
||||
// highlight matching substrings in their original case.
|
||||
@@ -4658,8 +4509,8 @@ export function initializeApp(config) {
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off the first data load immediately; interval-based auto-refresh
|
||||
// begins only if the checkbox is already checked on page load.
|
||||
// Kick off the first data load immediately then start the silent background
|
||||
// auto-refresh timer.
|
||||
refresh();
|
||||
restartAutoRefresh();
|
||||
|
||||
@@ -4668,27 +4519,16 @@ export function initializeApp(config) {
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
}
|
||||
|
||||
if (autoRefreshEl) {
|
||||
// When the user enables auto-refresh mid-session, trigger an immediate
|
||||
// fetch so the UI does not sit stale until the next interval fires.
|
||||
autoRefreshEl.addEventListener('change', () => {
|
||||
restartAutoRefresh();
|
||||
if (autoRefreshEl.checked) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the count badge showing how many nodes are displayed.
|
||||
* Update the page/tab title with the total active-node count for the past 7 days.
|
||||
*
|
||||
* @param {Array<Object>} nodes Node payloads.
|
||||
* @param {Array<Object>} nodes All node payloads (unfiltered — counts are global).
|
||||
* @param {number} nowSec Reference timestamp.
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateCount(nodes, nowSec) {
|
||||
const dayAgoSec = nowSec - 86400;
|
||||
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= dayAgoSec).length;
|
||||
function updateTitleCount(nodes, nowSec) {
|
||||
const weekAgoSec = nowSec - 7 * 86_400;
|
||||
const count = nodes.filter(n => n.last_heard && Number(n.last_heard) >= weekAgoSec).length;
|
||||
const text = `${baseTitle} (${count})`;
|
||||
titleEl.textContent = text;
|
||||
if (headerTitleTextEl) {
|
||||
@@ -4699,26 +4539,40 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status message describing the currently rendered data.
|
||||
* Update legend column headers with per-protocol active node counts (7 days).
|
||||
*
|
||||
* @param {Array<Object>} nodes All node payloads (unfiltered).
|
||||
* @param {number} nowSec Reference timestamp.
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateLegendProtocolCounts(nodes, nowSec) {
|
||||
if (!meshcoreCountEl && !meshtasticCountEl) return;
|
||||
const weekAgoSec = nowSec - 7 * 86_400;
|
||||
const recentNodes = nodes.filter(n => Number.isFinite(Number(n.last_heard)) && Number(n.last_heard) >= weekAgoSec);
|
||||
const meshcoreCount = recentNodes.filter(n => n.protocol === 'meshcore').length;
|
||||
// Treat any non-meshcore node as Meshtastic until additional protocols are supported.
|
||||
const meshtasticCount = recentNodes.filter(n => n.protocol !== 'meshcore').length;
|
||||
if (meshcoreCountEl) meshcoreCountEl.textContent = ` (${meshcoreCount})`;
|
||||
if (meshtasticCountEl) meshtasticCountEl.textContent = ` (${meshtasticCount})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the footer active-node stats element with day/week/month counts.
|
||||
*
|
||||
* @param {Array<Object>} nodes Node payloads.
|
||||
* @param {number} nowSec Reference timestamp.
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateRefreshInfo(nodes, nowSec) {
|
||||
if (!refreshInfo || !isDashboardView) {
|
||||
function updateFooterStats(nodes, nowSec) {
|
||||
if (!footerActiveNodes) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++refreshInfoRequestId;
|
||||
const requestId = ++activeStatsRequestId;
|
||||
void fetchActiveNodeStats({ nodes, nowSeconds: nowSec }).then(stats => {
|
||||
if (requestId !== refreshInfoRequestId) {
|
||||
if (requestId !== activeStatsRequestId) {
|
||||
return;
|
||||
}
|
||||
refreshInfo.textContent = formatActiveNodeStatsText({
|
||||
channel: config.channel,
|
||||
frequency: config.frequency,
|
||||
stats
|
||||
});
|
||||
footerActiveNodes.textContent = 'Active: ' + formatActiveNodeStatsText({ stats });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4750,6 +4604,15 @@ export function initializeApp(config) {
|
||||
hiddenProtocols,
|
||||
legendRoleButtons,
|
||||
legendProtocolButtons,
|
||||
updateTitleCount,
|
||||
updateLegendProtocolCounts,
|
||||
updateFooterStats,
|
||||
restartAutoRefresh,
|
||||
/** Inject mock count span elements for legend protocol count tests. */
|
||||
_setProtocolCountElements(mc, mt) {
|
||||
meshcoreCountEl = mc;
|
||||
meshtasticCountEl = mt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,12 +339,55 @@ function resolveMessageTextSegment(message, isReaction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a text segment, replacing ``@[Name]`` mention patterns with the
|
||||
* output of ``renderMentionHtml`` when provided. Segments between mentions
|
||||
* are passed through ``escapeHtml`` to prevent XSS.
|
||||
* Regex with a single capturing group that matches http:// and https:// URLs.
|
||||
* Used by {@link renderLiteralWithLinks} to split text into URL and non-URL
|
||||
* segments while preserving the matched URL in the resulting array.
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const URL_SPLIT_PATTERN = /(https?:\/\/[^\s<>"'[\]]{1,2048})/;
|
||||
|
||||
/**
|
||||
* Strip trailing punctuation characters that are typically sentence
|
||||
* delimiters rather than part of a URL (e.g. a period at end of sentence).
|
||||
*
|
||||
* When ``renderMentionHtml`` is ``null`` the function behaves identically to
|
||||
* ``escapeHtml(text)``, preserving backward compatibility.
|
||||
* @param {string} url Raw URL candidate.
|
||||
* @returns {string} URL with trailing punctuation trimmed.
|
||||
*/
|
||||
function trimUrlTrailingPunctuation(url) {
|
||||
return url.replace(/[.,;!?)]+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single raw text segment, converting any ``http://`` or
|
||||
* ``https://`` URLs into ``<a>`` elements that open in a new tab.
|
||||
* Non-URL text is passed through ``escapeHtml`` unchanged.
|
||||
*
|
||||
* @param {string} text Raw (unescaped) literal text.
|
||||
* @param {Function} escapeHtml HTML-escape function.
|
||||
* @returns {string} Safe HTML with URLs wrapped in anchor elements.
|
||||
*/
|
||||
export function renderLiteralWithLinks(text, escapeHtml) {
|
||||
// split() with a capturing group interleaves plain text (even indices)
|
||||
// and matched URLs (odd indices): ["before", "https://x", " after", ...]
|
||||
const parts = text.split(URL_SPLIT_PATTERN);
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 0) {
|
||||
return part ? escapeHtml(part) : '';
|
||||
}
|
||||
// URL segment — strip trailing punctuation then linkify.
|
||||
const url = trimUrlTrailingPunctuation(part);
|
||||
const trailing = part.slice(url.length);
|
||||
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>${trailing ? escapeHtml(trailing) : ''}`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a text segment, replacing ``@[Name]`` mention patterns with the
|
||||
* output of ``renderMentionHtml`` when provided. Literal text segments are
|
||||
* passed through {@link renderLiteralWithLinks} so that URLs become clickable.
|
||||
*
|
||||
* When ``renderMentionHtml`` is ``null`` the function is equivalent to
|
||||
* calling {@link renderLiteralWithLinks} on the whole string.
|
||||
*
|
||||
* @param {string} text Raw message text segment.
|
||||
* @param {Function} escapeHtml HTML-escape function.
|
||||
@@ -353,20 +396,22 @@ function resolveMessageTextSegment(message, isReaction) {
|
||||
* @returns {string} HTML string safe for insertion into the DOM.
|
||||
*/
|
||||
function renderTextWithMentions(text, escapeHtml, renderMentionHtml) {
|
||||
if (typeof renderMentionHtml !== 'function') return escapeHtml(text);
|
||||
if (typeof renderMentionHtml !== 'function') return renderLiteralWithLinks(text, escapeHtml);
|
||||
// split() with a capturing group interleaves literal segments (even indices)
|
||||
// and captured mention names (odd indices): ["before", "Alice", "after", ...]
|
||||
const parts = text.split(/@\[([^\]]+)\]/);
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 1) return renderMentionHtml(part);
|
||||
// Empty literal segments (e.g. when a mention is at the start or end) can
|
||||
// be skipped to avoid unnecessary escapeHtml calls on empty strings.
|
||||
return part ? escapeHtml(part) : '';
|
||||
// be skipped to avoid unnecessary renderLiteralWithLinks calls.
|
||||
return part ? renderLiteralWithLinks(part, escapeHtml) : '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rendered message body containing text and optional emoji.
|
||||
* ``http://`` and ``https://`` URLs in the message text are automatically
|
||||
* converted to ``<a>`` elements that open in a new tab.
|
||||
*
|
||||
* @param {{
|
||||
* message: Object,
|
||||
|
||||
@@ -154,18 +154,16 @@ export async function fetchActiveNodeStats({ nodes, nowSeconds, fetchImpl = fetc
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the dashboard refresh-info sentence for active-node counts.
|
||||
* Format the active-node counts for display in the footer.
|
||||
*
|
||||
* @param {{channel: string, frequency: string, stats: {hour:number,day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
|
||||
* @returns {string} User-visible sentence for the dashboard header.
|
||||
* @param {{stats: {day:number,week:number,month:number,sampled:boolean}}} params Formatting data.
|
||||
* @returns {string} Compact user-visible string, e.g. ``"569/day · 729/week · 1168/month"``.
|
||||
*/
|
||||
export function formatActiveNodeStatsText({ channel, frequency, stats }) {
|
||||
export function formatActiveNodeStatsText({ stats }) {
|
||||
const parts = [
|
||||
`${Number(stats?.hour) || 0}/hour`,
|
||||
`${Number(stats?.day) || 0}/day`,
|
||||
`${Number(stats?.week) || 0}/week`,
|
||||
`${Number(stats?.month) || 0}/month`
|
||||
];
|
||||
const suffix = stats?.sampled ? ' (sampled)' : '';
|
||||
return `${channel} (${frequency}) — active nodes: ${parts.join(', ')}${suffix}.`;
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
+11
-142
@@ -16,128 +16,22 @@
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Number of seconds theme preferences should persist in the cookie store.
|
||||
* Apply the dark theme to the root HTML and body elements.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
var THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
|
||||
/**
|
||||
* Retrieve a cookie value by name.
|
||||
*
|
||||
* @param {string} name Cookie identifier.
|
||||
* @returns {?string} Decoded cookie value or ``null`` when absent.
|
||||
*/
|
||||
function getCookie(name) {
|
||||
var matcher = new RegExp(
|
||||
'(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'
|
||||
);
|
||||
var match = document.cookie.match(matcher);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert cookie options to a serialized string suitable for ``document.cookie``.
|
||||
*
|
||||
* @param {Object<string, *>} options Map of cookie attribute keys and values.
|
||||
* @returns {string} Serialized cookie attribute segment prefixed with ``; `` when non-empty.
|
||||
*/
|
||||
function formatCookieOption(pair) {
|
||||
var key = pair[0];
|
||||
var optionValue = pair[1];
|
||||
if (optionValue === true) {
|
||||
return '; ' + key;
|
||||
}
|
||||
return '; ' + key + '=' + optionValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise cookie attributes into a string consumable by ``document.cookie``.
|
||||
*
|
||||
* @param {Object<string, *>} options Map of cookie attribute keys and values.
|
||||
* @returns {string} Concatenated cookie attribute segment.
|
||||
*/
|
||||
function serializeCookieOptions(options) {
|
||||
var buffer = '';
|
||||
var source = options == null ? {} : options;
|
||||
var entries = Object.entries(source);
|
||||
for (var index = 0; index < entries.length;) {
|
||||
buffer += formatCookieOption(entries[index]);
|
||||
index += 1;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a cookie with optional attributes.
|
||||
*
|
||||
* @param {string} name Cookie identifier.
|
||||
* @param {string} value Value to store.
|
||||
* @param {Object<string, *>} [opts] Additional cookie attributes.
|
||||
* @returns {void}
|
||||
*/
|
||||
function setCookie(name, value, opts) {
|
||||
var options = Object.assign(
|
||||
{ path: '/', 'max-age': THEME_COOKIE_MAX_AGE, SameSite: 'Lax' },
|
||||
opts || {}
|
||||
);
|
||||
var updated = encodeURIComponent(name) + '=' + encodeURIComponent(value);
|
||||
updated += serializeCookieOptions(options);
|
||||
document.cookie = updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the user's preferred theme selection.
|
||||
*
|
||||
* @param {string} value Theme identifier to persist.
|
||||
* @returns {void}
|
||||
*/
|
||||
function persistTheme(value) {
|
||||
setCookie('theme', value, { 'max-age': THEME_COOKIE_MAX_AGE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the requested theme to the root HTML and body elements.
|
||||
*
|
||||
* @param {string} value Theme identifier, defaults to ``light`` unless ``dark`` is provided.
|
||||
* @returns {boolean} ``true`` when the dark theme is active.
|
||||
*/
|
||||
function applyTheme(value) {
|
||||
var themeValue = value === 'dark' ? 'dark' : 'light';
|
||||
function applyTheme() {
|
||||
var root = document.documentElement;
|
||||
var isDark = themeValue === 'dark';
|
||||
|
||||
if (root) {
|
||||
root.setAttribute('data-theme', themeValue);
|
||||
root.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
document.body.classList.toggle('dark', isDark);
|
||||
document.body.setAttribute('data-theme', themeValue);
|
||||
}
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the setCookie helper through a monkey-patched ``hasOwnProperty`` to keep coverage deterministic.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function exerciseSetCookieGuard() {
|
||||
var originalHasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
Object.prototype.hasOwnProperty = function alwaysFalse() {
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
setCookie('probe', 'probe', { SameSite: 'Lax' });
|
||||
} finally {
|
||||
Object.prototype.hasOwnProperty = originalHasOwnProperty;
|
||||
document.body.classList.add('dark');
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
var theme = 'dark';
|
||||
|
||||
/**
|
||||
* Initialise theme state on page load and register the ready handler.
|
||||
*
|
||||
@@ -145,12 +39,7 @@
|
||||
*/
|
||||
function bootstrap() {
|
||||
document.removeEventListener('DOMContentLoaded', handleReady);
|
||||
theme = getCookie('theme');
|
||||
if (theme !== 'dark' && theme !== 'light') {
|
||||
theme = 'dark';
|
||||
}
|
||||
persistTheme(theme);
|
||||
applyTheme(theme);
|
||||
applyTheme();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', handleReady);
|
||||
@@ -165,12 +54,7 @@
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleReady() {
|
||||
var isDark = applyTheme(theme);
|
||||
|
||||
var btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = isDark ? '☀️' : '🌙';
|
||||
}
|
||||
applyTheme();
|
||||
|
||||
if (typeof window.applyFiltersToAllTiles === 'function') {
|
||||
window.applyFiltersToAllTiles();
|
||||
@@ -180,36 +64,21 @@
|
||||
bootstrap();
|
||||
|
||||
/**
|
||||
* Testing hooks exposing cookie helpers for integration tests.
|
||||
* Testing hooks exposing internal helpers for integration tests.
|
||||
*
|
||||
* @type {{
|
||||
* getCookie: function(string): (?string),
|
||||
* setCookie: function(string, string, Object<string, *>=): void,
|
||||
* persistTheme: function(string): void,
|
||||
* maxAge: number,
|
||||
* __testHooks: {
|
||||
* applyTheme: function(string): boolean,
|
||||
* applyTheme: function(): void,
|
||||
* handleReady: function(): void,
|
||||
* bootstrap: function(): void,
|
||||
* setTheme: function(string): void
|
||||
* bootstrap: function(): void
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
window.__themeCookie = {
|
||||
getCookie: getCookie,
|
||||
setCookie: setCookie,
|
||||
persistTheme: persistTheme,
|
||||
maxAge: THEME_COOKIE_MAX_AGE,
|
||||
__testHooks: {
|
||||
applyTheme: applyTheme,
|
||||
handleReady: handleReady,
|
||||
bootstrap: bootstrap,
|
||||
setTheme: function setTheme(value) {
|
||||
theme = value;
|
||||
},
|
||||
exerciseSetCookieGuard: exerciseSetCookieGuard,
|
||||
serializeCookieOptions: serializeCookieOptions,
|
||||
formatCookieOption: formatCookieOption
|
||||
bootstrap: bootstrap
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -65,6 +65,7 @@ body.dark {
|
||||
--announcement-bg: #3b2500;
|
||||
--announcement-fg: #ffd184;
|
||||
--announcement-border: #a56a00;
|
||||
--map-tiles-filter: var(--map-tile-filter-dark);
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -173,12 +174,10 @@ body {
|
||||
|
||||
.page-shell--full-screen {
|
||||
padding: 0;
|
||||
padding-bottom: 0;
|
||||
gap: 0;
|
||||
gap: var(--pad);
|
||||
}
|
||||
|
||||
.page-shell--full-screen .site-header,
|
||||
.page-shell--full-screen .row.meta,
|
||||
.page-shell--full-screen .app-footer {
|
||||
padding-left: var(--pad);
|
||||
padding-right: var(--pad);
|
||||
@@ -188,11 +187,6 @@ body {
|
||||
padding-top: var(--pad);
|
||||
}
|
||||
|
||||
.page-shell--full-screen .row.meta {
|
||||
padding-bottom: var(--pad);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-shell--full-screen .app-footer {
|
||||
padding-top: var(--pad);
|
||||
padding-bottom: var(--pad);
|
||||
@@ -223,6 +217,7 @@ h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 4px 0;
|
||||
@@ -239,6 +234,9 @@ h1 {
|
||||
.site-header__left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.site-header__right {
|
||||
@@ -341,7 +339,7 @@ h1 {
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
@@ -510,10 +508,6 @@ h1 {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.site-header__left--federation {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
@@ -1212,7 +1206,7 @@ body.dark .node-detail-overlay__close:hover {
|
||||
}
|
||||
|
||||
.charts-page {
|
||||
padding: 24px var(--pad) 48px;
|
||||
padding: 0 0 var(--pad);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
@@ -1515,76 +1509,26 @@ body.dark .node-detail__chart-grid-line {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.refresh-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.refresh-info--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.refresh-row--no-info {
|
||||
grid-template-columns: auto;
|
||||
}
|
||||
|
||||
.refresh-row--no-info .refresh-actions {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.refresh-actions {
|
||||
.meta-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-self: end;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.auto-refresh-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls--full-screen {
|
||||
.meta-controls .filter-input {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.controls .filter-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1 1 260px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.refresh-timestamp {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-input input[type="text"] {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
@@ -1719,6 +1663,19 @@ input[type="radio"] {
|
||||
transition: accent-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
/* Wrapper that places the toggle button to the LEFT of the legend panel. */
|
||||
.legend-outer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
/* Reset Leaflet control chrome so only the inner elements have styling. */
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.legend {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
@@ -1753,7 +1710,7 @@ input[type="radio"] {
|
||||
|
||||
.legend-items--columns {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1764,6 +1721,15 @@ input[type="radio"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-column--bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.legend-protocol-count {
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.legend-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1800,9 +1766,9 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
.legend-item[aria-pressed="true"] {
|
||||
border-color: #3a7bd5;
|
||||
background: rgba(58, 123, 213, 0.18);
|
||||
color: #1a4a9e;
|
||||
border-color: #2a6bc5;
|
||||
background: rgba(58, 123, 213, 0.32);
|
||||
color: #0e3a8a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1825,6 +1791,10 @@ input[type="radio"] {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.nav-protocol-icon {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.legend-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1868,7 +1838,7 @@ input[type="radio"] {
|
||||
position: fixed;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 12px;
|
||||
@@ -1909,6 +1879,28 @@ input[type="radio"] {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Slim footer variant used on Charts and Federation pages — floats like the
|
||||
dashboard footer but with a transparent background and no border. */
|
||||
.app-footer--slim {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Cancel the extra block padding that .page-shell--full-screen injects on
|
||||
.app-footer — slim footers use the same visual height as the dashboard. */
|
||||
.page-shell--full-screen .app-footer--slim {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.meta-active-nodes {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-footer .footer-content {
|
||||
padding: 10px 12px;
|
||||
@@ -1926,85 +1918,6 @@ input[type="radio"] {
|
||||
}
|
||||
}
|
||||
|
||||
.info-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--pad);
|
||||
z-index: 13000;
|
||||
}
|
||||
|
||||
.info-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.info-dialog {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
max-width: 420px;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
padding: 20px 24px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.info-dialog:focus {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.info-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.info-close:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.info-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.info-intro {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.info-details {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-details dt {
|
||||
font-weight: 600;
|
||||
margin-top: 12px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.info-details dd {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.info-details dd a {
|
||||
color: inherit;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.row {
|
||||
@@ -2022,39 +1935,14 @@ input[type="radio"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.controls {
|
||||
order: 2;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls .filter-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
order: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refresh-row {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
.meta-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-self: start;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.meta-controls .filter-input {
|
||||
flex: 1 1 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
#map {
|
||||
@@ -2124,20 +2012,6 @@ input[type="radio"] {
|
||||
}
|
||||
}
|
||||
|
||||
body.dark {
|
||||
background: #111;
|
||||
color: #eee;
|
||||
--map-tiles-filter: var(--map-tile-filter-dark);
|
||||
}
|
||||
|
||||
body.dark .meta {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .refresh-info {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .pill {
|
||||
background: #444;
|
||||
}
|
||||
@@ -2239,9 +2113,9 @@ body.dark .legend-item:hover {
|
||||
}
|
||||
|
||||
body.dark .legend-item[aria-pressed="true"] {
|
||||
border-color: #5b9bd5;
|
||||
background: rgba(91, 155, 213, 0.28);
|
||||
color: #a8d4ff;
|
||||
border-color: #70b3f0;
|
||||
background: rgba(91, 155, 213, 0.48);
|
||||
color: #c8e8ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -2286,28 +2160,6 @@ body.dark .chat-entry-date {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .info-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
body.dark .info-dialog {
|
||||
background: #1c1c1c;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
body.dark .info-intro {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body.dark .info-details dt {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body.dark .info-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .short-info-overlay {
|
||||
background: #1c1c1c;
|
||||
border-color: #444;
|
||||
@@ -2360,16 +2212,12 @@ body.dark #map .leaflet-tile.map-tiles {
|
||||
}
|
||||
|
||||
.federation-page {
|
||||
padding: 24px var(--pad) 48px;
|
||||
padding: 0 0 var(--pad);
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.federation-page--full-width {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.federation-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+32
-20
@@ -1351,13 +1351,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
|
||||
end
|
||||
|
||||
it "disables the auto-fit toggle when a map zoom override is configured" do
|
||||
it "does not include the removed auto-fit checkbox regardless of map zoom override" do
|
||||
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(11.0)
|
||||
|
||||
get "/"
|
||||
|
||||
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
|
||||
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
|
||||
expect(last_response.body).not_to include('id="fitBounds"')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1369,21 +1368,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('class="map-panel map-panel--full"')
|
||||
expect(last_response.body).to include('id="map"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).to include('id="fitBounds"')
|
||||
expect(last_response.body).not_to include('id="fitBounds"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
|
||||
it "disables the auto-fit toggle when a map zoom override is configured" do
|
||||
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(9.5)
|
||||
|
||||
get "/map"
|
||||
|
||||
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
|
||||
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /federation" do
|
||||
@@ -1401,11 +1391,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
get "/federation"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="federation-page federation-page--full-width"')
|
||||
expect(last_response.body).to include('class="federation-page"')
|
||||
expect(last_response.body).to include("initializeFederationPage")
|
||||
end
|
||||
|
||||
it "hides dashboard-only refresh controls while keeping manual refresh and theme toggle" do
|
||||
it "hides the meta-controls row entirely on the federation page" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
|
||||
|
||||
get "/federation"
|
||||
@@ -1413,8 +1403,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="themeToggle"')
|
||||
expect(last_response.body).not_to include('id="refreshBtn"')
|
||||
expect(last_response.body).not_to include('id="themeToggle"')
|
||||
expect(last_response.body).not_to include('id="metaRow"')
|
||||
end
|
||||
|
||||
it "renders the slim footer on the federation page" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
|
||||
|
||||
get "/federation"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="app-footer app-footer--slim"')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1425,7 +1425,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
@@ -1449,13 +1449,25 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('class="nodes-table-wrapper"')
|
||||
expect(last_response.body).to include('id="nodes"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /charts" do
|
||||
it "renders the charts page with the slim footer but without meta-controls" do
|
||||
get "/charts"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include("initializeChartsPage")
|
||||
expect(last_response.body).not_to include('id="metaRow"')
|
||||
expect(last_response.body).not_to include('id="filterInput"')
|
||||
expect(last_response.body).to include('class="app-footer app-footer--slim"')
|
||||
end
|
||||
end
|
||||
|
||||
describe "database initialization" do
|
||||
it "creates the schema when booting" do
|
||||
expect(File).to exist(PotatoMesh::Config.db_path)
|
||||
|
||||
@@ -158,8 +158,8 @@ RSpec.describe PotatoMesh::App::Helpers do
|
||||
expect(helper.meshcore_companion_display_short_name(" ")).to be_nil
|
||||
end
|
||||
|
||||
it "returns ' A ' for a single-word name" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice")).to eq(" A ")
|
||||
it "returns nil for a single-word name (falls back to raw DB short name)" do
|
||||
expect(helper.meshcore_companion_display_short_name("Alice")).to be_nil
|
||||
end
|
||||
|
||||
it "returns ' AB ' for a two-word name" do
|
||||
@@ -203,8 +203,8 @@ RSpec.describe PotatoMesh::App::Helpers do
|
||||
expect(helper.meshcore_companion_display_short_name(name)).to eq(" \u{1F600} ")
|
||||
end
|
||||
|
||||
it "returns the single initial when the name is one word with no emoji" do
|
||||
expect(helper.meshcore_companion_display_short_name("Zigzag")).to eq(" Z ")
|
||||
it "returns nil for a single-word name with no emoji (falls back to raw DB short name)" do
|
||||
expect(helper.meshcore_companion_display_short_name("Zigzag")).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -434,7 +434,7 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
expect(row["short_name"]).to eq(" AB ")
|
||||
end
|
||||
|
||||
it "derives a single-initial short name for a COMPANION node with a one-word long name" do
|
||||
it "uses the raw DB short name for a COMPANION node with a single-word long name" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
@@ -444,7 +444,20 @@ RSpec.describe PotatoMesh::App::Queries do
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000002")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000002" }
|
||||
expect(row["short_name"]).to eq(" Z ")
|
||||
expect(row["short_name"]).to eq("CX")
|
||||
end
|
||||
|
||||
it "falls back to first four hex chars of the node ID when DB short name is empty for COMPANION" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
["!cc000009", 0xcc000009, "", "Feierabend", now, now, "COMPANION"],
|
||||
)
|
||||
end
|
||||
rows = queries.query_nodes(10, node_ref: "!cc000009")
|
||||
row = rows.find { |r| r["node_id"] == "!cc000009" }
|
||||
expect(row["short_name"]).to eq("cc00")
|
||||
end
|
||||
|
||||
it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
-->
|
||||
<section class="charts-page">
|
||||
<header class="charts-page__intro">
|
||||
<h2>Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every node in the past week.</p>
|
||||
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
|
||||
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
|
||||
</header>
|
||||
<div id="chartsPage" class="charts-page__content">
|
||||
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<section class="federation-page federation-page--full-width">
|
||||
<section class="federation-page">
|
||||
<div class="federation-page__content">
|
||||
<div class="federation-page__map-row">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
|
||||
|
||||
+16
-108
@@ -14,7 +14,7 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<html lang="en" data-theme="<%= initial_theme %>">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta charset="utf-8" />
|
||||
@@ -65,10 +65,9 @@
|
||||
crossorigin=""
|
||||
></script>
|
||||
</head>
|
||||
<% 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 %>
|
||||
<body
|
||||
class="<%= body_classes.join(" ") %>"
|
||||
@@ -110,7 +97,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<header class="site-header">
|
||||
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
|
||||
<div class="site-header__left">
|
||||
<h1 class="site-title">
|
||||
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
|
||||
<span class="site-title-text"><%= site_name %></span>
|
||||
@@ -132,7 +119,7 @@
|
||||
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
|
||||
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
|
||||
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
|
||||
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
|
||||
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
|
||||
<% if federation_nav_enabled %>
|
||||
<a href="/federation" class="site-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
|
||||
<% end %>
|
||||
@@ -163,7 +150,7 @@
|
||||
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
|
||||
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
|
||||
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
|
||||
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
|
||||
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>><img src="/assets/img/meshtastic.svg" alt="" width="13" height="13" class="protocol-icon protocol-icon--meshtastic nav-protocol-icon" aria-hidden="true" loading="lazy" decoding="async" /> Charts</a>
|
||||
<% if federation_nav_enabled %>
|
||||
<a href="/federation" class="mobile-nav__link js-federation-nav<%= view_mode == :federation ? " is-active" : "" %>" data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
|
||||
<% end %>
|
||||
@@ -172,75 +159,19 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="metaRow" class="row meta">
|
||||
<% if show_meta_info %>
|
||||
<div class="meta-info">
|
||||
<div class="<%= refresh_row_classes.join(" ") %>">
|
||||
<% if refresh_info_text %>
|
||||
<p id="refreshInfo" class="refresh-info" aria-live="polite"><%= refresh_info_text %></p>
|
||||
<% else %>
|
||||
<p id="refreshInfo" class="<%= refresh_info_classes.join(" ") %>" aria-live="polite"></p>
|
||||
<% end %>
|
||||
<% if show_refresh_actions %>
|
||||
<div class="refresh-actions">
|
||||
<% if show_auto_refresh_toggle %>
|
||||
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
|
||||
<% end %>
|
||||
<button id="refreshBtn" type="button">Refresh now</button>
|
||||
<span id="status" class="pill">loading…</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="<%= controls_classes.join(" ") %>">
|
||||
<% if show_auto_fit_toggle %>
|
||||
<% auto_fit_attrs = [] %>
|
||||
<% if map_zoom_override.nil? %>
|
||||
<% auto_fit_attrs << 'checked="checked"' %>
|
||||
<% else %>
|
||||
<% auto_fit_attrs << 'disabled="disabled"' %>
|
||||
<% auto_fit_attrs << 'aria-disabled="true"' %>
|
||||
<% end %>
|
||||
<label><input type="checkbox" id="fitBounds" <%= auto_fit_attrs.join(" ") %> /> Auto-fit map</label>
|
||||
<% end %>
|
||||
<% if show_meta_row %>
|
||||
<div id="metaRow" class="meta-controls">
|
||||
<% if show_filter_input %>
|
||||
<div class="filter-input">
|
||||
<input type="text" id="filterInput" placeholder="Filter nodes" />
|
||||
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>×</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
|
||||
<% if show_info_button %>
|
||||
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true">ℹ️</span></button>
|
||||
<% end %>
|
||||
<span id="footerActiveNodes" class="meta-active-nodes"></span>
|
||||
<button id="refreshBtn" type="button">Refresh</button>
|
||||
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="infoOverlay" class="info-overlay" role="dialog" aria-modal="true" aria-labelledby="infoTitle" hidden>
|
||||
<div class="info-dialog" tabindex="-1">
|
||||
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
|
||||
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
|
||||
<dl class="info-details">
|
||||
<dt>Channel</dt>
|
||||
<dd><%= channel %></dd>
|
||||
<dt>Frequency</dt>
|
||||
<dd><%= frequency %></dd>
|
||||
<dt>Map center</dt>
|
||||
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
|
||||
<dt>Visible range</dt>
|
||||
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<dt>Chat</dt>
|
||||
<% if contact_link_url %>
|
||||
<dd><a href="<%= contact_link_url %>" target="_blank" rel="noreferrer noopener"><%= contact_link %></a></dd>
|
||||
<% else %>
|
||||
<dd><%= contact_link %></dd>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="nodeDetailOverlay" class="node-detail-overlay" hidden>
|
||||
<div class="node-detail-overlay__dialog" role="dialog" aria-modal="true" aria-labelledby="nodeDetailOverlayHeader" tabindex="-1">
|
||||
@@ -257,30 +188,7 @@
|
||||
</main>
|
||||
|
||||
<% if show_footer %>
|
||||
<footer class="app-footer">
|
||||
<div class="footer-content">
|
||||
<span class="footer-brand">PotatoMesh</span>
|
||||
<% if version && !version.empty? %>
|
||||
<span class="mono"><%= version %></span>
|
||||
<% end %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-links">
|
||||
GitHub:
|
||||
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-contact">
|
||||
<%= site_name %> chat:
|
||||
<% if contact_link_url %>
|
||||
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
|
||||
<% else %>
|
||||
<%= contact_link %>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
<%= erb :"shared/_footer", locals: { site_name: site_name, version: version, contact_link: contact_link, contact_link_url: contact_link_url, slim: footer_slim } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<% slim = defined?(slim) && slim %>
|
||||
<footer class="app-footer<%= slim ? " app-footer--slim" : "" %>">
|
||||
<div class="footer-content">
|
||||
<span class="footer-brand">PotatoMesh</span>
|
||||
<% if version && !version.empty? %>
|
||||
<span class="mono"><%= version %></span>
|
||||
<% end %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-links">
|
||||
GitHub:
|
||||
<a href="https://github.com/l5yth/potato-mesh" target="_blank">l5yth/potato-mesh</a>
|
||||
<% if contact_link && !contact_link.empty? %>
|
||||
<span class="footer-separator" aria-hidden="true">—</span>
|
||||
<span class="footer-contact">
|
||||
<%= site_name %> chat:
|
||||
<% if contact_link_url %>
|
||||
<a href="<%= contact_link_url %>" target="_blank"><%= contact_link %></a>
|
||||
<% else %>
|
||||
<%= contact_link %>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
Reference in New Issue
Block a user