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) && (