diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 04df542872..6200258de4 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -478,7 +478,13 @@ revokes all of them, and a pairing unused for 30 days expires. - **Sidebar activity**: rest the pointer on a sidebar row to list its unread mentions of you and its unread threads, mentions first. Open one or mark it read from the list; just looking marks nothing read. -- **Mute** is available from a channel or DM's sidebar context menu. Once +- **Notifications** in a channel's sidebar or header menu chooses what alerts + you: **All new posts** (every new top-level post, plus your usual mentions and + thread replies), **Just mentions** (the default) or **Mute**. All new posts is + saved on this device only and can be turned off for every channel with + **Channel posts** in Settings → Notifications. Mute wins over it. +- **Mute** is available from a channel's **Notifications** submenu or a DM's + sidebar context menu. Once confirmed, a crossed-out bell appears immediately after its name. New messages do not make muted channel or DM names bold. Mute suppresses notifications for messages that do not mention you; mentions can still notify diff --git a/docs/notifications.md b/docs/notifications.md index 43c611146e..0432b04cd0 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -79,8 +79,7 @@ checks, not that an OS banner was displayed or read. - Channel Mute/Unmute uses the session's confirmed, encrypted `channel-mutes` preference, independently of whether Channels is mounted. Muted channels suppress DM and participating-thread alerts; explicit mentions still pass the channel-mute - gate, not global off/category/read/access/permission gates. This does not introduce - a broadcast notification category. Confirmed mute quiets ordinary channel unread + gate, not global off/category/read/access/permission gates. Confirmed mute quiets ordinary channel unread styling and offscreen cues without clearing unread evidence. Muted channel and DM sidebar rows show a crossed-out bell immediately after their names and hide unread dots, including thread dots. Manual unread can still emphasize @@ -93,6 +92,17 @@ checks, not that an OS banner was displayed or read. does not replay cancelled alerts. Existing shown OS banners are not withdrawn. Hosts without preference decoding retain existing notification behavior; this slice adds no native preference adapter or automatic cross-device synchronization. +- A channel's **Notifications** submenu offers All new posts, Just mentions + (default) and Mute; DMs keep the single Mute/Unmute item. Mute is the synced + `channel-mutes` preference above. All new posts is an account-local list in + the existing notification preferences (`allPosts`), so it does not sync across + devices and adds no relay coordinate. In those channels, someone else's + top-level post (unread attention `post: true`) alerts under the `post` + category, **Channel posts**, with its own switch and sound. Replies keep + thread rules, mentions stay mentions, and confirmed mute cancels pending post + candidates; switching back to Just mentions cancels them too. Muting keeps the + device's All new posts choice; leaving Mute applies the picked level only after + unmute is confirmed, so a failed unmute (and its Retry) keeps the earlier choice. - Permission is requested explicitly from Settings where a browser needs a user gesture. A fresh pending candidate is reconsidered after Allow; a newer off choice still wins. Observable API errors are reported, never auto-retried. diff --git a/src/app/App.profile.test.tsx b/src/app/App.profile.test.tsx index 627c88e5ce..fb4606cb1f 100644 --- a/src/app/App.profile.test.tsx +++ b/src/app/App.profile.test.tsx @@ -467,7 +467,12 @@ it.each([ name: "Planning", }); fireEvent.keyDown(row, { key: "ContextMenu" }); - await user.click(await screen.findByRole("menuitem", { name: "Mute" })); + await user.hover( + await screen.findByRole("menuitem", { name: "Notifications" }), + ); + fireEvent.click( + await screen.findByRole("menuitemradio", { name: "Mute" }), + ); await waitFor(() => expect(write).toHaveBeenCalledExactlyOnceWith({ channelId, diff --git a/src/app/App.tsx b/src/app/App.tsx index 33905868d3..bd86926ed7 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -601,7 +601,10 @@ function ConnectedAppContent({ services }: { services: AppServices }) { communities={client.memberships.map((membership) => membership.id)} /> )} - + ( diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx index 09964f0122..b7cd0fa4f5 100644 --- a/src/app/NotificationSettings.test.tsx +++ b/src/app/NotificationSettings.test.tsx @@ -463,6 +463,7 @@ it("selects and restores Silent for every category, stops previews, and can rest direct: "silent", mention: "silent", thread: "silent", + post: "flutter", agent: "flutter", }); expect(play).toHaveBeenCalledOnce(); diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx index 10b29e1a8e..838b21d0e7 100644 --- a/src/app/NotificationSettings.tsx +++ b/src/app/NotificationSettings.tsx @@ -23,6 +23,7 @@ const SOUND_ROWS: readonly NotificationCategory[] = [ "direct", "mention", "thread", + "post", "agent", ]; diff --git a/src/bundled/channels/useOptimisticMute.ts b/src/bundled/channels/useOptimisticMute.ts index c397b745a7..fb2c54e086 100644 --- a/src/bundled/channels/useOptimisticMute.ts +++ b/src/bundled/channels/useOptimisticMute.ts @@ -34,9 +34,11 @@ export function useOptimisticMute( channelId: string, name: string, muted: boolean, + /** Told whether the session confirmed this write. */ + settled?: (saved: boolean) => void, ) { const active = lifetime.current; - if (!active) return; + if (!active) return settled?.(false); const intent: MuteIntent = { channelId, name, muted, pending: true }; setIntents((current) => new Map(current).set(channelId, intent)); const finish = (error?: string) => { @@ -56,8 +58,10 @@ export function useOptimisticMute( // The session owns saving; leaving Messages must not cancel the intent. await active.write(channelId, muted); finish(); + settled?.(true); } catch (error) { finish(error instanceof Error ? error.message : String(error)); + settled?.(false); } })(); }, []); diff --git a/src/features/channel-navigation/ChannelInventory.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx index 371e4d4c4f..a336bce680 100644 --- a/src/features/channel-navigation/ChannelInventory.test.tsx +++ b/src/features/channel-navigation/ChannelInventory.test.tsx @@ -53,6 +53,8 @@ import { ChannelNavigationProvider, useChannelNavigation, } from "./ChannelNavigationState"; +import type { ChannelAlerts } from "./ChannelNavigationState"; +import { DEFAULT_NOTIFICATION_PREFERENCES } from "../notifications/preferences"; import { readView, writeView } from "../../shared/view-state"; import styles from "../../app/shell/PrimarySidebar.module.css"; import { BestieNavigationProvider } from "../../app/shell/BestieNavigation"; @@ -222,9 +224,10 @@ function fixture( ), sidebarVisible = true, + alerts?: ChannelAlerts, ) => ( - + {sidebarVisible && ( void>(); + let snapshot = { preferences }; + return { + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + updatePreferences(patch: Partial) { + preferences = { ...preferences, ...patch }; + snapshot = { preferences }; + act(() => { + for (const listener of listeners) listener(); + }); + return true; + }, + } as unknown as ChannelAlerts; +} + +it("chooses All new posts, Mute and Just mentions for a channel; DMs keep the Mute toggle", async () => { + let muted: string[] = []; + const write = vi.fn( + async ({ channelId, muted: next }: { channelId: string; muted: boolean }) => + (muted = next + ? [...muted, channelId] + : muted.filter((id) => id !== channelId)), + ); + const preferences = createSidebarPreferencesStore({ + read: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted, + }), + available: true, + writeMute: write, + }); + await preferences.queries.ensure(); + const h = fixture(preferences.queries, "ready", [ + { id: "alpha", name: "alpha", channelType: "stream" }, + { id: "dm", name: "Alice", channelType: "dm" }, + ]); + const alerts = alertsFixture(); + const user = userEvent.setup(); + const level = async (name: string) => { + // A muted row's name also carries its crossed-out bell. + fireEvent.keyDown(screen.getByRole("button", { name: /^alpha/ }), { + key: "ContextMenu", + }); + await user.hover( + await screen.findByRole("menuitem", { name: "Notifications" }), + ); + expect( + await screen.findByRole("menu", { name: "Notifications for alpha" }), + ).toHaveTextContent("Notify you about…"); + const checked = screen + .getAllByRole("menuitemradio") + .filter((item) => item.getAttribute("aria-checked") === "true") + .map((item) => item.textContent); + fireEvent.click(screen.getByRole("menuitemradio", { name })); + await waitFor(() => + expect( + screen.queryByRole("menu", { name: "Actions for alpha" }), + ).toBeNull(), + ); + return checked; + }; + try { + render(h.view("alpha", undefined, true, alerts)); + await screen.findByRole("button", { name: "alpha" }); + expect(await level("All new posts")).toEqual(["Just mentions"]); + expect(alerts.snapshot().preferences.allPosts).toEqual(["alpha"]); + expect(write).not.toHaveBeenCalled(); + + expect(await level("Mute")).toEqual(["All new posts"]); + await waitFor(() => + expect(write).toHaveBeenCalledExactlyOnceWith( + { channelId: "alpha", muted: true }, + expect.any(AbortSignal), + ), + ); + // Mute wins in the alert gate; the device keeps the earlier choice. + expect(alerts.snapshot().preferences.allPosts).toEqual(["alpha"]); + + await waitFor(() => + expect(preferences.queries.snapshot().data?.muted).toEqual(["alpha"]), + ); + // A failed unmute stays muted and keeps the device's earlier choice. + write.mockRejectedValueOnce(new Error("offline")); + expect(await level("Just mentions")).toEqual(["Mute"]); + await screen.findByRole("dialog", { name: "Couldn’t unmute alpha" }); + expect(alerts.snapshot().preferences.allPosts).toEqual(["alpha"]); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(await level("Just mentions")).toEqual(["Mute"]); + await waitFor(() => + expect(alerts.snapshot().preferences.allPosts).toEqual([]), + ); + expect(write).toHaveBeenLastCalledWith( + { channelId: "alpha", muted: false }, + expect.any(AbortSignal), + ); + + // Every DM message already alerts, so a DM offers only Mute. + fireEvent.keyDown(screen.getByRole("button", { name: "Alice" }), { + key: "ContextMenu", + }); + expect(await screen.findByRole("menuitem", { name: "Mute" })).toBeVisible(); + expect( + screen.queryByRole("menuitem", { name: "Notifications" }), + ).toBeNull(); + } finally { + preferences.dispose(); + } +}); + it("uses relay section filters across navigation and remount, and offers only hidden channels", async () => { const user = userEvent.setup(); let filters: Record = {}; diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx index 114fb111ca..d8271d4e0c 100644 --- a/src/features/channel-navigation/ChannelInventory.tsx +++ b/src/features/channel-navigation/ChannelInventory.tsx @@ -48,6 +48,7 @@ import { MenuSeparator, } from "../../shared/design-system/ui/Menu"; import type { ChannelSummary } from "../relay/contracts"; +import { MAX_ALL_POSTS_CHANNELS } from "../notifications/preferences"; import { useChannelRowMenu } from "../../bundled/channels/useChannelRowMenu"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { useRecoveryOwns } from "../relay/RecoveryNotice"; @@ -130,6 +131,8 @@ type Inventory = { }; const noSubscription = () => () => {}; +const noPosts: readonly string[] = []; +type AlertLevel = "all" | "mentions" | "mute"; const emptyContributions: readonly RegisteredSidebarSection[] = []; const noSidebarSections = () => emptyContributions; const emptyRowContributions: readonly RegisteredSidebarRows[] = []; @@ -211,6 +214,11 @@ export function ChannelInventory({ }, [list.activityStatus]); const handoff = useChannelNavigation(); const mute = handoff?.session === queries ? handoff.mute : undefined; + const alerts = handoff?.alerts; + const allPosts = useSyncExternalStore( + alerts?.subscribe ?? noSubscription, + () => alerts?.snapshot().preferences.allPosts ?? noPosts, + ); const readAction = useChannelReadAction(); const readWrite = readAction.state; const [rowFocus, setRowFocus] = useState(); @@ -897,11 +905,76 @@ export function ChannelInventory({ const muteable = queries.sidebarPreferences.muteWritable && !!preferences.data; const readable = queries.unread.sync().capability === "frontier-sync"; - if (muteable && mute) { - const intent = mute.intents.get(channel.id); - const muted = intent?.pending - ? intent.muted - : (preferences.data?.muted.includes(channel.id) ?? false); + const intent = mute?.intents.get(channel.id); + const muted = intent?.pending + ? intent.muted + : (preferences.data?.muted.includes(channel.id) ?? false); + // Every DM message already alerts; Solo chats own their completion alerts. + if ( + alerts && + channel.channelType !== "dm" && + channel.channelType !== "solo" + ) { + const level: AlertLevel = muted + ? "mute" + : allPosts.includes(channel.id) + ? "all" + : "mentions"; + // Leaving Mute needs the same confirmed write as entering it. + const writable = muteable && !!mute; + actions.push( + + + + {muted ? : } + + Notifications + + + + Notify you about… + + + changeAlertLevel(channel, level, next, surface) + } + > + = MAX_ALL_POSTS_CHANNELS) + } + > + All new posts + + + Just mentions + + + Mute + + + + , + ); + } else if (muteable && mute) { actions.push( void, ) => { - if (!mute) return; - mute.change(channelId, name, muted); + if (!mute) return settled?.(false); + mute.change(channelId, name, muted, settled); if (surface) { surface.close(); surface.focus(); @@ -1117,6 +1191,44 @@ export function ChannelInventory({ closeRowMenu(); } }; + /** Mute stays the synced sidebar preference; All new posts is local to + * this device's notification preferences, so either half can change alone. */ + const changeAlertLevel = ( + channel: ChannelSummary, + from: AlertLevel, + to: AlertLevel, + surface?: ChannelMenuSurface, + ) => { + if (!alerts || from === to) return; + const choose = () => { + if (to === "mute") return; + const posts = alerts.snapshot().preferences.allPosts; + const listed = posts.includes(channel.id); + if (to === "all" && !listed) + alerts.updatePreferences({ allPosts: [...posts, channel.id] }); + else if (to === "mentions" && listed) + alerts.updatePreferences({ + allPosts: posts.filter((id) => id !== channel.id), + }); + }; + if (to === "mute") changeMute(channel.id, channel.name, true, surface); + // Leaving Mute changes the device's level only once unmute is confirmed, + // so a failed unmute keeps the earlier choice. Its Retry only unmutes. + else if (from === "mute") + changeMute(channel.id, channel.name, false, surface, (saved) => { + if (saved) choose(); + }); + else { + choose(); + if (surface) { + surface.close(); + surface.focus(); + } else { + setRowFocus(channel.id); + closeRowMenu(); + } + } + }; // Publish presentation callbacks, not a second mutation owner. Header renders // these elements under its own MenuRoot; a portal here would lose that context. const menuActions = handoff?.menuActions; diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx index 92cb360c4d..5825852658 100644 --- a/src/features/channel-navigation/ChannelNavigationState.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.tsx @@ -22,6 +22,7 @@ import type { RelayData } from "../relay/service"; import type { RelaySession } from "../relay/session"; import type { ChannelSummary } from "../relay/contracts"; import type { ChannelLifecycleAction } from "../relay/channel-lifecycle-protocol"; +import type { NotificationsService } from "../notifications/service"; /** The session owns writes/recovery; each menu owns its focus and read status. */ export type ChannelMenuSurface = { @@ -99,9 +100,15 @@ type ActivityAgent = { /** The one conversation the agent works in, whose transcript to open. */ transcript?: { rootId?: string }; }; +/** Account-local alert choices, including which channels alert on every post. */ +export type ChannelAlerts = Pick< + NotificationsService, + "snapshot" | "subscribe" | "updatePreferences" +>; type Handoff = State & { menuActions: ReturnType; mute: ReturnType; + alerts: ChannelAlerts | undefined; muteRecovery: RefObject< { focus(channelId: string): void; closeMenu(): void } | undefined >; @@ -133,9 +140,11 @@ export const useChannelNavigation = () => useContext(ChannelNavigationContext); // Session replacement resets this state, not the independent page subtree. export function ChannelNavigationProvider({ relay, + alerts, children, }: { relay: RelayData; + alerts?: ChannelAlerts | undefined; children: ReactNode; }) { const connection = useRelayConnection(relay); @@ -219,6 +228,7 @@ export function ChannelNavigationProvider({ ...state, menuActions, mute, + alerts, muteRecovery, lifecycleCallbacks, soloNeighbors, @@ -310,6 +320,7 @@ export function ChannelNavigationProvider({ state, menuActions, mute, + alerts, connection.session, connection.viewer, update, diff --git a/src/features/notifications/content.test.ts b/src/features/notifications/content.test.ts index 5f8d549e07..60f18065a0 100644 --- a/src/features/notifications/content.test.ts +++ b/src/features/notifications/content.test.ts @@ -64,6 +64,7 @@ const message = { it.each([ ["mention", "Pinky mentioned you in #new-notifications"], ["thread", "Pinky replied in #new-notifications"], + ["post", "Pinky posted in #new-notifications"], ["direct", "Pinky sent you a direct message"], ] as const)( "formats %s with sender, conversation and plain preview", diff --git a/src/features/notifications/content.ts b/src/features/notifications/content.ts index 9b4513b374..72accabe8f 100644 --- a/src/features/notifications/content.ts +++ b/src/features/notifications/content.ts @@ -120,6 +120,8 @@ export function messageNotificationText( ? `${sender} mentioned you in ${destination}` : category === "direct" ? `${sender} sent you a direct message` - : `${sender} replied in ${destination}`; + : category === "post" + ? `${sender} posted in ${destination}` + : `${sender} replied in ${destination}`; return Object.freeze({ title, body: messagePreview(message.previewContent) }); } diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts index ff010ff097..2e05a5d6c5 100644 --- a/src/features/notifications/desktop.test.ts +++ b/src/features/notifications/desktop.test.ts @@ -159,8 +159,9 @@ it("desktop settings expose app-owned sounds and collapse them when alerts are d expect(html).toContain("Direct messages"); expect(html).toContain("@Mentions"); expect(html).toContain("Thread replies"); + expect(html).toContain("Channel posts"); expect(html).toContain("Solo chat completions"); - expect(html.match(/role="combobox"/g)).toHaveLength(4); + expect(html.match(/role="combobox"/g)).toHaveLength(5); expect(html).not.toContain("Preview flutter"); expect(html).not.toContain("Permission granted"); expect(html).toContain("Check permission"); diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts index 6b69037ad6..128306b778 100644 --- a/src/features/notifications/messages.test.ts +++ b/src/features/notifications/messages.test.ts @@ -864,6 +864,58 @@ it.each(["direct", "thread"] as const)( }, ); +it("All new posts alerts top-level posts in that channel only; mute and Just mentions quiet them", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000); + const h = await setup(Promise.resolve(), undefined, undefined, { + decode: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + }), + write: async ({ muted }) => (muted ? ["room"] : []), + }); + await h.owner.session.sidebarPreferences.ensure(); + const post = (text: string) => message(h.peer, "room", text, 1_780_000_000); + // Just mentions (the default): an ordinary post stays quiet. The mention is + // a presentation barrier behind it. + h.emit([post("default quiet")], "live"); + h.emit([h.make("first mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.show.mock.calls[0]?.[0].body).toBe("first mention"); + + h.notifications.updatePreferences({ allPosts: ["room"] }); + const root = post("audible post"); + h.emit([root], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(2)); + expect(h.show.mock.calls[1]?.[0]).toMatchObject({ + title: expect.stringMatching(/ posted in #/), + body: "audible post", + }); + // Replies keep thread rules; the viewer is not in this conversation. + h.emit( + [ + message(h.peer, "room", "other thread", 1_780_000_000, [ + ["e", root.id, "", "reply"], + ]), + ], + "live", + ); + // Mute wins over a remembered All new posts choice. + await h.owner.session.sidebarPreferences.setMute("room", true); + h.emit([post("muted post")], "live"); + h.emit([h.make("second mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(3)); + expect(h.show.mock.calls[2]?.[0].body).toBe("second mention"); + await h.owner.session.sidebarPreferences.setMute("room", false); + // Switching back to Just mentions quiets posts again. + h.notifications.updatePreferences({ allPosts: [] }); + h.emit([post("quiet again")], "live"); + h.emit([h.make("third mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(4)); + expect(h.show.mock.calls[3]?.[0].body).toBe("third mention"); +}); + it("a confirmed mute cancels an alert waiting on permission, even after unmute", async () => { vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000); const h = await setup(Promise.resolve(), undefined, undefined, { diff --git a/src/features/notifications/messages.ts b/src/features/notifications/messages.ts index 433769cf50..b47c21f8d6 100644 --- a/src/features/notifications/messages.ts +++ b/src/features/notifications/messages.ts @@ -15,6 +15,7 @@ const labels = { mention: "Mentions", direct: "Direct messages", thread: "Thread replies", + post: "Channel posts", }; /** A local Solo run owns its completion feedback; its reply is not a mention alert. */ export function localSoloAgentReply( @@ -133,6 +134,8 @@ export function bindMessageNotifications( !closed && generation === current && communities.relay.snapshot().session === owned; + const allPosts = (channelId: string) => + notifications.snapshot().preferences.allPosts.includes(channelId); const receive: IncomingListener = (messages) => { for (const message of messages) { if (!valid()) return; @@ -144,9 +147,15 @@ export function bindMessageNotifications( message.messageId, ); // A reply whose conversation lookup is pending may become thread - // attention; admission waits until the lookup decides it. + // attention; admission waits until the lookup decides it. A plain + // top-level post alerts only in a channel set to All new posts. const category = - attention.category ?? (attention.pending ? "thread" : undefined); + attention.category ?? + (attention.pending + ? "thread" + : attention.post && allPosts(message.channelId) + ? "post" + : undefined); const soloReply = category === "mention" ? localSoloAgentReply( @@ -157,7 +166,9 @@ export function bindMessageNotifications( : undefined; if ( !category || - (attention.status !== "eligible" && !attention.pending) || + (attention.status !== "eligible" && + !attention.pending && + category !== "post") || soloReply === "reply" ) continue; @@ -209,9 +220,14 @@ export function bindMessageNotifications( } const whileViewing = notifications.snapshot().preferences.notifyWhileViewing; + const kind = + attention.category ?? + (attention.post && allPosts(message.channelId) + ? "post" + : undefined); if ( - attention.status === "ineligible" || - (!attention.pending && attention.category !== category) || + (attention.status === "ineligible" && kind !== "post") || + (!attention.pending && kind !== category) || (!whileViewing && attention.viewing) ) return false; diff --git a/src/features/notifications/preferences.test.ts b/src/features/notifications/preferences.test.ts index d9f2480bac..feace2dcfd 100644 --- a/src/features/notifications/preferences.test.ts +++ b/src/features/notifications/preferences.test.ts @@ -18,11 +18,18 @@ it("values saved before per-category sounds restore with the defaults", () => { }); it.each([ - { mention: "ping", direct: "unison", thread: "doop", agent: "doodone" }, + { + mention: "ping", + direct: "unison", + thread: "doop", + post: "soft", + agent: "doodone", + }, { mention: "silent", direct: "silent", thread: "silent", + post: "silent", agent: "silent", }, ])( @@ -40,3 +47,16 @@ it.each([ ).toThrow("Invalid notification sounds"); }, ); + +it("channels set to All new posts round-trip; older values restore none", () => { + expect(parsePreferences(stored).allPosts).toEqual([]); + expect( + parsePreferences( + JSON.parse(JSON.stringify({ ...stored, allPosts: ["c1", "c2"] })), + ).allPosts, + ).toEqual(["c1", "c2"]); + for (const allPosts of ["c1", [""], ["c1", "c1"], [7]]) + expect(() => parsePreferences({ ...stored, allPosts })).toThrow( + "Invalid notification channels", + ); +}); diff --git a/src/features/notifications/preferences.ts b/src/features/notifications/preferences.ts index eafb924f3b..8bb9117fd2 100644 --- a/src/features/notifications/preferences.ts +++ b/src/features/notifications/preferences.ts @@ -10,6 +10,7 @@ export const NOTIFICATION_CATEGORIES = [ "mention", "direct", "thread", + "post", "agent", ] as const; export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]; @@ -19,6 +20,8 @@ export type NotificationPreferences = Readonly<{ sound: boolean; sounds: CategorySounds; categories: Readonly>; + /** Channels whose every new top-level post alerts on this device. */ + allPosts: readonly string[]; }>; export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = Object.freeze({ @@ -30,8 +33,10 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = mention: true, direct: true, thread: true, + post: true, agent: true, }), + allPosts: Object.freeze([]), }); const KEY = "buzz-notification-preferences.v1"; // Values saved before per-category sounds existed omit `sounds`; they get the @@ -52,6 +57,20 @@ function parseSounds(raw: unknown): CategorySounds { } return Object.freeze(sounds); } +/** The same budget as channel mutes. */ +export const MAX_ALL_POSTS_CHANNELS = 500; +// Values saved before per-channel post alerts existed omit `allPosts`. +function parseAllPosts(raw: unknown): readonly string[] { + if (raw === undefined) return DEFAULT_NOTIFICATION_PREFERENCES.allPosts; + if ( + !Array.isArray(raw) || + raw.length > MAX_ALL_POSTS_CHANNELS || + raw.some((id) => typeof id !== "string" || !id.trim() || id.length > 256) || + new Set(raw).size !== raw.length + ) + throw new Error("Invalid notification channels"); + return Object.freeze([...raw]); +} export function parsePreferences(raw: unknown): NotificationPreferences { if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid notification preferences"); @@ -81,6 +100,7 @@ export function parsePreferences(raw: unknown): NotificationPreferences { sound: value.sound as boolean, sounds: parseSounds(value.sounds), categories: Object.freeze(Object.fromEntries(categories)), + allPosts: parseAllPosts(value.allPosts), }); } export function createNotificationPreferences( diff --git a/src/features/notifications/service.test.ts b/src/features/notifications/service.test.ts index e9e3895c6e..d77033fc18 100644 --- a/src/features/notifications/service.test.ts +++ b/src/features/notifications/service.test.ts @@ -336,6 +336,7 @@ it("plays the selected per-category sound once delivery is accepted", async () = mention: "ping", direct: "unison", thread: "doop", + post: "soft", agent: "doodone", }, }); diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts index 2d894e5de8..f631250cdc 100644 --- a/src/features/notifications/service.ts +++ b/src/features/notifications/service.ts @@ -66,6 +66,7 @@ const categories = Object.freeze([ { key: "mention", label: "Mentions" }, { key: "direct", label: "Direct messages" }, { key: "thread", label: "Thread replies" }, + { key: "post", label: "Channel posts" }, { key: "agent", label: "Solo chat completions" }, ]); const FRESH_MS = 120_000; diff --git a/src/features/notifications/sound.ts b/src/features/notifications/sound.ts index 0c8b0de75e..acf8cb8918 100644 --- a/src/features/notifications/sound.ts +++ b/src/features/notifications/sound.ts @@ -32,6 +32,7 @@ export const DEFAULT_CATEGORY_SOUNDS: CategorySounds = Object.freeze({ mention: DEFAULT_SOUND, direct: DEFAULT_SOUND, thread: DEFAULT_SOUND, + post: DEFAULT_SOUND, agent: DEFAULT_SOUND, }); @@ -40,6 +41,7 @@ export const CATEGORY_SOUND_LABELS: Record = { direct: "Direct messages", mention: "@Mentions", thread: "Thread replies", + post: "Channel posts", agent: "Solo chat completions", }; export const RECOMMENDED_SOUND_BY_CATEGORY: Record< @@ -49,6 +51,7 @@ export const RECOMMENDED_SOUND_BY_CATEGORY: Record< direct: "unison", mention: "ping", thread: "doop", + post: "soft", agent: "doodone", }; diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index 102091c592..78c400a3fb 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -73,6 +73,10 @@ export type MessageAttention = Readonly<{ category?: "mention" | "direct" | "thread"; /** The event mentions the viewer, excluding trusted workflow-owner attribution. */ mentioned?: boolean; + /** Someone else's top-level post in a channel (not a DM). It earns no + * attention category, so its status stays "ineligible"; per-channel + * "All new posts" alerts consume it. */ + post?: boolean; rootId?: string; /** The reply's conversation is still being looked up; it may become thread * attention when the lookup finishes. */ @@ -985,6 +989,7 @@ export function createUnread({ ...(kind ? { category: kind } : {}), ...(pending ? { pending: true } : {}), ...(entry.mentioned ? { mentioned: true } : {}), + ...(!dm && !entry.parentId ? { post: true } : {}), ...(entry.rootId ? { rootId: entry.rootId } : {}), unread: isUnread(entry, state, dm), forced: forcedMessages.get(channelId)?.has(messageId) ?? false, diff --git a/tests/browser/channel-header-menu.spec.mjs b/tests/browser/channel-header-menu.spec.mjs index 696aac94c1..73f823f66f 100644 --- a/tests/browser/channel-header-menu.spec.mjs +++ b/tests/browser/channel-header-menu.spec.mjs @@ -1,6 +1,11 @@ import { checkDurationConsumer } from "./duration-layout.mjs"; import { test, expect } from "./fixture.mjs"; -import { openPage } from "./navigation.mjs"; +import { + expectNotificationLevel, + notificationLevel, + notificationLevels, + openPage, +} from "./navigation.mjs"; import { openChannelDetails } from "./channel-details.mjs"; const channelId = "11111111-1111-4111-8111-111111111111"; @@ -507,13 +512,14 @@ test.describe("sidebar actions in the header", () => { await expect(trigger).toBeFocused(); await trigger.click(); const muted = page.waitForResponse("**/sidebar-mute"); - await menu.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await notificationLevel( + await notificationLevels(page, menu), + "Mute", + ).click(); await muted; await expect(trigger).toBeFocused(); await trigger.click(); - await expect( - menu.getByRole("menuitem", { name: "Unmute", exact: true }), - ).toBeVisible(); + await expectNotificationLevel(page, menu, "Mute"); await menu .getByRole("menuitem", { name: /^Mark as (read|unread)$/ }) .click(); diff --git a/tests/browser/channel-lifecycle.spec.mjs b/tests/browser/channel-lifecycle.spec.mjs index 7740393384..9f012331bd 100644 --- a/tests/browser/channel-lifecycle.spec.mjs +++ b/tests/browser/channel-lifecycle.spec.mjs @@ -60,7 +60,7 @@ test("archive confirmation returns focus on cancel and retains the conversation await page.keyboard.press("Shift+F10"); await expect(menu.getByRole("menuitem")).toHaveText([ "Move channel", - "Mute", + "Notifications", "Mark as unread", "Copy link", "Archive channel", diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index e6f773178e..1e79b53de0 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -105,7 +105,7 @@ test("row menu moves and removes a channel optimistically, retaining keyboard na actions.getByRole("menuitem", { name: "Move channel", exact: true }), ).toBeVisible(); await expect( - actions.getByRole("menuitem", { name: "Mute", exact: true }), + actions.getByRole("menuitem", { name: "Notifications", exact: true }), ).toBeVisible(); await page .getByRole("menuitem", { name: "Move channel", exact: true }) @@ -1112,7 +1112,7 @@ test("Move and Create persist across apps without a sidebar", async ({ exact: true, }); await expect( - actions.getByRole("menuitem", { name: "Mute", exact: true }), + actions.getByRole("menuitem", { name: "Notifications", exact: true }), ).toBeVisible(); await menu .getByRole("menuitem", { name: "Create new…", exact: true }) diff --git a/tests/browser/navigation-mute-read.spec.mjs b/tests/browser/navigation-mute-read.spec.mjs index 27dd745fac..2d24651600 100644 --- a/tests/browser/navigation-mute-read.spec.mjs +++ b/tests/browser/navigation-mute-read.spec.mjs @@ -1,4 +1,9 @@ -import { openPage } from "./navigation.mjs"; +import { + expectNotificationLevel, + notificationLevel, + notificationLevels, + openPage, +} from "./navigation.mjs"; import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; import { holdReadingFocus, readJournal } from "./reading.mjs"; @@ -52,13 +57,13 @@ test("channel menu mute/read persist without selecting the row; failed mute rema // menu offers none beside the others. await expect(menu.getByRole("menuitem")).toHaveText([ "Move channel", - "Mute", + "Notifications", "Mark as read", "Copy link", ]); await expect(menu.getByRole("alert")).toHaveCount(0); await expect(menu.getByRole("separator")).toHaveCount(0); - for (const name of ["Mute", "Mark as read"]) { + for (const name of ["Notifications", "Mark as read"]) { await expect( menu .getByRole("menuitem", { name, exact: true }) @@ -88,7 +93,10 @@ test("channel menu mute/read persist without selecting the row; failed mute rema }); }); try { - await menu.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await notificationLevel( + await notificationLevels(page, menu), + "Mute", + ).click(); await requested; await expect(menu).toHaveCount(0); await expect(beta).toBeFocused(); @@ -100,9 +108,7 @@ test("channel menu mute/read persist without selecting the row; failed mute rema ); // Reopening is usable while the relay is still held; no frozen Saving menu. await page.keyboard.press("Shift+F10"); - await expect( - menu.getByRole("menuitem", { name: "Unmute", exact: true }), - ).toBeEnabled(); + await expectNotificationLevel(page, menu, "Mute"); await expect( menu.getByRole("menuitem", { name: "Mark as read" }), ).toBeEnabled(); @@ -114,9 +120,7 @@ test("channel menu mute/read persist without selecting the row; failed mute rema await expect(failure).toContainText("Relay request failed (502)"); await beta.focus(); await page.keyboard.press("Shift+F10"); - await expect( - menu.getByRole("menuitem", { name: "Mute", exact: true }), - ).toBeEnabled(); + await expectNotificationLevel(page, menu, "Just mentions"); await page.keyboard.press("Escape"); await page.unroute("**/sidebar-mute"); // Session-owned retry UI remains usable while the sidebar is hidden. @@ -143,9 +147,7 @@ test("channel menu mute/read persist without selecting the row; failed mute rema blob: { channels: { beta: { muted: true } } }, }); await page.keyboard.press("ContextMenu"); - await expect( - menu.getByRole("menuitem", { name: "Unmute", exact: true }), - ).toBeVisible(); + await expectNotificationLevel(page, menu, "Mute"); // The explicit cut is the click, not the newest loaded message: capture the // page clock on both sides of it rather than tolerating an arbitrary range. const seconds = () => page.evaluate(() => Math.floor(Date.now() / 1000)); @@ -173,9 +175,7 @@ test("channel menu mute/read persist without selecting the row; failed mute rema await page.reload(); await openPage(page, "Messages"); await beta.click({ button: "right" }); - await expect( - menu.getByRole("menuitem", { name: "Unmute", exact: true }), - ).toBeEnabled(); + await expectNotificationLevel(page, menu, "Mute"); await page.keyboard.press("Escape"); await expect(beta).toBeFocused(); await expect(beta.getByLabel(/^(Muting;|Muted;)/)).toHaveCount(0); @@ -243,14 +243,15 @@ test("channel menu mute/read persist without selecting the row; failed mute rema const unmute = holdResponse(page, "**/sidebar-mute"); await unmute.ready; try { - await menu.getByRole("menuitem", { name: "Unmute", exact: true }).click(); + await notificationLevel( + await notificationLevels(page, menu), + "Just mentions", + ).click(); await unmute.started; await expect(menu).toHaveCount(0); await expect(beta).toBeFocused(); await page.keyboard.press("Shift+F10"); - await expect( - menu.getByRole("menuitem", { name: "Mute", exact: true }), - ).toBeEnabled(); + await expectNotificationLevel(page, menu, "Just mentions"); await page.keyboard.press("Escape"); } finally { unmute.release(); @@ -265,9 +266,7 @@ test("channel menu mute/read persist without selecting the row; failed mute rema await page.reload(); await openPage(page, "Messages"); await beta.click({ button: "right" }); - await expect( - menu.getByRole("menuitem", { name: "Mute", exact: true }), - ).toBeVisible(); + await expectNotificationLevel(page, menu, "Just mentions"); await page.keyboard.press("Escape"); // A click in the same instant as Escape can be lost while the row menu // dismisses. Open the next menu only after this one has closed. @@ -277,7 +276,7 @@ test("channel menu mute/read persist without selecting the row; failed mute rema await beta.click({ button: "right" }); await expect(menu.getByRole("menuitem")).toHaveText([ "Move channel", - "Mute", + "Notifications", "Mark as unread", "Copy link", ]); @@ -367,7 +366,13 @@ test("muted bell stays visible while pressing an unselected channel", async ({ const alpha = sidebar.locator('[data-channel-id="alpha"]'); const bell = beta.getByRole("img", { name: "Muted", exact: true }); await beta.click({ button: "right" }); - await page.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await notificationLevel( + await notificationLevels( + page, + page.getByRole("menu", { name: "Actions for Beta" }), + ), + "Mute", + ).click(); await expect(bell).toBeVisible(); const settledColor = () => bell.evaluate((element) => { diff --git a/tests/browser/navigation-sorting.spec.mjs b/tests/browser/navigation-sorting.spec.mjs index 54350f0f57..10ce09f694 100644 --- a/tests/browser/navigation-sorting.spec.mjs +++ b/tests/browser/navigation-sorting.spec.mjs @@ -1,6 +1,11 @@ import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; -import { openPage } from "./navigation.mjs"; +import { + expectNotificationLevel, + notificationLevel, + notificationLevels, + openPage, +} from "./navigation.mjs"; import { openChannelDetails } from "./channel-details.mjs"; test.use({ productionBroker: true, savedSidebar: true, sortingSidebar: true }); @@ -939,7 +944,10 @@ test("section sort applies immediately, rolls back on failure, and persists retr await cedar.click({ button: "right" }); const rowMenu = page.getByRole("menu", { name: "Actions for Cedar" }); const muted = page.waitForResponse("**/sidebar-mute"); - await rowMenu.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await notificationLevel( + await notificationLevels(page, rowMenu), + "Mute", + ).click(); expect((await muted).ok()).toBe(true); await (await muted).finished(); await expect(rowMenu).toHaveCount(0); @@ -1037,9 +1045,7 @@ test("section sort applies immediately, rolls back on failure, and persists retr await page.reload(); await expect.poll(order).toEqual(alphaOrder); await cedar.click({ button: "right" }); - await expect( - rowMenu.getByRole("menuitem", { name: "Unmute", exact: true }), - ).toBeEnabled(); + await expectNotificationLevel(page, rowMenu, "Mute"); await page.keyboard.press("Escape"); await expect(cedar).toBeFocused(); await work.getByRole("button", { name: "More actions for Work" }).click(); diff --git a/tests/browser/navigation.mjs b/tests/browser/navigation.mjs index 43f576f7f2..aa6947e16f 100644 --- a/tests/browser/navigation.mjs +++ b/tests/browser/navigation.mjs @@ -1,5 +1,37 @@ import { expect } from "@playwright/test"; +/** Opens a channel menu's Notifications submenu: All new posts, Just mentions, Mute. */ +export async function notificationLevels(page, menu) { + // Keyboard, not hover: a pointer already resting on the trigger cannot reopen it. + await menu + .getByRole("menuitem", { name: "Notifications", exact: true }) + .focus(); + await page.keyboard.press("ArrowRight"); + const levels = page.getByRole("menu", { name: /^Notifications for / }); + await expect(levels).toBeVisible(); + // Focus enters the submenu a frame after it opens; keys sent sooner go to the parent. + await expect(levels.getByRole("menuitemradio").first()).toBeFocused(); + return levels; +} + +export function notificationLevel(levels, name) { + return levels.getByRole("menuitemradio", { name, exact: true }); +} + +/** Checks the open row menu's level, then returns focus to the parent menu. */ +export async function expectNotificationLevel(page, menu, name) { + await expect( + notificationLevel(await notificationLevels(page, menu), name), + ).toBeChecked(); + await page.keyboard.press("ArrowLeft"); + await expect( + page.getByRole("menu", { name: /^Notifications for / }), + ).toHaveCount(0); + await expect( + menu.getByRole("menuitem", { name: "Notifications", exact: true }), + ).toBeFocused(); +} + export async function pageChoices(page, query = "") { await page.getByRole("button", { name: "Search Buzz", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Search Buzz", exact: true }); diff --git a/tests/browser/notification-settings.spec.mjs b/tests/browser/notification-settings.spec.mjs index 50dd4f0475..9daa96b342 100644 --- a/tests/browser/notification-settings.spec.mjs +++ b/tests/browser/notification-settings.spec.mjs @@ -50,12 +50,13 @@ test("Notifications keeps settings separated and button labels contained at supp await expect( section.getByRole("button", { name: "Allow notifications" }), ).toBeVisible(); - await expect(section.getByRole("switch")).toHaveCount(8); + await expect(section.getByRole("switch")).toHaveCount(9); // The per-event sound controls must be present for the geometry sweep. for (const name of [ "Direct messages", "@Mentions", "Thread replies", + "Channel posts", "Solo chat completions", ]) { await expect(section.getByRole("combobox", { name })).toBeVisible(); diff --git a/tests/browser/sidebar-collapsed-unread.spec.mjs b/tests/browser/sidebar-collapsed-unread.spec.mjs index 3a4b87c153..ff2a520d4a 100644 --- a/tests/browser/sidebar-collapsed-unread.spec.mjs +++ b/tests/browser/sidebar-collapsed-unread.spec.mjs @@ -2,6 +2,7 @@ import { finalizeEvent, generateSecretKey } from "nostr-tools"; import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; import { holdReadingFocus } from "./reading.mjs"; +import { notificationLevel, notificationLevels } from "./navigation.mjs"; test.use({ productionBroker: true, @@ -34,6 +35,11 @@ test("collapsed headings follow unread, confirmed mute and read changes", async await menu.getByRole("menuitem", { name, exact: true }).click(); await expect(menu).toBeHidden(); }; + const level = async (name) => { + await beta.click({ button: "right" }); + await notificationLevel(await notificationLevels(page, menu), name).click(); + await expect(menu).toBeHidden(); + }; const ink = (role) => title.evaluate((element, role) => { const probe = document.createElement("span"); @@ -89,14 +95,14 @@ test("collapsed headings follow unread, confirmed mute and read changes", async await summary.press("Enter"); await expect(beta).toBeVisible(); await expect(title).toHaveCSS("color", await ink("text-metadata")); - await action("Mute"); + await level("Mute"); await expect( beta.getByRole("img", { name: "Muted", exact: true }), ).toBeVisible(); await summary.click(); await expect(title).toHaveCSS("color", await ink("text-metadata")); await summary.click(); - await action("Unmute"); + await level("Just mentions"); await expect( beta.getByRole("img", { name: "Muted", exact: true }), ).toHaveCount(0); @@ -183,7 +189,13 @@ test.describe("thread activity", () => { await expect(alpha).toBeHidden(); await summary.click(); await alpha.click({ button: "right" }); - await page.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await notificationLevel( + await notificationLevels( + page, + page.getByRole("menu", { name: "Actions for Alpha" }), + ), + "Mute", + ).click(); await expect( alpha.getByRole("img", { name: "Muted", exact: true }), ).toBeVisible();