From 108573b100a9f5bd1e7892b8ffb72c0b50cc2dfb Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:17:59 +0100 Subject: [PATCH] web: add announcement banner (#609) * web: add announcement banner * web: cover missing unit test vectors --- README.md | 1 + web/lib/potato_mesh/application/helpers.rb | 43 +++++++++++++ web/lib/potato_mesh/config.rb | 7 ++ web/lib/potato_mesh/sanitizer.rb | 8 +++ .../app/__tests__/instance-selector.test.js | 64 ++++++++++++++++++- web/public/assets/js/app/instance-selector.js | 48 +++++++++++++- web/public/assets/styles/base.css | 29 +++++++++ web/spec/app_spec.rb | 28 ++++++++ web/spec/config_spec.rb | 18 ++++++ web/spec/sanitizer_spec.rb | 8 +++ web/views/layouts/app.erb | 12 +++- 11 files changed, 261 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 48f8f5a..03dfcfa 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ The web app can be configured with environment variables (defaults shown): | `CHANNEL` | `"#LongFast"` | Default channel name displayed in the UI. | | `FREQUENCY` | `"915MHz"` | Default frequency description displayed in the UI. | | `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix alias rendered in the footer and overlays. | +| `ANNOUNCEMENT` | _unset_ | Optional announcement banner text rendered above the header on every page. | | `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map on load. | | `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. | | `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. | diff --git a/web/lib/potato_mesh/application/helpers.rb b/web/lib/potato_mesh/application/helpers.rb index 837fc45..40f4c46 100644 --- a/web/lib/potato_mesh/application/helpers.rb +++ b/web/lib/potato_mesh/application/helpers.rb @@ -20,6 +20,8 @@ module PotatoMesh # its intended consumers to ensure consistent behaviour across the Sinatra # application. module Helpers + ANNOUNCEMENT_URL_PATTERN = %r{\bhttps?://[^\s<]+}i.freeze + # Fetch an application level constant exposed by {PotatoMesh::Application}. # # @param name [Symbol] constant identifier to retrieve. @@ -92,6 +94,47 @@ module PotatoMesh PotatoMesh::Sanitizer.sanitized_site_name end + # Retrieve the configured announcement banner copy. + # + # @return [String, nil] sanitised announcement or nil when unset. + def sanitized_announcement + PotatoMesh::Sanitizer.sanitized_announcement + end + + # Render the announcement copy with safe outbound links. + # + # @return [String, nil] escaped HTML snippet or nil when unset. + def announcement_html + announcement = sanitized_announcement + return nil unless announcement + + fragments = [] + last_index = 0 + + announcement.to_enum(:scan, ANNOUNCEMENT_URL_PATTERN).each do + match = Regexp.last_match + next unless match + + start_index = match.begin(0) + end_index = match.end(0) + + if start_index > last_index + fragments << Rack::Utils.escape_html(announcement[last_index...start_index]) + end + + url = match[0] + escaped_url = Rack::Utils.escape_html(url) + fragments << %(#{escaped_url}) + last_index = end_index + end + + if last_index < announcement.length + fragments << Rack::Utils.escape_html(announcement[last_index..]) + end + + fragments.join + end + # Retrieve the configured channel. # # @return [String] sanitised channel identifier. diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index d1d061a..b652a62 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -444,6 +444,13 @@ module PotatoMesh fetch_string("SITE_NAME", "PotatoMesh Demo") end + # Retrieve the configured announcement banner copy. + # + # @return [String, nil] announcement string when configured. + def announcement + fetch_string("ANNOUNCEMENT", nil) + end + # Retrieve the default radio channel label. # # @return [String] channel name from configuration. diff --git a/web/lib/potato_mesh/sanitizer.rb b/web/lib/potato_mesh/sanitizer.rb index 7d06882..2dad09b 100644 --- a/web/lib/potato_mesh/sanitizer.rb +++ b/web/lib/potato_mesh/sanitizer.rb @@ -199,6 +199,14 @@ module PotatoMesh sanitized_string(Config.site_name) end + # Retrieve the configured announcement banner copy and normalise blank values to nil. + # + # @return [String, nil] announcement copy or +nil+ when blank. + def sanitized_announcement + value = sanitized_string(Config.announcement) + value.empty? ? nil : value + end + # Retrieve the configured channel as a cleaned string. # # @return [String] trimmed configuration value. diff --git a/web/public/assets/js/app/__tests__/instance-selector.test.js b/web/public/assets/js/app/__tests__/instance-selector.test.js index bc0c869..d0a8e90 100644 --- a/web/public/assets/js/app/__tests__/instance-selector.test.js +++ b/web/public/assets/js/app/__tests__/instance-selector.test.js @@ -20,7 +20,7 @@ import { createDomEnvironment } from './dom-environment.js'; import { buildInstanceUrl, initializeInstanceSelector, __test__ } from '../instance-selector.js'; -const { resolveInstanceLabel } = __test__; +const { resolveInstanceLabel, updateFederationNavCount } = __test__; function setupSelectElement(document) { const select = document.createElement('select'); @@ -191,3 +191,65 @@ test('initializeInstanceSelector navigates to the chosen instance domain', async env.cleanup(); } }); + +test('initializeInstanceSelector updates federation navigation labels with instance count', async () => { + const env = createDomEnvironment(); + const select = setupSelectElement(env.document); + const navLink = env.document.createElement('a'); + navLink.classList.add('js-federation-nav'); + navLink.textContent = 'Federation'; + env.document.body.appendChild(navLink); + + 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.equal(navLink.textContent, 'Federation (2)'); + } finally { + env.cleanup(); + } +}); + +test('updateFederationNavCount prefers stored labels and normalizes counts', () => { + const env = createDomEnvironment(); + const navLink = env.document.createElement('a'); + navLink.classList.add('js-federation-nav'); + navLink.textContent = 'Federation'; + navLink.dataset.federationLabel = 'Community'; + env.document.body.appendChild(navLink); + + try { + updateFederationNavCount({ documentObject: env.document, count: -3 }); + + assert.equal(navLink.textContent, 'Community (0)'); + } finally { + env.cleanup(); + } +}); + +test('updateFederationNavCount falls back to existing link text when no dataset label', () => { + const env = createDomEnvironment(); + const navLink = env.document.createElement('a'); + navLink.classList.add('js-federation-nav'); + navLink.textContent = 'Federation (9)'; + env.document.body.appendChild(navLink); + + try { + updateFederationNavCount({ documentObject: env.document, count: 4 }); + + assert.equal(navLink.textContent, 'Federation (4)'); + } finally { + env.cleanup(); + } +}); diff --git a/web/public/assets/js/app/instance-selector.js b/web/public/assets/js/app/instance-selector.js index 0b17c0c..1d84800 100644 --- a/web/public/assets/js/app/instance-selector.js +++ b/web/public/assets/js/app/instance-selector.js @@ -34,6 +34,50 @@ function resolveInstanceLabel(entry) { return domain; } +/** + * Update federation navigation labels with the instance count. + * + * @param {{ + * documentObject?: Document | null, + * count: number + * }} options Configuration for updating the navigation labels. + * @returns {void} + */ +function updateFederationNavCount(options) { + const { documentObject, count } = options; + + if (!documentObject || typeof count !== 'number' || !Number.isFinite(count)) { + return; + } + + const normalizedCount = Math.max(0, Math.floor(count)); + const root = typeof documentObject.querySelectorAll === 'function' + ? documentObject + : documentObject.body; + + if (!root || typeof root.querySelectorAll !== 'function') { + return; + } + + const links = Array.from(root.querySelectorAll('.js-federation-nav')); + + links.forEach(link => { + if (!link || typeof link !== 'object') { + return; + } + + const dataset = link.dataset || {}; + const storedLabel = typeof dataset.federationLabel === 'string' ? dataset.federationLabel.trim() : ''; + const fallbackLabel = typeof link.textContent === 'string' + ? link.textContent.split('(')[0].trim() + : 'Federation'; + const label = storedLabel || fallbackLabel || 'Federation'; + + dataset.federationLabel = label; + link.textContent = `${label} (${normalizedCount})`; + }); +} + /** * Construct a navigable URL for the provided instance domain. * @@ -166,6 +210,8 @@ export async function initializeInstanceSelector(options) { return; } + updateFederationNavCount({ documentObject: doc, count: payload.length }); + const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null; const sortedEntries = payload @@ -238,4 +284,4 @@ export async function initializeInstanceSelector(options) { }); } -export const __test__ = { resolveInstanceLabel }; +export const __test__ = { resolveInstanceLabel, updateFederationNavCount }; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index d44e2e4..cb6cac7 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -30,6 +30,9 @@ --input-border: rgba(12, 15, 18, 0.18); --input-placeholder: rgba(12, 15, 18, 0.45); --control-accent: var(--accent); + --announcement-bg: #fff4d6; + --announcement-fg: #7a3f00; + --announcement-border: #f0c05b; --pad: 16px; --map-tile-filter-light: grayscale(1) saturate(0) brightness(0.92) contrast(1.05); --map-tile-filter-dark: grayscale(1) invert(1) brightness(0.9) contrast(1.08); @@ -59,6 +62,9 @@ body.dark { --input-border: rgba(230, 235, 240, 0.24); --input-placeholder: rgba(230, 235, 240, 0.55); --control-accent: var(--accent); + --announcement-bg: #3b2500; + --announcement-fg: #ffd184; + --announcement-border: #a56a00; } html, @@ -240,6 +246,29 @@ h1 { margin-left: auto; } +.announcement-banner { + display: flex; + align-items: center; + justify-content: center; + height: 1.6em; + padding: 0 var(--pad); + border-radius: 999px; + background: var(--announcement-bg); + color: var(--announcement-fg); + border: 1px solid var(--announcement-border); + box-sizing: border-box; + overflow: hidden; +} + +.announcement-banner__content { + margin: 0; + line-height: 1.6; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .site-title { display: inline-flex; align-items: center; diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index f8f1d8e..59514ec 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -728,6 +728,34 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(sanitized_contact_link).to be_nil end + it "returns nil when the announcement is unset" do + allow(PotatoMesh::Config).to receive(:announcement).and_return(nil) + expect(announcement_html).to be_nil + end + + it "renders announcement links with safe targets" do + allow(PotatoMesh::Config).to receive(:announcement).and_return("Visit https://example.org now.") + + expect(announcement_html).to include( + 'https://example.org', + ) + end + + it "escapes announcement text while preserving links" do + allow(PotatoMesh::Config).to receive(:announcement).and_return("Hi https://example.org") + + expect(announcement_html).to include("<b>Hi</b>") + expect(announcement_html).to include( + 'https://example.org', + ) + end + + it "returns escaped announcement text when no links are present" do + allow(PotatoMesh::Config).to receive(:announcement).and_return("") + + expect(announcement_html).to eq("<hi>") + end + it "coerces string_or_nil inputs" do expect(string_or_nil(" hello \n")).to eq("hello") expect(string_or_nil(" ")).to be_nil diff --git a/web/spec/config_spec.rb b/web/spec/config_spec.rb index 1dfb9ad..db31ee3 100644 --- a/web/spec/config_spec.rb +++ b/web/spec/config_spec.rb @@ -516,6 +516,24 @@ RSpec.describe PotatoMesh::Config do end end + describe ".announcement" do + it "returns nil when unset or blank" do + within_env("ANNOUNCEMENT" => nil) do + expect(described_class.announcement).to be_nil + end + + within_env("ANNOUNCEMENT" => " \t ") do + expect(described_class.announcement).to be_nil + end + end + + it "returns the trimmed announcement text" do + within_env("ANNOUNCEMENT" => " Next Meetup ") do + expect(described_class.announcement).to eq("Next Meetup") + end + end + end + describe ".debug?" do it "reflects the DEBUG environment variable" do within_env("DEBUG" => "1") do diff --git a/web/spec/sanitizer_spec.rb b/web/spec/sanitizer_spec.rb index 3f9afd5..249745b 100644 --- a/web/spec/sanitizer_spec.rb +++ b/web/spec/sanitizer_spec.rb @@ -75,6 +75,7 @@ RSpec.describe PotatoMesh::Sanitizer do before do allow(PotatoMesh::Config).to receive_messages( site_name: " Spec Mesh ", + announcement: " Next Meetup ", channel: " #Spec ", frequency: " 915MHz ", contact_link: " #room:example.org ", @@ -84,6 +85,7 @@ RSpec.describe PotatoMesh::Sanitizer do it "provides trimmed strings" do expect(described_class.sanitized_site_name).to eq("Spec Mesh") + expect(described_class.sanitized_announcement).to eq("Next Meetup") expect(described_class.sanitized_channel).to eq("#Spec") expect(described_class.sanitized_frequency).to eq("915MHz") expect(described_class.sanitized_contact_link).to eq("#room:example.org") @@ -98,6 +100,12 @@ RSpec.describe PotatoMesh::Sanitizer do expect(described_class.sanitized_contact_link_url).to be_nil end + it "returns nil when the announcement is blank" do + allow(PotatoMesh::Config).to receive(:announcement).and_return(" ") + + expect(described_class.sanitized_announcement).to be_nil + end + it "returns nil when the distance is not positive" do allow(PotatoMesh::Config).to receive(:max_distance_km).and_return(0) diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 1174858..11c5704 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -94,7 +94,8 @@ 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? %> + refresh_info_classes << "refresh-info--hidden" if refresh_info_text.nil? + announcement_markup = announcement_html %> " data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>" @@ -103,6 +104,11 @@ > "> <% if show_header %> + <% if announcement_markup && !announcement_markup.empty? %> + + <%= announcement_markup %> + + <% end %> "> @@ -128,7 +134,7 @@ "<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes "<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts <% if federation_nav_enabled %> - "<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation + " data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation <% end %> "<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes "<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts <% if federation_nav_enabled %> - "<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation + " data-federation-label="Federation"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation <% end %>
<%= announcement_markup %>