mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-30 19:52:03 +01:00
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:
14 files changed
+309
-9
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user