web: add announcement banner (#609)

* web: add announcement banner

* web: cover missing unit test vectors
This commit is contained in:
l5y
2026-01-08 21:17:59 +01:00
committed by GitHub
parent 36f55e6b79
commit 108573b100
11 changed files with 261 additions and 5 deletions
+1
View File
@@ -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. |
@@ -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 << %(<a href="#{escaped_url}" target="_blank" rel="noopener noreferrer">#{escaped_url}</a>)
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.
+7
View File
@@ -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.
+8
View File
@@ -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.
@@ -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();
}
});
+47 -1
View File
@@ -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 };
+29
View File
@@ -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;
+28
View File
@@ -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(
'<a href="https://example.org" target="_blank" rel="noopener noreferrer">https://example.org</a>',
)
end
it "escapes announcement text while preserving links" do
allow(PotatoMesh::Config).to receive(:announcement).and_return("<b>Hi</b> https://example.org")
expect(announcement_html).to include("&lt;b&gt;Hi&lt;/b&gt;")
expect(announcement_html).to include(
'<a href="https://example.org" target="_blank" rel="noopener noreferrer">https://example.org</a>',
)
end
it "returns escaped announcement text when no links are present" do
allow(PotatoMesh::Config).to receive(:announcement).and_return("<hi>")
expect(announcement_html).to eq("&lt;hi&gt;")
end
it "coerces string_or_nil inputs" do
expect(string_or_nil(" hello \n")).to eq("hello")
expect(string_or_nil(" ")).to be_nil
+18
View File
@@ -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
+8
View File
@@ -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)
+9 -3
View File
@@ -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 %>
<body
class="<%= body_classes.join(" ") %>"
data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>"
@@ -103,6 +104,11 @@
>
<div class="<%= shell_classes.join(" ") %>">
<% if show_header %>
<% if announcement_markup && !announcement_markup.empty? %>
<div class="announcement-banner" role="status" aria-live="polite">
<p class="announcement-banner__content"><%= announcement_markup %></p>
</div>
<% end %>
<header class="site-header">
<div class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
<h1 class="site-title">
@@ -128,7 +134,7 @@
<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>
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<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 %>
</nav>
<button
@@ -159,7 +165,7 @@
<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>
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<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 %>
</nav>
</div>