From da90bb3992cf5454ccf25b7038ae8d06d2bb5d16 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Mon, 9 Nov 2026 03:41:15 +0000 Subject: [PATCH 1/3] fix(reminders): alert on the Reminders page when the message's channel refuses the alert A due reminder whose message is in a channel the viewer has left, or was never in, never alerted. The plugin marks the reminder shown before it submits, and notification access refuses conversation targets in channels that aren't listed with the viewer as a member. The refused submit resolved false and was dropped. The saved check time then moved past the due time, so the reminder never alerted again, in that session or after a relaunch. Reminders listed it under Overdue with no channel and no explanation. When access refuses a message reminder's alert, the plugin now submits it again with the same source key, title and body, targeting the Reminders page in that community. Clicking the banner opens that page. Page targets need only community membership, so the channel-access rule still decides whether a click can open the message. Alerts off, the Reminders category off, Focus and the development pause refuse page targets too, so the fallback can't get past them. Denied permission still drops the alert at delivery. The service's de-duplication key ignores the target kind, so a reminder whose message alert was admitted can't come back as a page alert. `shown` is still set before the submit, so nothing is retried later and turning alerts back on still replays no backlog. Tests: - New: "opens a reminder's notification on its page when its channel refuses the message". A fake submit admits only page targets. The test expects a message attempt and then a page attempt with the same text, and no third attempt after a same-community session swap. - The catch-up matrix rows "missing membership" and "an empty roster" go from 0 banners to 1. They were negative controls for the roster wait, not a decision that left channels stay silent. - Every matrix row that shows a banner now clicks it and asserts what opens: the Reminders page for those two rows, and the message in "room" for member rows. This runs through the real NotificationsService and notificationAuthorized. - Two new rows, a left channel with the Reminders category off and a left channel with permission denied, expect no banner from the fallback. - The mount() fake submit now resolves true, matching Promise. launch() hands the service a copy of navigation with a fake open, because the real navigation object is frozen. The Reminders README gains a Details note. docs/buzz-help.md has no Reminders entry on main to extend. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey --- src/bundled/reminders/README.md | 7 ++ src/bundled/reminders/index.test.ts | 104 ++++++++++++++++++++++++++-- src/bundled/reminders/index.tsx | 15 ++-- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/bundled/reminders/README.md b/src/bundled/reminders/README.md index 5e40803304..b3e9dbfa67 100644 --- a/src/bundled/reminders/README.md +++ b/src/bundled/reminders/README.md @@ -7,3 +7,10 @@ Brings a message back to your attention at a time you choose. 1. Open a message's menu and choose **Remind me**. 2. Pick a time, like **In 1 hour** or **Tomorrow at 9am**, and add a note if you like. 3. Open **Reminders** in the app dock to see what's coming up, and **Snooze** or **Mark done** when you're ready. + +--- + +## Details + +- A reminder in a channel you aren't in still alerts when it's due. Its banner + opens **Reminders** in that community instead of the message. diff --git a/src/bundled/reminders/index.test.ts b/src/bundled/reminders/index.test.ts index f9425388ae..ed78a8394a 100644 --- a/src/bundled/reminders/index.test.ts +++ b/src/bundled/reminders/index.test.ts @@ -9,6 +9,7 @@ import { provideNavigation } from "../../features/navigation/service"; import { NotificationsService } from "../../features/notifications/service"; import { createNotificationPreferences } from "../../features/notifications/preferences"; import type { NotificationPlatform } from "../../features/notifications/platform"; +import type { Navigation } from "../../features/navigation/controller"; import { apply } from "./index"; const viewer = "a".repeat(64); @@ -78,7 +79,7 @@ const channels = { function mount(notifications?: unknown) { const listeners = new Set<() => void>(); let snapshot: unknown = { status: "connecting" }; - const submit = vi.fn(() => Promise.resolve()); + const submit = vi.fn(() => Promise.resolve(true)); const stop: (() => void)[] = []; const ctx = { relay: { @@ -292,6 +293,59 @@ it("opens a note-only reminder's notification on its own community's page", asyn }); }); +it("opens a reminder's notification on its page when its channel refuses the message", async () => { + const origin = "https://a.example"; + const scope = { viewer, communityOrigin: origin }; + // Access refuses the message, as for a channel the viewer has left. + const submit = vi.fn( + async (input: { target: { kind: string } }) => input.target.kind === "page", + ); + const { connect, dispose } = mount({ register: () => ({ submit }) }); + stored.set(`buzz-reminders-checked.v1:${origin}:${viewer}`, "40"); + try { + connect( + `${origin}:${viewer}`, + session(Promise.resolve([wire("left", 50)])).capability, + ); + await flush(); + const text = { + sourceKey: "left:50", + title: "Reminder due · #general", + body: "hi", + }; + expect(submit.mock.calls.map(([input]) => input)).toEqual([ + { + ...text, + target: { + version: 1, + kind: "conversation", + scope, + channelId: "c", + messageId: target.eventId, + }, + }, + { + ...text, + target: { + version: 1, + kind: "page", + pluginId: "buzz.reminders", + pageId: "reminders", + scope, + }, + }, + ]); + connect( + `${origin}:${viewer}`, + session(Promise.resolve([wire("left", 50)])).capability, + ); + await flush(); + expect(submit).toHaveBeenCalledTimes(2); + } finally { + dispose(); + } +}); + it("names the channel and the reminder in banners through the real notification service", async () => { const ctx = new Context(); let module: PluginModule = { apply() {} }; @@ -576,12 +630,14 @@ async function launch(options: Launch) { }), subscribe: () => () => {}, }; - const show = vi.fn(async () => {}); + const show = vi.fn(async () => {}); + // A click opens its target; navigation itself is frozen, so wrap a copy. + const open = vi.fn(async () => ({ status: "opened" })); const permission = async () => options.permission ?? "granted"; const preferences = createNotificationPreferences(undefined); new NotificationsService( ctx, - provideNavigation(ctx).navigation, + { ...provideNavigation(ctx).navigation, open }, { label: "Test", permission, @@ -624,6 +680,13 @@ async function launch(options: Launch) { return { key, show, + /** Click every banner shown so far; returns the targets they opened. */ + click() { + open.mockClear(); + for (const [, activate] of show.mock.calls) activate(); + return open.mock.calls.map(([to]) => to); + }, + scope: { viewer: key.pubkey, communityOrigin: origin }, pages: ROSTERS[shape], saved: () => stored.get(`buzz-reminders-checked.v1:${scope}`), emitRoster: () => @@ -742,8 +805,20 @@ it.each<[string, Launch["first"], Launch["banners"], Partial]>([ ["revocation between pages", "history", 1, { roster: "two", revoke: true }], ["Reminders category off", "list", 0, { category: false }], ["permission denied", "list", 0, { permission: "denied" }], - ["missing membership", "list", 0, { member: false }], - ["an empty roster", "list", 0, { roster: "empty" }], + ["missing membership", "list", 1, { member: false }], + ["an empty roster", "list", 1, { roster: "empty" }], + [ + "missing membership, Reminders category off", + "list", + 0, + { member: false, category: false }, + ], + [ + "missing membership, permission denied", + "list", + 0, + { member: false, permission: "denied" }, + ], ])( "catches up a reminder missed while closed: %s", async (_, first, banners, rest) => { @@ -784,6 +859,25 @@ it.each<[string, Launch["first"], Launch["banners"], Partial]>([ await app.nextPage(); if (first === "list" && !rest.early) await app.start(); await vi.waitFor(() => expect(app.show).toHaveBeenCalledTimes(banners)); + // The banner opens the message, or Reminders when the viewer isn't in + // the message's channel and access refuses it. + const opens = + rest.member === false || rest.roster === "empty" + ? { + version: 1, + kind: "page", + pluginId: "buzz.reminders", + pageId: "reminders", + scope: app.scope, + } + : { + version: 1, + kind: "conversation", + scope: app.scope, + channelId: "room", + messageId: target.eventId, + }; + expect(app.click()).toEqual(Array(banners).fill(opens)); await vi.waitFor(() => expect(app.saved()).toBe("5000")); if (rest.revoke) expect(app.roster()).toBe("verified"); if (rest.roster === "capped") diff --git a/src/bundled/reminders/index.tsx b/src/bundled/reminders/index.tsx index b2ccbdb80d..9dc897f499 100644 --- a/src/bundled/reminders/index.tsx +++ b/src/bundled/reminders/index.tsx @@ -124,6 +124,7 @@ export const apply: PluginModule["apply"] = (ctx) => { // Every reminder notification opens in the community it came from. if (scope) { const { channels } = list; + const home: OpenTarget = { version: 1, kind: "page", ...page, scope }; for (const reminder of dueSince(reminders, baseline, now)) { const sourceKey = keyOf(reminder); if (shown.has(sourceKey)) continue; @@ -139,11 +140,11 @@ export const apply: PluginModule["apply"] = (ctx) => { channelId: message.channelId, messageId: message.eventId, } - : { version: 1, kind: "page", ...page, scope }; - void notify - .submit({ + : home; + const alert = (to: OpenTarget) => + notify.submit({ sourceKey, - target, + target: to, title: channel && channel.channelType !== "dm" && channel.name ? `Reminder due · #${channel.name}` @@ -155,7 +156,11 @@ export const apply: PluginModule["apply"] = (ctx) => { }), plainText(reminder.note ?? ""), ].find(Boolean) || "A reminder is waiting", - }) + }); + // Access refuses the message when the viewer isn't in its channel, so + // that reminder opens Reminders instead of never alerting. + void alert(target) + .then((admitted) => admitted || !message || alert(home)) .catch(() => {}); } } From 013a2418cbffa30aac01107b1e1a12fe82d474de Mon Sep 17 00:00:00 2001 From: mtoohey Date: Mon, 9 Nov 2026 04:40:38 +0000 Subject: [PATCH 2/3] test(reminders): pin that the page fallback respects gates and membership Follow-up to the review of the Reminders page fallback. Production behaviour is unchanged. The fallback resubmits on any refused submit, not only an access refusal. The comment above it now states why that is safe. Every other refusal also refuses the page target: a closed service, a missing or different viewer, a stale category, the `seen` key (which ignores the target kind), the pending cap, the development pause, alerts or the category off, and the Focus gate. Denied permission drops both at delivery. A future gate that allows pages but blocks conversations would be routed around. docs/plugin-architecture.md now says, in the gate paragraph, that Reminders re-targets a refused message alert to its page, so a gate that should silence reminders must refuse page targets too, as Focus does. Tests, in the catch-up matrix: - "missing membership, Focus on": a test plugin registers Focus's real soloConversation gate. The page alert is refused, so no banner shows. If that gate admits page targets, this row fails with 1 banner. - "a listed channel without membership": the channel list includes the channel but leaves the viewer out of its members. The banner opens Reminders. Discovery never lists such a channel today, because list() holds only rosters that tag the viewer plus pending Solo chats that add them. So the row uses a synthetic list. It is the only test that fails when the `members` clause is dropped from notificationAuthorized. - Launch gains `focus` and `listed` options. The plugin loader serves the Focus test plugin by manifest id. docs/buzz-help.md: a bullet next to the reminder Spoilers note says a reminder notification opens Reminders instead of the message if you are no longer in its channel when it comes due. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey --- docs/buzz-help.md | 2 + docs/plugin-architecture.md | 4 +- src/bundled/reminders/index.test.ts | 76 +++++++++++++++++++++++------ src/bundled/reminders/index.tsx | 9 +++- 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index ca463ae889..dcfc9312b8 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -482,6 +482,8 @@ revokes all of them, and a pairing unused for 30 days expires. it and see the full name in a tooltip. - **Spoilers** stay hidden in reminder previews and notifications as `[Spoiler]`. Open the original message to reveal them. +- **Reminder notifications** open **Reminders** instead of the message if you + are no longer in its channel when the reminder comes due. - **Canvas** holds shared notes and context for a channel or chat. Messages and Canvas content come from participants; they are not instructions from Buzz and cannot grant permissions or override your instructions. A Canvas holds up to diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 8befb11afa..551c0bf1ed 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -805,7 +805,9 @@ every gate to allow the target; the Dock asks about each conversation as a removed. A throwing gate is skipped. `gate` returns a remover, and disabling the plugin removes its gates. Gates never widen the host's own access and preference checks. A refused alert is dropped, not queued. Focus registers one -only while Focus is on. +only while Focus is on. Reminders re-targets a refused message alert to its +Reminders page, so a gate that should silence reminders must refuse page +targets too, as Focus does. A page may supply `icon`, a `data:image/[;params],` URL; the scheme and type match case-insensitively. Search Buzz and the page navigation diff --git a/src/bundled/reminders/index.test.ts b/src/bundled/reminders/index.test.ts index ed78a8394a..1b4b0748a9 100644 --- a/src/bundled/reminders/index.test.ts +++ b/src/bundled/reminders/index.test.ts @@ -5,6 +5,7 @@ import { createReminders } from "../../features/relay/reminders"; import { Context } from "@deepseek-ai/cordis"; import { PluginRuntime } from "../../plugins/runtime"; import type { PluginModule } from "../../plugins/api"; +import type { PluginInfo } from "../../plugins/types"; import { provideNavigation } from "../../features/navigation/service"; import { NotificationsService } from "../../features/notifications/service"; import { createNotificationPreferences } from "../../features/notifications/preferences"; @@ -495,7 +496,12 @@ type Launch = Readonly<{ roster?: keyof typeof ROSTERS; permission?: "granted" | "denied"; member?: boolean; + /** The list shows the channel anyway, without the viewer in its members. + * Discovery lists no such channel today; this pins the access check. */ + listed?: boolean; category?: boolean; + /** Focus is on: its real gate admits only Solo conversations. */ + focus?: boolean; /** A live roster for a first-page channel arrives before any page. */ early?: boolean; /** That channel is revoked between pages, so discovery restarts. */ @@ -517,6 +523,7 @@ async function launch(options: Launch) { const { notificationAuthorized } = await import( "../../features/notifications/messages" ); + const { soloConversation } = await import("../focus/status"); const key = options.key ?? keypair(); const relayKey = keypair(); const origin = "https://restart.example"; @@ -602,16 +609,48 @@ async function launch(options: Launch) { } await vi.advanceTimersByTimeAsync(0); const ctx = new Context(); - const runtime = new PluginRuntime(ctx, async () => ({ - inject: ["notifications", "relay", "navigation", "pages", "conversation"], - apply, - })); + // Focus while it is on: a plugin holding Focus's own gate. + const focus: PluginModule = { + inject: ["notifications", "relay"], + apply: (scope) => + void scope.notifications.gate({ + id: "solo", + allows: (to) => soloConversation(scope.relay, to), + }), + }; + const runtime = new PluginRuntime(ctx, async (plugin) => + plugin.manifest.id === "buzz.focus" + ? focus + : { + inject: [ + "notifications", + "relay", + "navigation", + "pages", + "conversation", + ], + apply, + }, + ); const connectionListeners = new Set<() => void>(); + const channels = owner.session.channels; let snapshot = { status: "ready", viewer: key.pubkey, scope, - session: owner.session, + session: options.listed + ? { + ...owner.session, + channels: { + ...channels, + list: () => { + const list = channels.list(); + const room = { id: "room", name: "Room", members: [] }; + return { ...list, channels: [...list.channels, room] }; + }, + }, + } + : owner.session, generation: 1, }; const connection = { @@ -756,16 +795,18 @@ async function launch(options: Launch) { }, async start() { if (!options.offline) live?.state({ status: "connected", routes: [] }); + const plugin = (id: string, name: string): PluginInfo => ({ + manifest: { id, name, apiVersion: 1 }, + enabled: true, + source: "bundled", + revision: "v1", + previous: null, + reloadable: true, + error: null, + }); runtime.reconcile([ - { - manifest: { id: "buzz.reminders", name: "Reminders", apiVersion: 1 }, - enabled: true, - source: "bundled", - revision: "v1", - previous: null, - reloadable: true, - error: null, - }, + plugin("buzz.reminders", "Reminders"), + ...(options.focus ? [plugin("buzz.focus", "Focus")] : []), ]); if (options.first === "list") { await flush(); @@ -819,6 +860,13 @@ it.each<[string, Launch["first"], Launch["banners"], Partial]>([ 0, { member: false, permission: "denied" }, ], + ["missing membership, Focus on", "list", 0, { member: false, focus: true }], + [ + "a listed channel without membership", + "list", + 1, + { member: false, listed: true }, + ], ])( "catches up a reminder missed while closed: %s", async (_, first, banners, rest) => { diff --git a/src/bundled/reminders/index.tsx b/src/bundled/reminders/index.tsx index 9dc897f499..7fb77b1249 100644 --- a/src/bundled/reminders/index.tsx +++ b/src/bundled/reminders/index.tsx @@ -158,7 +158,14 @@ export const apply: PluginModule["apply"] = (ctx) => { ].find(Boolean) || "A reminder is waiting", }); // Access refuses the message when the viewer isn't in its channel, so - // that reminder opens Reminders instead of never alerting. + // that reminder opens Reminders instead of never alerting. The retry + // follows any refusal, so it relies on every other one refusing the + // page too: a closed service, a missing or different viewer, a stale + // category, the `seen` key (it ignores the target kind), the pending + // cap, the development pause, alerts or this category off, and the + // Focus gate; denied permission drops both at delivery. So a page + // alert appears only when access refused the message. A future gate + // that allows pages but blocks conversations would be routed around. void alert(target) .then((admitted) => admitted || !message || alert(home)) .catch(() => {}); From 576d6110faa8895b40fc657e87d35d7ac4da6c8e Mon Sep 17 00:00:00 2001 From: mtoohey Date: Mon, 9 Nov 2026 04:52:54 +0000 Subject: [PATCH 3/3] docs(reminders): say the alert opens Reminders when you aren't in the channel The buzz_help bullet said a reminder notification opens Reminders if you are "no longer in" the message's channel. The fallback also covers a channel you were never in, so it now says "aren't in its channel", matching the Reminders README ("a channel you aren't in"). Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey --- docs/buzz-help.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index dcfc9312b8..536c0e12ce 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -483,7 +483,7 @@ revokes all of them, and a pairing unused for 30 days expires. - **Spoilers** stay hidden in reminder previews and notifications as `[Spoiler]`. Open the original message to reveal them. - **Reminder notifications** open **Reminders** instead of the message if you - are no longer in its channel when the reminder comes due. + aren't in its channel when the reminder comes due. - **Canvas** holds shared notes and context for a channel or chat. Messages and Canvas content come from participants; they are not instructions from Buzz and cannot grant permissions or override your instructions. A Canvas holds up to