diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 4c6b25a1cf..b8777f3041 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -619,6 +619,15 @@ and are asked to sign in again in Buzz. dots, and do not contribute to the sidebar’s floating **N unread** buttons or their avatar previews. Muted activity does not light the macOS Dock unread dot. Muting does not mark messages or threads as read. +- **People on your list**: choose **Notify me when they post** on someone's + profile to be notified when they post in a channel you're in; **Stop notifying** + removes them. **Settings → Notifications → People on your list** shows everyone + you added, with **Remove** and **Clear list**, and the **People on your list** + switch under **Notify me about** turns these alerts off without clearing the + list. They follow the same rules as other alerts: nothing while you're viewing + the conversation, nothing once read, and nothing from a muted channel. If + they also mention you, you get one alert, as a mention. Replies count only in + conversations you're part of. The list is saved on this device, per account. - **Hidden DMs**: **Close conversation** and **Hide conversation** take a DM out of the sidebar without leaving it. To find one again, start a **New message** to exactly the same people. Its earlier messages appear before you send, diff --git a/docs/notifications.md b/docs/notifications.md index 3a7df68b2b..ace0dd93f7 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -88,6 +88,29 @@ checks, not that an OS banner was displayed or read. - A fresh live arrival can still alert when a channel read or replay cached the message first. Content caching does not count as live alert delivery; duplicate live copies do not produce additional alerts. +- A person on the viewer's **People on your list** (the `person` category) alerts + for their post in a channel the viewer is in, under the same viewing, unread, + mute and sync gates as the categories above. The list is the `people` array in + the account-local notification preferences (pubkeys, at most 200; saved values + without it restore as empty), edited from **Notify me when they post** on a + profile and reviewed or cleared in Settings → Notifications; the category + switch turns the alerts off and keeps the list. The unread engine's category + wins: a listed person who also mentions or DMs the viewer alerts once, as + `mention` or `direct`, and a reply in one of the viewer's conversations stays + `thread`; a reply waiting on its conversation lookup stays a `thread` candidate. + Only otherwise-uncategorized posts the viewer's own rules count (top-level + posts, or replies in their conversations) become `person`, so it never + overrides a thread opt-out or channel mute. The list is read again at + delivery, so removing someone cancels a waiting alert. A reply outside + the viewer's own conversations is never unread by the engine's rule, so it does + not alert; top-level posts and in-conversation replies do. A workflow post is + matched on its owner. The `notificationPeople` service's methods + reject calls made through any plugin's context except the bundled Profiles + plugin's (`buzz.profiles`), and the typed `ctx.notifications` contract has no people + methods. This is not isolation: plugins are trusted, unsandboxed code (see + plugin-architecture.md), so one that digs into host objects, rewrites + its own context or keeps another plugin's rendered panel can still reach the list, as it can every other + notification preference. - 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 diff --git a/src/app/NotificationPeople.tsx b/src/app/NotificationPeople.tsx new file mode 100644 index 0000000000..b9a15148ff --- /dev/null +++ b/src/app/NotificationPeople.tsx @@ -0,0 +1,85 @@ +import { useIdentityNames } from "../features/identity-names/react"; +import type { IdentityNameView } from "../features/identity-names/service"; +import type { NotificationsService } from "../features/notifications/service"; +import { UsersIcon } from "../shared/design-system/icons"; +import { Button } from "../shared/design-system/ui/Button"; +import { EmptyState } from "../shared/design-system/ui/EmptyState"; +import { InlineHeader } from "../shared/design-system/ui/Header"; +import { PreferenceRow } from "../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; +import { publicKeyLabels } from "../shared/identity/public-key"; + +/** The people whose posts notify, to review one by one or clear. They are + * added from a profile, where the person is in front of you. */ +export function NotificationPeople({ + notifications, + people, + names, +}: { + notifications: NotificationsService; + people: readonly string[]; + names: IdentityNameView | undefined; +}) { + const label = useIdentityNames(names); + // Abbreviated keys are lengthened until no two people share one. + const keys = publicKeyLabels(people); + const key = (pubkey: string) => + keys.get(pubkey.toLowerCase()) ?? pubkey.slice(0, 8); + const labels = people.map((pubkey) => label(pubkey, key(pubkey))); + // Two people may share a display name; those rows also show their key. + const shared = new Set( + labels.filter((name, at) => labels.indexOf(name) !== at), + ); + return ( +
+ 0 && ( + + ) + } + /> + {people.length === 0 ? ( + } + title="No one on your list" + description="Open someone’s profile and choose Notify me when they post." + /> + ) : ( + + {people.map((pubkey, at) => { + const name = labels[at] ?? key(pubkey); + const ambiguous = shared.has(name); + return ( + notifications.setPerson(pubkey, false)} + > + Remove + + } + /> + ); + })} + + )} +
+ ); +} diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx index d100e55d8a..9b01b0565e 100644 --- a/src/app/NotificationSettings.test.tsx +++ b/src/app/NotificationSettings.test.tsx @@ -11,6 +11,7 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; +import type { IdentityNameView } from "../features/identity-names/service"; import { provideNavigation } from "../features/navigation/service"; import { NotificationsService } from "../features/notifications/service"; import { DEFAULT_SOUND } from "../features/notifications/sound"; @@ -521,6 +522,7 @@ it("selects and restores Silent for every category, stops previews, and can rest mention: "silent", thread: "silent", agent: "flutter", + person: "flutter", }); expect(audios).toHaveLength(1); expect(screen.getByRole("switch", { name: "Desktop alerts" })).toBeChecked(); @@ -576,3 +578,95 @@ it("restores a saved sound name this build lacks with alerts on and the default screen.getByRole("combobox", { name: "Direct messages" }), ).toHaveTextContent(DEFAULT_SOUND); }); + +function peopleService() { + const ctx = new Context(); + contexts.push(ctx); + const runtime = new PluginRuntime(ctx, async () => ({ apply() {} })); + ctx.effect(() => () => runtime.dispose()); + const service = new NotificationsService( + ctx, + provideNavigation(ctx).navigation, + { + label: "Browser", + permission: vi.fn(async () => "granted" as const), + requestPermission: vi.fn(async () => "granted" as const), + show: vi.fn(async () => {}), + dispose() {}, + }, + ); + service.selectViewer("a".repeat(64)); + return service; +} + +it("lists the people on the notify list, removes one, and clears the rest", async () => { + const service = peopleService(); + const ada = "1".repeat(64); + const bo = "2".repeat(64); + render(, { + wrapper: ToastProvider, + }); + expect(screen.getByText("No one on your list")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Clear list" }), + ).not.toBeInTheDocument(); + act(() => { + service.setPerson(ada, true); + service.setPerson(bo, true); + }); + const user = userEvent.setup(); + expect(screen.getAllByRole("button", { name: /^Remove / })).toHaveLength(2); + const [first] = screen.getAllByRole("button", { name: /^Remove / }); + if (!first) throw new Error("No people listed"); + await user.click(first); + expect(service.snapshot().preferences.people).toEqual([bo]); + await user.click(screen.getByRole("button", { name: "Clear list" })); + expect(service.snapshot().preferences.people).toEqual([]); + expect(screen.getByText("No one on your list")).toBeInTheDocument(); +}); + +it("gives two unnamed people whose keys end alike different labels", () => { + const service = peopleService(); + // Their npubs share the last three characters. + const one = (2245).toString(16).padStart(64, "0"); + const two = (16384).toString(16).padStart(64, "0"); + render(, { + wrapper: ToastProvider, + }); + act(() => { + service.setPerson(one, true); + service.setPerson(two, true); + }); + const labels = screen + .getAllByRole("button", { name: /^Remove / }) + .map((button) => button.getAttribute("aria-label")); + expect(new Set(labels).size).toBe(2); +}); + +it("tells two people with the same display name apart by key", () => { + const service = peopleService(); + const one = "1".repeat(64); + const two = "2".repeat(64); + const solo = "3".repeat(64); + const names = { + resolve: (pubkey: string) => (pubkey === solo ? "Ada" : "Sam"), + snapshot: () => 0, + subscribe: () => () => {}, + } as unknown as IdentityNameView; + render(, { + wrapper: ToastProvider, + }); + act(() => { + service.setPerson(one, true); + service.setPerson(two, true); + service.setPerson(solo, true); + }); + const removes = screen + .getAllByRole("button", { name: /^Remove / }) + .map((button) => button.getAttribute("aria-label")); + expect(new Set(removes).size).toBe(3); + expect(removes[0]).toMatch(/^Remove Sam \(npub…\w+\)$/); + expect(removes[2]).toBe("Remove Ada"); + // The key is visible on the colliding rows only. + expect(screen.getAllByText(/^npub…\w+$/)).toHaveLength(2); +}); diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx index fff43bc403..ba86cee388 100644 --- a/src/app/NotificationSettings.tsx +++ b/src/app/NotificationSettings.tsx @@ -5,6 +5,8 @@ import { SwitchPreferenceRow } from "../shared/design-system/ui/SwitchPreference import { Button } from "../shared/design-system/ui/Button"; import { PreferenceRow } from "../shared/design-system/ui/PreferenceRow"; import { Select } from "../shared/design-system/ui/Select"; +import { NotificationPeople } from "./NotificationPeople"; +import type { IdentityNameView } from "../features/identity-names/service"; import { UnreadIndicatorSettings } from "./UnreadIndicatorSettings"; import { usePermissionCheck } from "./usePermissionCheck"; import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; @@ -26,6 +28,7 @@ const SOUND_ROWS: readonly NotificationCategory[] = [ "mention", "thread", "agent", + "person", ]; function AlertSoundRow({ @@ -80,9 +83,12 @@ function AlertSoundRow({ export function NotificationSettings({ notifications, + names, active = true, }: { notifications: NotificationsService; + /** Labels the people on the list; absent until a community is connected. */ + names?: IdentityNameView | undefined; active?: boolean; }) { const state = useSyncExternalStore( @@ -167,6 +173,11 @@ export function NotificationSettings({ ))} +
diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 53e17629ba..2781e5e44e 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1410,6 +1410,7 @@ export function Settings({ diff --git a/src/bundled/profiles/ProfileNotify.tsx b/src/bundled/profiles/ProfileNotify.tsx new file mode 100644 index 0000000000..9c0c19d167 --- /dev/null +++ b/src/bundled/profiles/ProfileNotify.tsx @@ -0,0 +1,54 @@ +import { useCallback, useState, useSyncExternalStore } from "react"; +import { MAX_NOTIFICATION_PEOPLE } from "../../features/notifications/preferences"; +import type { NotificationPeople } from "../../features/notifications/service"; +import { Button } from "../../shared/design-system/ui/Button"; + +/** Adds or removes this person from the list whose posts notify the viewer. */ +export function ProfileNotify({ + notifications, + pubkey, + variant, +}: { + notifications: NotificationPeople; + pubkey: string; + variant: "glass" | "subtle"; +}) { + // The list checks who calls it, so keep its methods bound to it. + const subscribe = useCallback( + (listener: () => void) => notifications.subscribe(listener), + [notifications], + ); + const listed = useSyncExternalStore(subscribe, () => + notifications.snapshot().preferences.people.includes(pubkey), + ); + const [problem, setProblem] = useState<"full" | "unsaved" | null>(null); + // A failed save still applies the choice, so a retry saves it as it is. + // A full list changes nothing, so it gets its own message and no retry. + const save = (next: boolean) => { + const { people } = notifications.snapshot().preferences; + if (next && !listed && people.length >= MAX_NOTIFICATION_PEOPLE) + return setProblem("full"); + setProblem(notifications.setPerson(pubkey, next) ? null : "unsaved"); + }; + return ( + <> + + {problem === "full" && ( +

+ Your list is full ({MAX_NOTIFICATION_PEOPLE} people). Remove someone + in Settings → Notifications first. +

+ )} + {problem === "unsaved" && ( +

+ Couldn’t save this choice on this device.{" "} + +

+ )} + + ); +} diff --git a/src/bundled/profiles/ProfilePanel.notify.test.tsx b/src/bundled/profiles/ProfilePanel.notify.test.tsx new file mode 100644 index 0000000000..d85e3f9aa6 --- /dev/null +++ b/src/bundled/profiles/ProfilePanel.notify.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { Context } from "@deepseek-ai/cordis"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { provideNavigation } from "../../features/navigation/service"; +import { profileTarget } from "../../features/profiles/target"; +import type { RelayData } from "../../features/relay/service"; +import { createRelaySession } from "../../features/relay/session"; +import { + NotificationsService, + PEOPLE_LIST_OWNER, +} from "../../features/notifications/service"; +import { PluginRuntime } from "../../plugins/runtime"; +import { ProfilePanel } from "./ProfilePanel"; + +const viewer = "b".repeat(64); +const person = "a".repeat(64); +const contexts: Context[] = []; +afterEach(async () => { + cleanup(); + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose(); + localStorage.clear(); +}); + +function fixture() { + const ctx = new Context(); + contexts.push(ctx); + const runtime = new PluginRuntime(ctx, async () => ({ apply() {} })); + ctx.effect(() => () => runtime.dispose()); + const service = new NotificationsService( + ctx, + provideNavigation(ctx).navigation, + { + label: "Browser", + permission: vi.fn(async () => "granted" as const), + requestPermission: vi.fn(async () => "granted" as const), + show: vi.fn(async () => {}), + dispose() {}, + }, + ); + service.selectViewer(viewer); + const snapshot = { + status: "ready" as const, + generation: 1, + scope: `https://relay.example.test:${viewer}`, + viewer, + session: createRelaySession(null).session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + tryAgain() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const panel = (pubkey: string) => ( + {}} + notifications={ctx.notificationPeople} + /> + ); + return { ctx, service, panel }; +} + +it("adds and removes the person from the notify list, and offers it on no one's own profile", async () => { + const f = fixture(); + const user = userEvent.setup(); + const view = render(f.panel(person)); + await user.click( + screen.getByRole("button", { name: "Notify me when they post" }), + ); + expect(f.service.snapshot().preferences.people).toEqual([person]); + await user.click(screen.getByRole("button", { name: "Stop notifying" })); + expect(f.service.snapshot().preferences.people).toEqual([]); + view.rerender(f.panel(viewer)); + expect( + screen.queryByRole("button", { name: /Notify me when they post/ }), + ).not.toBeInTheDocument(); +}); + +it("keeps the notify list from every plugin but the profile card", async () => { + const f = fixture(); + const as = async (id: string) => { + let scope: Context | undefined; + const fiber = f.ctx + .extend({ pluginOwner: { id, revision: "one" } }) + .plugin({ + inject: ["notificationPeople"], + apply: (ctx: Context) => { + scope = ctx; + }, + }); + await fiber.await(); + return (scope as Context).notificationPeople; + }; + const other = await as("example.browser"); + expect(() => other.snapshot()).toThrow( + "Only the profile card may use the notify list", + ); + expect(() => other.subscribe(() => {})).toThrow( + "Only the profile card may use the notify list", + ); + expect(() => other.setPerson(person, true)).toThrow( + "Only the profile card may use the notify list", + ); + expect(f.service.snapshot().preferences.people).toEqual([]); + const profiles = await as(PEOPLE_LIST_OWNER); + profiles.setPerson(person, true); + expect(profiles.snapshot().preferences.people).toEqual([person]); +}); + +it("retrying a failed save keeps the choice the person made", async () => { + const f = fixture(); + const user = userEvent.setup(); + render(f.panel(person)); + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("full"); + }); + await user.click( + screen.getByRole("button", { name: "Notify me when they post" }), + ); + expect( + screen.getByText("Couldn’t save this choice on this device."), + ).toBeInTheDocument(); + setItem.mockRestore(); + await user.click(screen.getByRole("button", { name: "Retry saving" })); + expect( + screen.queryByText("Couldn’t save this choice on this device."), + ).not.toBeInTheDocument(); + expect(f.service.snapshot().preferences.people).toEqual([person]); + expect( + JSON.parse(Object.values({ ...localStorage })[0] ?? "{}").people, + ).toEqual([person]); +}); + +it("says the list is full instead of offering a retry that can't add them", async () => { + const f = fixture(); + const user = userEvent.setup(); + const others = Array.from({ length: 200 }, (_, i) => + (i + 1).toString(16).padStart(64, "0"), + ); + f.service.updatePreferences({ people: others }); + render(f.panel(person)); + await user.click( + screen.getByRole("button", { name: "Notify me when they post" }), + ); + expect( + screen.getByText(/Your list is full \(200 people\)/), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Retry saving" }), + ).not.toBeInTheDocument(); + expect(f.service.snapshot().preferences.people).toEqual(others); +}); diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index dd6e4318cc..215f9799c4 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -31,6 +31,8 @@ import { ProfileLocalTime } from "../../features/profiles/ProfileLocalTime"; import { relayOrigin } from "../../features/communities/destination"; import type { Navigation } from "../../features/navigation/controller"; import { ProfileChannels } from "./ProfileChannels"; +import { ProfileNotify } from "./ProfileNotify"; +import type { NotificationPeople } from "../../features/notifications/service"; import { useChannelIdentityNames } from "../../features/identity-names/react"; import { usePresenceStatus } from "../../features/presence/react"; import { @@ -135,6 +137,7 @@ export function ProfilePanel({ agents, communities, linkRenderers, + notifications, close, renderPersonInfo, }: PanelProps & @@ -145,6 +148,8 @@ export function ProfilePanel({ agents?: Agents; communities?: CommunityReader | undefined; linkRenderers?: ConversationExtensions["links"] | undefined; + /** Lets the viewer choose to be notified when this person posts. */ + notifications?: NotificationPeople | undefined; }) { const connection = useRelayConnection(relay); const activityKey = profileActivityViewKey(target); @@ -226,6 +231,7 @@ export function ProfilePanel({ navigation={navigation} agents={agents} communities={communities} + notifications={notifications} scope={connection.scope} viewer={connection.viewer} openConversation={openConversation} @@ -369,6 +375,7 @@ function ProfileDetails({ navigation, agents, communities, + notifications, scope, viewer, openConversation, @@ -378,6 +385,7 @@ function ProfileDetails({ agents: Agents | undefined; linkRenderers: ConversationExtensions["links"] | undefined; communities: CommunityReader | undefined; + notifications: NotificationPeople | undefined; initialTab: "info" | "activity"; initialTranscript: TranscriptRequest | undefined; agentHint: boolean; @@ -568,6 +576,7 @@ function ProfileDetails({ }, }); const canEdit = !!scoped && viewer === pubkey; + const canNotify = !!notifications && !!viewer && viewer !== pubkey; // Settings edits the selected community's profile, so bind it to this one. const editProfile = () => void scoped?.navigation.open({ @@ -646,7 +655,7 @@ function ProfileDetails({ /> - {(canSetStatus || canMessage || canEdit) && ( + {(canSetStatus || canMessage || canEdit || canNotify) && (
{canSetStatus && (