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);
|
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 }) => {
|
test("auto-refresh works and can be paused", async ({ page }) => {
|
||||||
await page.goto("/advertisements");
|
await page.goto("/advertisements");
|
||||||
await expectListLoaded(page);
|
await expectListLoaded(page);
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ test.describe("messages", () => {
|
|||||||
await expect(page.getByTestId("list-row")).toHaveCount(4);
|
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 }) => {
|
test("auto-refresh works and can be paused", async ({ page }) => {
|
||||||
await page.goto("/messages");
|
await page.goto("/messages");
|
||||||
await expectListLoaded(page);
|
await expectListLoaded(page);
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ test.describe("nodes", () => {
|
|||||||
await expect(table.getByText("Bravo Node").first()).toBeVisible();
|
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 }) => {
|
test("auto-refresh works and can be paused", async ({ page }) => {
|
||||||
await page.goto("/nodes");
|
await page.goto("/nodes");
|
||||||
await expectListLoaded(page);
|
await expectListLoaded(page);
|
||||||
|
|||||||
@@ -298,16 +298,18 @@ def _advert_items(
|
|||||||
node = nodes_by_id.get(advert.node_id) if advert.node_id else None
|
node = nodes_by_id.get(advert.node_id) if advert.node_id else None
|
||||||
title = _node_display_name(advert.name, node, advert.public_key)
|
title = _node_display_name(advert.name, node, advert.public_key)
|
||||||
description = (
|
description = (
|
||||||
f"{advert.adv_type or 'node'} advertisement; "
|
f"{advert.adv_type or 'node'} - {_format_ts(advert.advert_timestamp)}"
|
||||||
f"advertised at {_format_ts(advert.advert_timestamp)}, "
|
|
||||||
f"received {_format_ts(advert.received_at)}"
|
|
||||||
)
|
)
|
||||||
|
if advert.packet_hash:
|
||||||
|
link = f"{base}/packets/hash/{advert.packet_hash}"
|
||||||
|
else:
|
||||||
|
link = f"{base}/nodes/{advert.public_key}"
|
||||||
items.append(
|
items.append(
|
||||||
FeedItem(
|
FeedItem(
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
guid=f"advert:{advert.id}",
|
guid=f"advert:{advert.id}",
|
||||||
link=f"{base}/nodes/{advert.public_key}",
|
link=link,
|
||||||
pub_date=advert.received_at,
|
pub_date=advert.received_at,
|
||||||
guid_is_permalink=False,
|
guid_is_permalink=False,
|
||||||
)
|
)
|
||||||
@@ -325,10 +327,7 @@ def _node_items(nodes: list[Node], base: str) -> list[FeedItem]:
|
|||||||
items: list[FeedItem] = []
|
items: list[FeedItem] = []
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
title = _node_display_name(None, node, node.public_key)
|
title = _node_display_name(None, node, node.public_key)
|
||||||
description = (
|
description = f"{node.adv_type or 'node'} - {_format_ts(node.last_seen)}"
|
||||||
f"{node.adv_type or 'node'} joined the mesh; "
|
|
||||||
f"last seen {_format_ts(node.last_seen)}"
|
|
||||||
)
|
|
||||||
items.append(
|
items.append(
|
||||||
FeedItem(
|
FeedItem(
|
||||||
title=title,
|
title=title,
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ describe("ListToolbar", () => {
|
|||||||
expect(container.querySelector('input[type="checkbox"]')).toBeNull();
|
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", () => {
|
it("renders the filter toggle only when provided", () => {
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<ListToolbar total={null} autoRefresh={autoRefresh} />,
|
<ListToolbar total={null} autoRefresh={autoRefresh} />,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { WarningBadge } from "@/components/Alerts";
|
|||||||
import { AutoRefreshToggle } from "@/components/AutoRefreshToggle";
|
import { AutoRefreshToggle } from "@/components/AutoRefreshToggle";
|
||||||
import { CountBadge } from "@/components/Badges";
|
import { CountBadge } from "@/components/Badges";
|
||||||
import { FilterToggle } from "@/components/FilterForm";
|
import { FilterToggle } from "@/components/FilterForm";
|
||||||
|
import { IconRss } from "@/components/icons";
|
||||||
import { formatNumber } from "@/utils/format";
|
import { formatNumber } from "@/utils/format";
|
||||||
|
|
||||||
export interface ListToolbarAutoRefresh {
|
export interface ListToolbarAutoRefresh {
|
||||||
@@ -17,11 +18,13 @@ export function ListToolbar({
|
|||||||
error,
|
error,
|
||||||
autoRefresh,
|
autoRefresh,
|
||||||
filterToggle,
|
filterToggle,
|
||||||
|
feedHref,
|
||||||
}: {
|
}: {
|
||||||
total: number | null;
|
total: number | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
autoRefresh: ListToolbarAutoRefresh;
|
autoRefresh: ListToolbarAutoRefresh;
|
||||||
filterToggle?: { open: boolean; onChange: () => void };
|
filterToggle?: { open: boolean; onChange: () => void };
|
||||||
|
feedHref?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +34,17 @@ export function ListToolbar({
|
|||||||
)}
|
)}
|
||||||
{error && <WarningBadge message={error} />}
|
{error && <WarningBadge message={error} />}
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<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
|
<AutoRefreshToggle
|
||||||
paused={autoRefresh.paused}
|
paused={autoRefresh.paused}
|
||||||
onToggle={autoRefresh.onToggle}
|
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) {
|
export function IconGithub(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { Advertisements } from "@/pages/Advertisements";
|
import { Advertisements } from "@/pages/Advertisements";
|
||||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||||
|
import { makeConfig } from "@/test/makeConfig";
|
||||||
import * as api from "@/utils/api";
|
import * as api from "@/utils/api";
|
||||||
|
|
||||||
const ADVERTS = {
|
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 () => {
|
it("renders an empty state when no adverts exist", async () => {
|
||||||
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
|
||||||
if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 };
|
if (path.includes("/api/v1/advertisements")) return { items: [], total: 0 };
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export function Advertisements() {
|
|||||||
error={error}
|
error={error}
|
||||||
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
|
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
|
||||||
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
|
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
|
||||||
|
feedHref={features.feeds !== false ? "/feeds/adverts.xml" : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filterOpen && (
|
{filterOpen && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { Messages } from "@/pages/Messages";
|
import { Messages } from "@/pages/Messages";
|
||||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||||
|
import { makeConfig } from "@/test/makeConfig";
|
||||||
import * as api from "@/utils/api";
|
import * as api from "@/utils/api";
|
||||||
|
|
||||||
const MESSAGES = {
|
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", () => {
|
describe("Messages", () => {
|
||||||
it("shows a loading spinner before data resolves", () => {
|
it("shows a loading spinner before data resolves", () => {
|
||||||
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
vi.spyOn(api, "apiGet").mockReturnValue(new Promise(() => {}));
|
||||||
@@ -65,4 +83,55 @@ describe("Messages", () => {
|
|||||||
expect(screen.queryByText("Hello world")).not.toBeInTheDocument();
|
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 {
|
interface ChannelItem {
|
||||||
channel_hash: string;
|
channel_hash: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
visibility?: string;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListResponse<T> {
|
interface ListResponse<T> {
|
||||||
@@ -80,6 +82,8 @@ interface ListResponse<T> {
|
|||||||
total?: number;
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUILTIN_PUBLIC_CHANNEL_IDX = 17;
|
||||||
|
|
||||||
export function Messages() {
|
export function Messages() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -150,6 +154,15 @@ export function Messages() {
|
|||||||
);
|
);
|
||||||
const channelLabels = new Map([...builtin, ...custom]);
|
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[]>();
|
const areaMap = new Map<string, string[]>();
|
||||||
for (const n of nodesData.items ?? []) {
|
for (const n of nodesData.items ?? []) {
|
||||||
const area = n.tags?.find((tg) => tg.key === "area")?.value;
|
const area = n.tags?.find((tg) => tg.key === "area")?.value;
|
||||||
@@ -192,6 +205,7 @@ export function Messages() {
|
|||||||
builtinLabels: builtin,
|
builtinLabels: builtin,
|
||||||
customLabels: custom,
|
customLabels: custom,
|
||||||
channelLabels,
|
channelLabels,
|
||||||
|
feedableChannelIdxs,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -203,6 +217,16 @@ export function Messages() {
|
|||||||
const builtinLabels = data?.builtinLabels ?? new Map<number, string>();
|
const builtinLabels = data?.builtinLabels ?? new Map<number, string>();
|
||||||
const customLabels = data?.customLabels ?? new Map<number, string>();
|
const customLabels = data?.customLabels ?? new Map<number, string>();
|
||||||
const channelLabels = data?.channelLabels ?? 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 handleObserverToggle = (area: string) => {
|
||||||
const updated = toggleObserverArea(area, sortedAreas.length);
|
const updated = toggleObserverArea(area, sortedAreas.length);
|
||||||
@@ -286,6 +310,7 @@ export function Messages() {
|
|||||||
error={error}
|
error={error}
|
||||||
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
|
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
|
||||||
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
|
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
|
||||||
|
feedHref={feedHref}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filterOpen && (
|
{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 () => {
|
it("renders without error when OIDC is enabled", async () => {
|
||||||
mockNodesApi();
|
mockNodesApi();
|
||||||
renderWithProviders(<Nodes />, {
|
renderWithProviders(<Nodes />, {
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export function Nodes() {
|
|||||||
open: filterOpen,
|
open: filterOpen,
|
||||||
onChange: () => setFilterOpen((open) => !open),
|
onChange: () => setFilterOpen((open) => !open),
|
||||||
}}
|
}}
|
||||||
|
feedHref={
|
||||||
|
(config.features ?? {}).feeds !== false ? "/feeds/nodes.xml" : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filterOpen && (
|
{filterOpen && (
|
||||||
|
|||||||
@@ -310,7 +310,24 @@ class TestFeedItemLinks:
|
|||||||
assert ("aabbccddeeff00112233445566778899", "false") in guids
|
assert ("aabbccddeeff00112233445566778899", "false") in guids
|
||||||
assert any(guid.startswith("msg:") for guid, _ 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
|
self, client_no_auth, api_db_session, sample_advertisement, sample_node
|
||||||
):
|
):
|
||||||
response = client_no_auth.get("/api/v1/feeds/adverts.xml")
|
response = client_no_auth.get("/api/v1/feeds/adverts.xml")
|
||||||
@@ -319,6 +336,40 @@ class TestFeedItemLinks:
|
|||||||
f"/nodes/{sample_advertisement.public_key}"
|
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(
|
def test_channel_feed_link_uses_channel_idx_query(
|
||||||
self, client_no_auth, api_db_session
|
self, client_no_auth, api_db_session
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in new issue
Block a user