diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 78aa513..4af7590 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -1603,5 +1603,74 @@ describe("Admin Routes", () => { const cfg = await repo.getConfig(feedId); expect(cfg?.sender_in_title).toBe(false); }); + + it("feed detail shows a native-feeds group when a native feed was detected", async () => { + const authCookie = await loginAndGetCookie(); + const repo = FeedRepository.from(mockEnv as unknown as Env); + const feedId = FeedId.generate(); + const mailboxId = MailboxId.unchecked("native.detail.07"); + const feed = Feed.create( + feedId, + { title: "N", language: "en", allowedSenders: [], blockedSenders: [] }, + { mailboxId }, + ); + feed.ingest( + { key: "k1", subject: "s", receivedAt: 1, size: 10 }, + { + maxBytes: 1e9, + nativeFeeds: { + senderKey: "a@x.com", + feeds: [{ url: "https://blog.example.com/feed.xml", type: "rss" }], + }, + }, + ); + await repo.save(feed); + + const res = await request(`/admin/feeds/${feedId.value}/emails`, { + headers: { Cookie: authCookie }, + }); + const body = await res.text(); + expect(body).toContain("native-feeds"); + expect(body).toContain("https://blog.example.com/feed.xml"); + }); + + it("native-feed dismiss route clears the flag", async () => { + const authCookie = await loginAndGetCookie(); + const repo = FeedRepository.from(mockEnv as unknown as Env); + const feedId = FeedId.generate(); + const mailboxId = MailboxId.unchecked("native.dismiss.09"); + const feed = Feed.create( + feedId, + { title: "N", language: "en", allowedSenders: [], blockedSenders: [] }, + { mailboxId }, + ); + feed.ingest( + { key: "k1", subject: "s", receivedAt: 1, size: 10 }, + { + maxBytes: 1e9, + nativeFeeds: { + senderKey: "a@x.com", + feeds: [{ url: "https://x.com/rss", type: "rss" }], + }, + }, + ); + await repo.save(feed); + + const res = await request( + `/admin/feeds/${feedId.value}/native-feed/dismiss`, + { + method: "POST", + headers: { + Cookie: authCookie, + "Content-Type": "application/json", + Origin: `https://${mockEnv.DOMAIN}`, + }, + }, + ); + expect(res.status).toBe(200); + const reloaded = await repo.load(feedId); + expect(reloaded!.hasNativeFeed()).toBe(false); + expect(reloaded!.nativeFeeds()).toHaveLength(1); // URLs preserved + }); }); }); diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index 613cc3a..40c7897 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -8,7 +8,9 @@ import { CheckIcon, FeedFormats, ExpiryBadge, + NativeFeeds, } from "./ui"; +import { unionNativeFeeds } from "../../domain/native-feed"; import { deleteAttachmentsForEmails, deleteKeysWithConcurrency, @@ -140,6 +142,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { return c.text("Feed not found", 404); } + const nativeFeeds = unionNativeFeeds(feedMetadata.nativeFeeds); const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env); return c.html( @@ -164,6 +167,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { )} + {feedMetadata.pendingConfirmation && ( @@ -185,6 +189,28 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { )} + {nativeFeeds.length > 0 && !feedMetadata.nativeFeedDismissed && ( +
+ + This newsletter publishes its own feed — subscribe to it directly + from "Native feeds" above. + +
+ +
+
+ )} +

Emails ( {feedMetadata.emails.length}) @@ -733,6 +759,32 @@ emailsRouter.post("/feeds/:feedId/confirmation/dismiss", async (c) => { : c.redirect(`/admin/feeds/${feedId}/emails`); }); +// ── Dismiss native-feed notice ─────────────────────────────────────────────── + +emailsRouter.post("/feeds/:feedId/native-feed/dismiss", async (c) => { + const env = c.env; + const repo = FeedRepository.from(env); + const feedId = c.req.param("feedId"); + const wantsJson = ( + c.req.header("Accept") || + c.req.header("Content-Type") || + "" + ).includes("application/json"); + + const feed = await repo.load(FeedId.unchecked(feedId)); + if (!feed) { + return wantsJson + ? c.json({ ok: false, error: "Feed not found" }, 404) + : c.text("Feed not found", 404); + } + feed.dismissNativeFeed(); + await repo.saveMetadata(feed); + + return wantsJson + ? c.json({ ok: true }) + : c.redirect(`/admin/feeds/${feedId}/emails`); +}); + // ── Bulk delete emails ──────────────────────────────────────────────────────── emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => { diff --git a/src/scripts/client/emails-page.ts b/src/scripts/client/emails-page.ts index 5167ca1..449a295 100644 --- a/src/scripts/client/emails-page.ts +++ b/src/scripts/client/emails-page.ts @@ -634,3 +634,22 @@ if (dismissBtn && banner) { .catch(() => {}); }); } + +// ── Native-feed banner dismiss ──────────────────────────────────────────────── + +const nativeDismissBtn = document.getElementById("native-feed-dismiss"); +const nativeBanner = document.getElementById("native-feed-banner"); +if (nativeDismissBtn && nativeBanner) { + nativeDismissBtn.addEventListener("click", () => { + const feedId = nativeBanner.getAttribute("data-feed-id") ?? ""; + fetch(`/admin/feeds/${feedId}/native-feed/dismiss`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + .then((r) => r.json()) + .then((d) => { + if ((d as { ok?: boolean }).ok) nativeBanner.remove(); + }) + .catch(() => {}); + }); +}