Skip to content
Closed
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
4 changes: 3 additions & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ the interface unless an available tool actually supports that action.
Mute suppresses notifications for messages that do not mention you; mentions can still notify
you. Unmute restores ordinary unread emphasis and dots. Manual **Mark as unread**
can still make the name bold. Muted rows show no unread dots, including thread
dots; muting does not mark messages or threads as read.
dots; muting does not mark messages or threads as read. On macOS, a muted
channel lights the Dock dot only for messages that need your attention, such
as mentions, or when you mark it unread; muted DMs and Solo chats still light it.
- **Manage members** shows **General** access first in the member list. **Public**
lets people in the community read and new people participate; **View only**
lets them read; **Private** limits access to people added to the channel.
Expand Down
7 changes: 6 additions & 1 deletion docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,12 @@ observed unread messages (including thread replies) or explicit channel-unread
intent. It is not an exact message count or evidence of complete history. Unknown
and observed-zero both omit the dot. Existing bounded evidence/read-state owns
startup and updates; this projection adds no relay reads, network subscriptions,
or storage. Channel mute does not remove retained unread evidence from this community-wide dot, so muted ordinary backlog can keep it on while the channel's ordinary sidebar cues are quiet. Personal space, account/session changes, access loss and host disposal
or storage. Each channel counts only when the sidebar treats it as unread: confirmed
channel mute quiets ordinary backlog, while manual unread and observed attention
(mentions, broadcasts and replies in the viewer's conversations) still light the dot. Muted DMs and Solo chats keep
lighting it, as they keep their sidebar unread state. Mute and unmute recompute the
dot without a new message; a pending mute change, or preferences not yet loaded,
quiet nothing. Personal space, account/session changes, access loss and host disposal
clear or recompute the indicator. Disabling Channels does not stop host ownership.
Desktop alert preferences do not alter this unread indicator.

Expand Down
17 changes: 9 additions & 8 deletions src/bundled/channels/UnreadBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
useSyncExternalStore,
type ReactNode,
} from "react";
import {
channelUnread,
confirmedMute,
} from "../../features/channels/channel-unread";
import type { RelaySession } from "../../features/relay/session";
import styles from "./Channels.module.css";

Expand All @@ -24,12 +28,11 @@ export function ordinaryUnread(
);
}

/** Observe only confirmed preferences; optimistic menu intents do not quiet unread. */
export function useConfirmedMute(session: RelaySession, channelId: string) {
const get = useCallback(() => {
const snapshot = session.sidebarPreferences.snapshot();
return !snapshot.cached && !!snapshot.data?.muted.includes(channelId);
}, [session, channelId]);
const get = useCallback(
() => confirmedMute(session.sidebarPreferences.snapshot(), channelId),
[session, channelId],
);
return useSyncExternalStore(session.sidebarPreferences.subscribe, get, get);
}

Expand Down Expand Up @@ -69,9 +72,7 @@ export function useUnreadState(
const muted = useConfirmedMute(session, channelId);
const count = snapshot.observedCount;
const manual = snapshot.manual !== "none";
const unread =
manual ||
((count ?? 0) > 0 && (dm || !muted || (snapshot.attentionCount ?? 0) > 0));
const unread = channelUnread(snapshot, !dm && muted);
const ordinary = ordinaryUnread(snapshot, dm, muted);
const threadCount = activity.items?.length ?? 0;
return {
Expand Down
23 changes: 23 additions & 0 deletions src/features/channels/channel-unread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { RelaySession } from "../relay/session";
import type { UnreadSnapshot } from "../relay/unread";

type PreferencesSnapshot = ReturnType<
RelaySession["sidebarPreferences"]["snapshot"]
>;

/** Only confirmed preferences count; optimistic menu intents do not quiet unread. */
export function confirmedMute(
preferences: PreferencesSnapshot,
channelId: string,
) {
return !preferences.cached && !!preferences.data?.muted.includes(channelId);
}

/** A row's unread state: mute quiets ordinary backlog, never manual unread or attention. */
export function channelUnread(snapshot: UnreadSnapshot, muted: boolean) {
return (
snapshot.manual !== "none" ||
((snapshot.observedCount ?? 0) > 0 &&
(!muted || (snapshot.attentionCount ?? 0) > 0))
);
}
76 changes: 76 additions & 0 deletions src/features/notifications/indicator-unread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Communities } from "../communities/service";
import { createRelaySession } from "../relay/session";
import type { RelayEvent } from "../relay/events";
import { readJournal, type ReadJournal } from "../relay/read-state-storage";
import { createSidebarPreferencesStore } from "../relay/sidebar-preferences-store";
import { keypair, message, metadata, roster, signed } from "../relay/testing";
import { bindUnreadIndicator } from "./indicator-unread";

Expand Down Expand Up @@ -100,6 +101,37 @@ function setup() {
},
};
}
/** Confirms "room" muted; the returned setter replaces the confirmed list. */
async function withMute(
h: ReturnType<typeof setup>,
channelType?: "dm" | "solo",
) {
let muted = ["room"];
const preferences = createSidebarPreferencesStore({
available: true,
read: async () => ({ sections: [], assignments: {}, starred: [], muted }),
});
cleanups.push(() => preferences.dispose());
await preferences.queries.refresh();
const original = h.session;
h.relayState.session = {
...original,
sidebarPreferences: preferences.queries,
channels: {
...original.channels,
list: () => ({
...original.channels.list(),
channels: original.channels
.list()
.channels.map((c) => (channelType ? { ...c, channelType } : c)),
}),
},
};
return async (next: string[]) => {
muted = next;
await preferences.queries.refresh();
};
}
const target = { kind: "channel", channelId: "room" } as const;
it("starts clear for unknown evidence; projects arrivals and explicit read clearing without new reads", async () => {
const h = setup();
Expand Down Expand Up @@ -239,3 +271,47 @@ it("stays off for coordination between agents and lights for the next message to
h.emit([message(h.peer, "room", "Done", 12)]);
expect(h.project).toHaveBeenLastCalledWith(true);
});

it("keeps confirmed-muted backlog off the Dock; unmute and re-mute flip it without new messages", async () => {
const h = setup();
h.grant();
const setMuted = await withMute(h);
h.bind();
h.emit([message(h.peer, "room", "muted backlog", 11)]);
expect(h.session.unread.snapshot(target).observedCount).toBe(1);
expect(h.project.mock.calls).toEqual([[false]]);
await setMuted([]);
expect(h.project).toHaveBeenLastCalledWith(true);
await setMuted(["room"]);
expect(h.project).toHaveBeenLastCalledWith(false);
});

it("lights a muted channel for attention and manual unread", async () => {
const h = setup();
h.grant();
await withMute(h);
h.bind();
h.emit([message(h.peer, "room", "ordinary", 11)]);
expect(h.project.mock.calls).toEqual([[false]]);
const mention = message(h.peer, "room", "mention", 12, [
["p", h.viewer.pubkey],
]);
h.emit([mention]);
expect(h.project).toHaveBeenLastCalledWith(true);
await h.session.unread.markThrough(target, mention.id);
expect(h.project).toHaveBeenLastCalledWith(false);
await h.session.unread.markUnreadLocal(target);
expect(h.project).toHaveBeenLastCalledWith(true);
});

it.each(["dm", "solo"] as const)(
"keeps muted %s backlog on the Dock, as the sidebar does",
async (channelType) => {
const h = setup();
h.grant();
await withMute(h, channelType);
h.bind();
h.emit([message(h.peer, "room", "arrival", 11)]);
expect(h.project).toHaveBeenLastCalledWith(true);
},
);
56 changes: 33 additions & 23 deletions src/features/notifications/indicator-unread.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { channelUnread, confirmedMute } from "../channels/channel-unread";
import type { Communities } from "../communities/service";
import type { RelaySession } from "../relay/session";

Expand All @@ -10,17 +11,21 @@ export function bindUnreadIndicator(
let session: RelaySession | undefined;
let identity = "";
let previous: boolean | undefined;
let stopRoster = () => {};
let detach = () => {};
const channels = new Map<string, () => void>();
// DMs and Solo chats keep unread emphasis when muted, as in the sidebar.
const mutable = new Set<string>();
const publish = (unread: boolean) => {
if (unread === previous) return;
previous = unread;
project(unread);
};
const clear = () => {
stopRoster();
detach();
detach = () => {};
for (const stop of channels.values()) stop();
channels.clear();
mutable.clear();
};
const update = () => {
if (closed) return;
Expand All @@ -44,31 +49,31 @@ export function bindUnreadIndicator(
const valid = () => !closed && session === owned && identity === next;
const changed = () => {
if (!valid()) return;
const preferences = owned.sidebarPreferences.snapshot();
publish(
[...channels.keys()].some((channelId) => {
const snapshot = owned.unread.snapshot({
kind: "channel",
channelId,
});
return (
snapshot.manual !== "none" || (snapshot.observedCount ?? 0) > 0
);
}),
[...channels.keys()].some((channelId) =>
channelUnread(
owned.unread.snapshot({ kind: "channel", channelId }),
mutable.has(channelId) && confirmedMute(preferences, channelId),
),
),
);
};
const roster = () => {
if (!valid()) return;
const ids = new Set(
owned.channels
.list()
.channels.filter(
(channel) =>
!channel.huddle &&
!channel.metadataPending &&
channel.members?.includes(viewer),
)
.map((channel) => channel.id),
);
const accessible = owned.channels
.list()
.channels.filter(
(channel) =>
!channel.huddle &&
!channel.metadataPending &&
channel.members?.includes(viewer),
);
const ids = new Set(accessible.map((channel) => channel.id));
mutable.clear();
for (const channel of accessible)
if (channel.channelType !== "dm" && channel.channelType !== "solo")
mutable.add(channel.id);
for (const [id, stop] of channels) {
if (ids.has(id)) continue;
stop();
Expand All @@ -83,7 +88,12 @@ export function bindUnreadIndicator(
}
changed();
};
stopRoster = owned.channels.subscribeList(roster);
const stopList = owned.channels.subscribeList(roster);
const stopPreferences = owned.sidebarPreferences.subscribe(changed);
detach = () => {
stopList();
stopPreferences();
};
roster();
};
const stop = communities.subscribe(update);
Expand Down
Loading