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
This commit is contained in:
Louis King committed 2026-08-31 10:22:52 +01:00
1 parent f5ae082adb
commit 8fb4b4705b
14 files changed
+309 -9

No files matched your search

+16
View File
@@ -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 <pre> and HTML-escapes tags; innerText gives
// us the unescaped RSS XML.
const content = await page.locator("pre").innerText();
expect(content).toContain("<rss");
expect(content).toContain("Alpha Node");
expect(content).toContain("/packets/hash/");
});
test("auto-refresh works and can be paused", async ({ page }) => {
await page.goto("/advertisements");
await expectListLoaded(page);
+31
View File
@@ -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);
+15
View File
@@ -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 <pre> and HTML-escapes tags; innerText gives
// us the unescaped RSS XML.
const content = await page.locator("pre").innerText();
expect(content).toContain("<rss");
expect(content).toContain("Alpha Node");
});
test("auto-refresh works and can be paused", async ({ page }) => {
await page.goto("/nodes");
await expectListLoaded(page);
+7 -8
View File
@@ -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,
@@ -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(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument();
rerender(
<ListToolbar
total={null}
autoRefresh={autoRefresh}
feedHref="/feeds/nodes.xml"
/>,
);
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(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
@@ -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 && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
{feedHref && (
<a
href={feedHref}
title="RSS"
aria-label="RSS"
data-testid="feed-link"
className="opacity-70 hover:opacity-100 transition-opacity"
>
<IconRss className="h-4 w-4" />
</a>
)}
<AutoRefreshToggle
paused={autoRefresh.paused}
onToggle={autoRefresh.onToggle}
@@ -516,6 +516,19 @@ export function IconClock(props: IconProps) {
);
}
export function IconRss(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-9 1a1 1 0 100-2 1 1 0 000 2z"
/>
</svg>
);
}
export function IconGithub(props: IconProps) {
return (
<svg
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { Advertisements } from "@/pages/Advertisements";
import { renderWithProviders } from "@/test/renderWithProviders";
import { makeConfig } from "@/test/makeConfig";
import * as api from "@/utils/api";
const ADVERTS = {
@@ -55,6 +56,28 @@ describe("Advertisements", () => {
});
});
it("links the RSS feed when feeds are enabled", async () => {
mockAdvertsApi();
renderWithProviders(<Advertisements />);
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(<Advertisements />, {
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 };
@@ -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 && (
@@ -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(<Messages />);
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(<Messages />, { 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(<Messages />, { 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(<Messages />, { 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(<Messages />, {
config: makeConfig({ features: { feeds: false } }),
});
await waitFor(() => {
expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1);
});
expect(screen.queryByTestId("feed-link")).not.toBeInTheDocument();
});
});
@@ -73,6 +73,8 @@ interface NodeItem {
interface ChannelItem {
channel_hash: string;
name: string;
visibility?: string;
enabled?: boolean;
}
interface ListResponse<T> {
@@ -80,6 +82,8 @@ interface ListResponse<T> {
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<number>([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<string, string[]>();
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<number, string>();
const customLabels = data?.customLabels ?? new Map<number, string>();
const channelLabels = data?.channelLabels ?? new Map<number, string>();
const feedableChannelIdxs =
data?.feedableChannelIdxs ?? new Set<number>([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 && (
@@ -69,6 +69,28 @@ describe("Nodes", () => {
});
});
it("links the RSS feed when feeds are enabled", async () => {
mockNodesApi();
renderWithProviders(<Nodes />);
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(<Nodes />, {
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(<Nodes />, {
@@ -260,6 +260,9 @@ export function Nodes() {
open: filterOpen,
onChange: () => setFilterOpen((open) => !open),
}}
feedHref={
(config.features ?? {}).feeds !== false ? "/feeds/nodes.xml" : undefined
}
/>
{filterOpen && (
+52 -1
View File
@@ -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
):