Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/app/NotificationPeople.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-labelledby="notification-people-title">
<InlineHeader
id="notification-people-title"
title="People on your list"
subtitle="Get a notification when they post in a channel you’re in."
actions={
people.length > 0 && (
<Button
size="compact"
variant="ghost"
onClick={() => notifications.updatePreferences({ people: [] })}
>
Clear list
</Button>
)
}
/>
{people.length === 0 ? (
<EmptyState
level={4}
icon={<UsersIcon size={24} />}
title="No one on your list"
description="Open someone’s profile and choose Notify me when they post."
/>
) : (
<SettingsGroup>
{people.map((pubkey, at) => {
const name = labels[at] ?? key(pubkey);
const ambiguous = shared.has(name);
return (
<PreferenceRow
key={pubkey}
title={name}
subtitle={ambiguous ? key(pubkey) : undefined}
trailing={
<Button
size="compact"
variant="ghost"
aria-label={`Remove ${name}${ambiguous ? ` (${key(pubkey)})` : ""}`}
onClick={() => notifications.setPerson(pubkey, false)}
>
Remove
</Button>
}
/>
);
})}
</SettingsGroup>
)}
</section>
);
}
94 changes: 94 additions & 0 deletions src/app/NotificationSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(<NotificationSettings notifications={service} />, {
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(<NotificationSettings notifications={service} />, {
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(<NotificationSettings notifications={service} names={names} />, {
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);
});
11 changes: 11 additions & 0 deletions src/app/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +28,7 @@ const SOUND_ROWS: readonly NotificationCategory[] = [
"mention",
"thread",
"agent",
"person",
];

function AlertSoundRow({
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -167,6 +173,11 @@ export function NotificationSettings({
))}
</SettingsGroup>
</section>
<NotificationPeople
notifications={notifications}
people={preferences.people}
names={names}
/>
<section id="setting-delivery" aria-label="Desktop delivery">
<InlineHeader title="Delivery" />
<SettingsGroup>
Expand Down
1 change: 1 addition & 0 deletions src/app/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,7 @@ export function Settings({
<div hidden={selected !== "notifications"}>
<NotificationSettings
notifications={notifications}
names={relay.status === "ready" ? relay.session.names : undefined}
active={selected === "notifications"}
/>
</div>
Expand Down
54 changes: 54 additions & 0 deletions src/bundled/profiles/ProfileNotify.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Button size="sm" variant={variant} onClick={() => save(!listed)}>
{listed ? "Stop notifying" : "Notify me when they post"}
</Button>
{problem === "full" && (
<p role="alert" className="text-body-sm text-danger">
Your list is full ({MAX_NOTIFICATION_PEOPLE} people). Remove someone
in Settings → Notifications first.
</p>
)}
{problem === "unsaved" && (
<p role="alert" className="text-body-sm text-danger">
Couldn’t save this choice on this device.{" "}
<Button size="sm" variant="subtle" onClick={() => save(listed)}>
Retry saving
</Button>
</p>
)}
</>
);
}
Loading