mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 01:13:01 +02:00
web: add announcement banner (#609)
* web: add announcement banner * web: cover missing unit test vectors
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("<b>Hi</b>")
|
||||
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("<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user