From 8fb4b4705bfcbfaa956f98bd25cfed4340ca878f Mon Sep 17 00:00:00 2001 From: Louis King Date: Mon, 31 Aug 2026 10:22:52 +0100 Subject: [PATCH] feat(web): add RSS feed icon links to list toolbars - Add IconRss component and feedHref prop to ListToolbar - Wire feed links on Nodes, Advertisements, and Messages pages - Messages feed link respects channel filter, defaults to idx 17 - Simplify advert/node feed item bodies, link adverts to packet hashes - Add unit tests, page tests, and e2e specs for feed link behavior --- e2e/tests/advertisements.spec.ts | 16 +++++ e2e/tests/messages.spec.ts | 31 +++++++++ e2e/tests/nodes.spec.ts | 15 ++++ src/meshcore_hub/api/routes/feeds.py | 15 ++-- .../spa-react/components/ListToolbar.test.tsx | 18 +++++ .../js/spa-react/components/ListToolbar.tsx | 14 ++++ .../js/spa-react/components/icons/index.tsx | 13 ++++ .../spa-react/pages/Advertisements.test.tsx | 23 +++++++ .../js/spa-react/pages/Advertisements.tsx | 1 + .../js/spa-react/pages/Messages.test.tsx | 69 +++++++++++++++++++ .../static/js/spa-react/pages/Messages.tsx | 25 +++++++ .../static/js/spa-react/pages/Nodes.test.tsx | 22 ++++++ .../web/static/js/spa-react/pages/Nodes.tsx | 3 + tests/test_api/test_feeds.py | 53 +++++++++++++- 14 files changed, 309 insertions(+), 9 deletions(-) diff --git a/e2e/tests/advertisements.spec.ts b/e2e/tests/advertisements.spec.ts index f802c76..09d7b05 100644 --- a/e2e/tests/advertisements.spec.ts +++ b/e2e/tests/advertisements.spec.ts @@ -26,6 +26,22 @@ test.describe("advertisements", () => { await expect(table.getByText("Alpha Node")).toHaveCount(0); }); + test("feed link points at the adverts RSS feed", async ({ page }) => { + await page.goto("/advertisements"); + await expectListLoaded(page); + const feedLink = page.getByTestId("feed-link"); + await expect(feedLink).toHaveAttribute("href", "/feeds/adverts.xml"); + + await feedLink.click(); + await expect(page).toHaveURL(/\/feeds\/adverts\.xml$/); + // Chromium wraps raw XML in
 and HTML-escapes tags; innerText gives
+    // us the unescaped RSS XML.
+    const content = await page.locator("pre").innerText();
+    expect(content).toContain(" {
     await page.goto("/advertisements");
     await expectListLoaded(page);
diff --git a/e2e/tests/messages.spec.ts b/e2e/tests/messages.spec.ts
index c06dca2..1c62249 100644
--- a/e2e/tests/messages.spec.ts
+++ b/e2e/tests/messages.spec.ts
@@ -28,6 +28,37 @@ test.describe("messages", () => {
     await expect(page.getByTestId("list-row")).toHaveCount(4);
   });
 
+  test("feed link respects the channel filter", async ({ page }) => {
+    await page.goto("/messages");
+    await expectListLoaded(page);
+    // No channel filter: defaults to the built-in public channel (idx 17).
+    const feedLink = page.getByTestId("feed-link");
+    await expect(feedLink).toHaveAttribute("href", "/feeds/channels/17.xml");
+
+    await feedLink.click();
+    await expect(page).toHaveURL(/\/feeds\/channels\/17\.xml$/);
+    const xml17 = await page.locator("pre").innerText();
+    expect(xml17).toContain("Hello from the e2e mesh");
+
+    // Selecting a community channel repoints the feed at that channel.
+    await page.goto("/messages");
+    await expectListLoaded(page);
+    await openFilters(page);
+    await page
+      .locator('select[name="channel_idx"]')
+      .selectOption({ label: "E2E General" });
+    await expect(page).toHaveURL(/channel_idx=\d+/);
+    const customLink = page.getByTestId("feed-link");
+    const href = await customLink.getAttribute("href");
+    expect(href).toMatch(/^\/feeds\/channels\/\d+\.xml$/);
+    expect(href).not.toBe("/feeds/channels/17.xml");
+
+    await customLink.click();
+    await expect(page).toHaveURL(/\/feeds\/channels\/\d+\.xml$/);
+    const xmlCustom = await page.locator("pre").innerText();
+    expect(xmlCustom).toContain("Ops channel traffic");
+  });
+
   test("auto-refresh works and can be paused", async ({ page }) => {
     await page.goto("/messages");
     await expectListLoaded(page);
diff --git a/e2e/tests/nodes.spec.ts b/e2e/tests/nodes.spec.ts
index 3be61bb..eddc039 100644
--- a/e2e/tests/nodes.spec.ts
+++ b/e2e/tests/nodes.spec.ts
@@ -34,6 +34,21 @@ test.describe("nodes", () => {
     await expect(table.getByText("Bravo Node").first()).toBeVisible();
   });
 
+  test("feed link points at the nodes RSS feed", async ({ page }) => {
+    await page.goto("/nodes");
+    await expectListLoaded(page);
+    const feedLink = page.getByTestId("feed-link");
+    await expect(feedLink).toHaveAttribute("href", "/feeds/nodes.xml");
+
+    await feedLink.click();
+    await expect(page).toHaveURL(/\/feeds\/nodes\.xml$/);
+    // Chromium wraps raw XML in 
 and HTML-escapes tags; innerText gives
+    // us the unescaped RSS XML.
+    const content = await page.locator("pre").innerText();
+    expect(content).toContain(" {
     await page.goto("/nodes");
     await expectListLoaded(page);
diff --git a/src/meshcore_hub/api/routes/feeds.py b/src/meshcore_hub/api/routes/feeds.py
index 823007b..ae6b54b 100644
--- a/src/meshcore_hub/api/routes/feeds.py
+++ b/src/meshcore_hub/api/routes/feeds.py
@@ -298,16 +298,18 @@ def _advert_items(
         node = nodes_by_id.get(advert.node_id) if advert.node_id else None
         title = _node_display_name(advert.name, node, advert.public_key)
         description = (
-            f"{advert.adv_type or 'node'} advertisement; "
-            f"advertised at {_format_ts(advert.advert_timestamp)}, "
-            f"received {_format_ts(advert.received_at)}"
+            f"{advert.adv_type or 'node'} - {_format_ts(advert.advert_timestamp)}"
         )
+        if advert.packet_hash:
+            link = f"{base}/packets/hash/{advert.packet_hash}"
+        else:
+            link = f"{base}/nodes/{advert.public_key}"
         items.append(
             FeedItem(
                 title=title,
                 description=description,
                 guid=f"advert:{advert.id}",
-                link=f"{base}/nodes/{advert.public_key}",
+                link=link,
                 pub_date=advert.received_at,
                 guid_is_permalink=False,
             )
@@ -325,10 +327,7 @@ def _node_items(nodes: list[Node], base: str) -> list[FeedItem]:
     items: list[FeedItem] = []
     for node in nodes:
         title = _node_display_name(None, node, node.public_key)
-        description = (
-            f"{node.adv_type or 'node'} joined the mesh; "
-            f"last seen {_format_ts(node.last_seen)}"
-        )
+        description = f"{node.adv_type or 'node'} - {_format_ts(node.last_seen)}"
         items.append(
             FeedItem(
                 title=title,
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx
index 03a0e1c..824db5f 100644
--- a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx
@@ -48,6 +48,24 @@ describe("ListToolbar", () => {
     expect(container.querySelector('input[type="checkbox"]')).toBeNull();
   });
 
+  it("renders the feed link only when feedHref is provided", () => {
+    const { rerender } = render(
+      ,
+    );
+    expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument();
+    rerender(
+      ,
+    );
+    const link = screen.getByTestId("feed-link");
+    expect(link).toHaveAttribute("href", "/feeds/nodes.xml");
+    expect(link).toHaveAttribute("aria-label", "RSS");
+    expect(link.querySelector("svg")).toBeInTheDocument();
+  });
+
   it("renders the filter toggle only when provided", () => {
     const { container, rerender } = render(
       ,
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx
index d515917..40f3852 100644
--- a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx
@@ -4,6 +4,7 @@ import { WarningBadge } from "@/components/Alerts";
 import { AutoRefreshToggle } from "@/components/AutoRefreshToggle";
 import { CountBadge } from "@/components/Badges";
 import { FilterToggle } from "@/components/FilterForm";
+import { IconRss } from "@/components/icons";
 import { formatNumber } from "@/utils/format";
 
 export interface ListToolbarAutoRefresh {
@@ -17,11 +18,13 @@ export function ListToolbar({
   error,
   autoRefresh,
   filterToggle,
+  feedHref,
 }: {
   total: number | null;
   error?: string | null;
   autoRefresh: ListToolbarAutoRefresh;
   filterToggle?: { open: boolean; onChange: () => void };
+  feedHref?: string;
 }) {
   const { t } = useTranslation();
   return (
@@ -31,6 +34,17 @@ export function ListToolbar({
       )}
       {error && }
       
+ {feedHref && ( + + + + )} + + + ); +} + export function IconGithub(props: IconProps) { return ( { }); }); + it("links the RSS feed when feeds are enabled", async () => { + mockAdvertsApi(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("feed-link")).toHaveAttribute( + "href", + "/feeds/adverts.xml", + ); + }); + }); + + it("omits the RSS feed link when feeds are disabled", async () => { + mockAdvertsApi(); + renderWithProviders(, { + config: makeConfig({ features: { feeds: false } }), + }); + await waitFor(() => { + expect(screen.getAllByText("AdNode").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument(); + }); + it("renders an empty state when no adverts exist", async () => { vi.spyOn(api, "apiGet").mockImplementation(async (path) => { if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 }; diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx index b165934..dfa52f4 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx @@ -243,6 +243,7 @@ export function Advertisements() { error={error} autoRefresh={{ paused, onToggle: toggle, intervalSeconds }} filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }} + feedHref={features.feeds !== false ? "/feeds/adverts.xml" : undefined} /> {filterOpen && ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx index 509b0f8..7770ee6 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { Messages } from "@/pages/Messages"; import { renderWithProviders } from "@/test/renderWithProviders"; +import { makeConfig } from "@/test/makeConfig"; import * as api from "@/utils/api"; const MESSAGES = { @@ -27,6 +28,23 @@ function mockMessagesApi() { }); } +const CHANNELS = { + items: [ + { channel_hash: "05", name: "CommunityChan", visibility: "community", enabled: true }, + { channel_hash: "06", name: "DisabledChan", visibility: "community", enabled: false }, + { channel_hash: "07", name: "MemberChan", visibility: "member", enabled: true }, + ], +}; + +function mockMessagesApiWithChannels() { + vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path.includes("/api/v1/messages")) return MESSAGES; + if (path.includes("/api/v1/nodes")) return { items: [], total: 0 }; + if (path.includes("/api/v1/channels")) return CHANNELS; + throw new Error(`Unexpected: ${path}`); + }); +} + describe("Messages", () => { it("shows a loading spinner before data resolves", () => { vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {})); @@ -65,4 +83,55 @@ describe("Messages", () => { expect(screen.queryByText("Hello world")).not.toBeInTheDocument(); }); }); + + it("defaults the feed link to the public channel", async () => { + mockMessagesApi(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("feed-link")).toHaveAttribute( + "href", + "/feeds/channels/17.xml", + ); + }); + }); + + it("points the feed link at the selected feedable channel", async () => { + mockMessagesApiWithChannels(); + renderWithProviders(, { route: "/messages?channel_idx=5" }); + await waitFor(() => { + expect(screen.getByTestId("feed-link")).toHaveAttribute( + "href", + "/feeds/channels/5.xml", + ); + }); + }); + + it("hides the feed link when a disabled channel is selected", async () => { + mockMessagesApiWithChannels(); + renderWithProviders(, { route: "/messages?channel_idx=6" }); + await waitFor(() => { + expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument(); + }); + + it("hides the feed link when a member channel is selected", async () => { + mockMessagesApiWithChannels(); + renderWithProviders(, { route: "/messages?channel_idx=7" }); + await waitFor(() => { + expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument(); + }); + + it("hides the feed link when feeds are disabled", async () => { + mockMessagesApi(); + renderWithProviders(, { + config: makeConfig({ features: { feeds: false } }), + }); + await waitFor(() => { + expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument(); + }); }); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx index 0d40e7b..9969c98 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx @@ -73,6 +73,8 @@ interface NodeItem { interface ChannelItem { channel_hash: string; name: string; + visibility?: string; + enabled?: boolean; } interface ListResponse { @@ -80,6 +82,8 @@ interface ListResponse { total?: number; } +const BUILTIN_PUBLIC_CHANNEL_IDX = 17; + export function Messages() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -150,6 +154,15 @@ export function Messages() { ); const channelLabels = new Map([...builtin, ...custom]); + // Channels that can serve a public per-channel feed (community + + // enabled, plus the built-in public channel idx 17). + const feedableChannelIdxs = new Set([BUILTIN_PUBLIC_CHANNEL_IDX]); + for (const ch of channelsData.items ?? []) { + if (ch.visibility !== "community" || ch.enabled === false) continue; + const idx = parseInt(ch.channel_hash, 16); + if (Number.isInteger(idx)) feedableChannelIdxs.add(idx); + } + const areaMap = new Map(); for (const n of nodesData.items ?? []) { const area = n.tags?.find((tg) => tg.key === "area")?.value; @@ -192,6 +205,7 @@ export function Messages() { builtinLabels: builtin, customLabels: custom, channelLabels, + feedableChannelIdxs, }; }, }); @@ -203,6 +217,16 @@ export function Messages() { const builtinLabels = data?.builtinLabels ?? new Map(); const customLabels = data?.customLabels ?? new Map(); const channelLabels = data?.channelLabels ?? new Map(); + const feedableChannelIdxs = + data?.feedableChannelIdxs ?? new Set([BUILTIN_PUBLIC_CHANNEL_IDX]); + const feedIdx = + channelIdx !== "" ? channelIdx : String(BUILTIN_PUBLIC_CHANNEL_IDX); + const feedable = + channelIdx === "" || feedableChannelIdxs.has(parseInt(channelIdx, 10)); + const feedHref = + features.feeds !== false && feedable + ? `/feeds/channels/${feedIdx}.xml` + : undefined; const handleObserverToggle = (area: string) => { const updated = toggleObserverArea(area, sortedAreas.length); @@ -286,6 +310,7 @@ export function Messages() { error={error} autoRefresh={{ paused, onToggle: toggle, intervalSeconds }} filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }} + feedHref={feedHref} /> {filterOpen && ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx index b714474..9680f4c 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx @@ -69,6 +69,28 @@ describe("Nodes", () => { }); }); + it("links the RSS feed when feeds are enabled", async () => { + mockNodesApi(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId("feed-link")).toHaveAttribute( + "href", + "/feeds/nodes.xml", + ); + }); + }); + + it("omits the RSS feed link when feeds are disabled", async () => { + mockNodesApi(); + renderWithProviders(, { + config: makeConfig({ features: { feeds: false } }), + }); + await waitFor(() => { + expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument(); + }); + it("renders without error when OIDC is enabled", async () => { mockNodesApi(); renderWithProviders(, { diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx index f68bb9e..b00e473 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx @@ -260,6 +260,9 @@ export function Nodes() { open: filterOpen, onChange: () => setFilterOpen((open) => !open), }} + feedHref={ + (config.features ?? {}).feeds !== false ? "/feeds/nodes.xml" : undefined + } /> {filterOpen && ( diff --git a/tests/test_api/test_feeds.py b/tests/test_api/test_feeds.py index 5c669a1..3753ad8 100644 --- a/tests/test_api/test_feeds.py +++ b/tests/test_api/test_feeds.py @@ -310,7 +310,24 @@ class TestFeedItemLinks: assert ("aabbccddeeff00112233445566778899", "false") in guids assert any(guid.startswith("msg:") for guid, _ in guids) - def test_advert_and_node_links( + def test_advert_link_prefers_packet_hash( + self, client_no_auth, api_db_session, sample_node + ): + advert = Advertisement( + public_key=sample_node.public_key, + name="HashedNode", + received_at=NOW, + packet_hash="aabbccddeeff00112233445566778899", + ) + api_db_session.add(advert) + api_db_session.commit() + response = client_no_auth.get("/api/v1/feeds/adverts.xml") + root = ET.fromstring(response.text) + assert _text(root, "channel/item/link").endswith( + "/packets/hash/aabbccddeeff00112233445566778899" + ) + + def test_advert_link_falls_back_to_node_without_hash( self, client_no_auth, api_db_session, sample_advertisement, sample_node ): response = client_no_auth.get("/api/v1/feeds/adverts.xml") @@ -319,6 +336,40 @@ class TestFeedItemLinks: f"/nodes/{sample_advertisement.public_key}" ) + def test_advert_description_is_minimal( + self, client_no_auth, api_db_session, sample_node + ): + advert = Advertisement( + public_key=sample_node.public_key, + adv_type="REPEATER", + received_at=NOW, + advert_timestamp=NOW, + ) + api_db_session.add(advert) + api_db_session.commit() + response = client_no_auth.get("/api/v1/feeds/adverts.xml") + root = ET.fromstring(response.text) + assert ( + _text(root, "channel/item/description") + == "REPEATER - 2026-08-30T12:00:00+00:00" + ) + + def test_node_description_is_minimal(self, client_no_auth, api_db_session): + node = Node( + public_key="33" * 32, + adv_type="REPEATER", + created_at=NOW, + last_seen=NOW, + ) + api_db_session.add(node) + api_db_session.commit() + response = client_no_auth.get("/api/v1/feeds/nodes.xml") + root = ET.fromstring(response.text) + assert ( + _text(root, "channel/item/description") + == "REPEATER - 2026-08-30T12:00:00+00:00" + ) + def test_channel_feed_link_uses_channel_idx_query( self, client_no_auth, api_db_session ):