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 (