fix(workspaces): exclude muted channels and unfollowed threads from rail unread badge (#1738)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-10 18:22:11 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 26966aacec
commit ea1c75b0d2
2 changed files with 528 additions and 9 deletions
@@ -12,6 +12,18 @@ const PUBKEY = "a".repeat(64);
const OTHER = "b".repeat(64);
const CHANNEL_ID = "channel-1";
const THREAD_ROOT = "c".repeat(64);
const THREAD_ROOT_2 = "d".repeat(64);
const EMPTY_RELATIONSHIPS = {
participatedRootIds: new Set(),
followedRootIds: new Set(),
authoredRootIds: new Set(),
mutedRootIds: new Set(),
};
function readRelationships(overrides = {}) {
return () => ({ ...EMPTY_RELATIONSHIPS, ...overrides });
}
function event(overrides = {}) {
return {
@@ -35,6 +47,15 @@ function relayFor(filters) {
};
}
// Helper: encode a mutes payload as JSON (decryptMutes stub returns content as-is)
function mutesContent(mutedIds) {
const channels = {};
for (const id of mutedIds) {
channels[id] = { muted: true, updatedAt: 1 };
}
return JSON.stringify({ version: 1, channels });
}
test("extractMemberChannelIds deduplicates d tags", () => {
assert.deepEqual(
extractMemberChannelIds([
@@ -103,6 +124,7 @@ test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => {
test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => {
const relay = relayFor([
// 1. member events
() => [
event({
tags: [
@@ -111,6 +133,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
@@ -119,8 +142,13 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [],
// 5. mutes events (parallel with read-state)
() => [],
// 6. unread events
() => [
event({
id: "unread".padEnd(64, "0"),
@@ -128,6 +156,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
tags: [["h", CHANNEL_ID]],
}),
],
// 7. mention events
() => [
event({
id: "mention".padEnd(64, "0"),
@@ -145,6 +174,8 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (value) => value,
decryptMutes: async (value) => value,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
@@ -173,6 +204,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
});
const relay = relayFor([
// 1. member events
() => [
event({
tags: [
@@ -181,6 +213,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
@@ -189,7 +222,9 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [
event({
pubkey: PUBKEY,
@@ -208,7 +243,11 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
}),
}),
],
// 5. mutes events (parallel with read-state)
() => [],
// 6. unread events
() => [threadReply, selfMention],
// 7. mention events
() => [threadReply, selfMention],
]);
@@ -217,6 +256,386 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (value) => value,
decryptMutes: async (value) => value,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread excludes muted-only channel — returns hasUnread:false mentionCount:0", async () => {
const MUTED_CHANNEL = "muted-channel-1";
const relay = relayFor([
// 1. member events — one muted channel
() => [
event({
tags: [
["d", MUTED_CHANNEL],
["p", PUBKEY],
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
["d", MUTED_CHANNEL],
["t", "stream"],
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [],
// 5. mutes events — MUTED_CHANNEL is muted
() => [
event({
pubkey: PUBKEY,
content: mutesContent([MUTED_CHANNEL]),
}),
],
// No per-channel fetches should follow — muted channel is skipped
]);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (value) => value,
decryptMutes: async (value) => value,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread counts unmuted channel but skips muted channel", async () => {
const UNMUTED_CHANNEL = "channel-unmuted";
const MUTED_CHANNEL = "channel-muted";
const relay = relayFor([
// 1. member events — two channels
() => [
event({
tags: [
["d", UNMUTED_CHANNEL],
["d", MUTED_CHANNEL],
["p", PUBKEY],
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
["d", UNMUTED_CHANNEL],
["t", "stream"],
],
}),
event({
tags: [
["d", MUTED_CHANNEL],
["t", "stream"],
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [],
// 5. mutes events — only MUTED_CHANNEL is muted
() => [
event({
pubkey: PUBKEY,
content: mutesContent([MUTED_CHANNEL]),
}),
],
// 6. unread events for UNMUTED_CHANNEL (muted channel loop iteration never fires)
() => [
event({
id: "unread".padEnd(64, "0"),
created_at: 20,
tags: [["h", UNMUTED_CHANNEL]],
}),
],
// 7. mention events for UNMUTED_CHANNEL
() => [
event({
id: "mention".padEnd(64, "0"),
created_at: 30,
tags: [
["h", UNMUTED_CHANNEL],
["p", PUBKEY],
],
}),
],
]);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (value) => value,
decryptMutes: async (value) => value,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
});
test("fetchWorkspaceUnread treats decryption failure as empty mutes set", async () => {
const relay = relayFor([
// 1. member events
() => [
event({
tags: [
["d", CHANNEL_ID],
["p", PUBKEY],
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
["d", CHANNEL_ID],
["t", "stream"],
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [],
// 5. mutes events — present but decryption will throw
() => [
event({
pubkey: PUBKEY,
content: "corrupted-ciphertext",
}),
],
// 6. unread events — channel is NOT muted (decryption failed → empty set)
() => [
event({
id: "unread".padEnd(64, "0"),
created_at: 20,
tags: [["h", CHANNEL_ID]],
}),
],
// 7. mention events
() => [],
]);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (value) => value,
decryptMutes: async () => {
throw new Error("decryption failed");
},
readThreadRelationships: readRelationships(),
});
// Channel counted as if no mutes
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread treats absent mutes blob as empty mutes set", async () => {
const relay = relayFor([
// 1. member events
() => [
event({
tags: [
["d", CHANNEL_ID],
["p", PUBKEY],
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
["d", CHANNEL_ID],
["t", "stream"],
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [],
// 5. mutes events — none
() => [],
// 6. unread events
() => [
event({
id: "unread".padEnd(64, "0"),
created_at: 20,
tags: [["h", CHANNEL_ID]],
}),
],
// 7. mention events
() => [],
]);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (value) => value,
decryptMutes: async (value) => value,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
// ── Thread-relevance gate tests ────────────────────────────────────────────
function threadedReplyEvent(overrides = {}) {
return event({
id: overrides.id ?? "reply".padEnd(64, "0"),
created_at: overrides.created_at ?? 20,
pubkey: overrides.pubkey ?? OTHER,
tags: [
["h", CHANNEL_ID],
["e", THREAD_ROOT_2, "", "root"],
["e", "parent".padEnd(64, "0"), "", "reply"],
...(overrides.extraTags ?? []),
],
...overrides,
});
}
function baseRelay(unreadEvent, mutesPayload = null) {
return relayFor([
// 1. member events
() => [
event({
tags: [
["d", CHANNEL_ID],
["p", PUBKEY],
],
}),
],
// 2. metadata events (parallel with visibility)
() => [
event({
tags: [
["d", CHANNEL_ID],
["t", "stream"],
],
}),
],
// 3. visibility events (parallel with metadata)
() => [],
// 4. read-state events (parallel with mutes)
() => [],
// 5. mutes events
() =>
mutesPayload ? [event({ pubkey: PUBKEY, content: mutesPayload })] : [],
// 6. unread events — the single event under test
() => [unreadEvent],
// 7. mention events
() => [],
]);
}
test("fetchWorkspaceUnread threaded reply in untracked root → hasUnread:false", async () => {
const relay = baseRelay(threadedReplyEvent());
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (v) => v,
decryptMutes: async (v) => v,
// No root in any set → gate rejects the threaded reply
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread threaded reply in participatedRootIds → hasUnread:true", async () => {
const relay = baseRelay(threadedReplyEvent());
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (v) => v,
decryptMutes: async (v) => v,
readThreadRelationships: readRelationships({
participatedRootIds: new Set([THREAD_ROOT_2]),
}),
});
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread #p-mention reply in untracked root → hasUnread:true (mention overrides)", async () => {
// A @mention of the user bypasses the follow/participation gate
const relay = baseRelay(
threadedReplyEvent({
id: "mention-reply".padEnd(64, "0"),
extraTags: [["p", PUBKEY]],
}),
);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (v) => v,
decryptMutes: async (v) => v,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread top-level post → hasUnread:true (no thread gate)", async () => {
// Top-level posts have no parentId — shouldNotifyForEvent returns true
const relay = baseRelay(
event({
id: "toplevel".padEnd(64, "0"),
created_at: 20,
tags: [["h", CHANNEL_ID]],
}),
);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (v) => v,
decryptMutes: async (v) => v,
readThreadRelationships: readRelationships(),
});
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread threaded reply whose root is in mutedRootIds → hasUnread:false", async () => {
const relay = baseRelay(
threadedReplyEvent({ id: "muted-reply".padEnd(64, "0") }),
);
const result = await fetchWorkspaceUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
decryptReadState: async (v) => v,
decryptMutes: async (v) => v,
// Root is participated but also muted — mute wins
readThreadRelationships: readRelationships({
participatedRootIds: new Set([THREAD_ROOT_2]),
mutedRootIds: new Set([THREAD_ROOT_2]),
}),
});
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
@@ -1,3 +1,4 @@
import { makeRootIdStore } from "@/features/channels/unreadRootIdStore";
import { DM_NOTIFIABLE_EVENT_KINDS } from "@/features/channels/isDmNotifiableKind";
import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot";
import {
@@ -8,13 +9,20 @@ import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify";
import {
mutedChannelIdsFromStore,
parseMutePayload,
} from "@/features/sidebar/lib/channelMutesStorage";
import type { Workspace } from "@/features/workspaces/types";
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
import { nip44DecryptFromSelf } from "@/shared/api/tauri";
import type { ChannelType, RelayEvent } from "@/shared/api/types";
import {
CHANNEL_MESSAGE_EVENT_KINDS,
HOME_MENTION_EVENT_KINDS,
KIND_CHANNEL_MUTES,
KIND_DM_VISIBILITY,
KIND_READ_STATE,
} from "@/shared/constants/kinds";
@@ -22,6 +30,53 @@ import {
const KIND_NIP29_GROUP_METADATA = 39000;
const KIND_NIP29_GROUP_MEMBERS = 39002;
// Stores for thread-relationship sets. Keyed by pubkey only (no relay/workspace),
// so they read correctly from the same origin regardless of which workspace is active.
const participationStore = makeRootIdStore("buzz-thread-participation.v1");
const authoredStore = makeRootIdStore("buzz-thread-authored.v1");
const mutedRootsStore = makeRootIdStore("buzz-thread-muted.v1");
const FOLLOWS_STORAGE_KEY_PREFIX = "buzz-thread-follows.v1";
export type ThreadRelationships = {
participatedRootIds: ReadonlySet<string>;
followedRootIds: ReadonlySet<string>;
authoredRootIds: ReadonlySet<string>;
mutedRootIds: ReadonlySet<string>;
};
function readFollowedRootIds(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(
`${FOLLOWS_STORAGE_KEY_PREFIX}:${pubkey}`,
);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
const ids = new Set<string>();
for (const entry of parsed) {
if (
typeof entry === "object" &&
entry !== null &&
typeof entry.rootId === "string"
) {
ids.add(entry.rootId);
}
}
return ids;
} catch {
return new Set();
}
}
function defaultReadThreadRelationships(pubkey: string): ThreadRelationships {
return {
participatedRootIds: participationStore.read(pubkey),
followedRootIds: readFollowedRootIds(pubkey),
authoredRootIds: authoredStore.read(pubkey),
mutedRootIds: mutedRootsStore.read(pubkey),
};
}
const MEMBER_CHANNEL_LIMIT = 1000;
const METADATA_LIMIT = 1000;
const UNREAD_EXISTENCE_LIMIT = 50;
@@ -98,32 +153,68 @@ export async function fetchWorkspaceUnread(args: {
pubkey: string;
nowSeconds?: number;
decryptReadState?: (ciphertext: string) => Promise<string>;
decryptMutes?: (ciphertext: string) => Promise<string>;
readThreadRelationships?: (pubkey: string) => ThreadRelationships;
}): Promise<WorkspaceUnreadObserverResult> {
const { client, pubkey } = args;
const normalizedPubkey = pubkey.toLowerCase();
const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
const decryptMutes = args.decryptMutes ?? nip44DecryptFromSelf;
const readRelationships =
args.readThreadRelationships ?? defaultReadThreadRelationships;
const channels = await fetchObservedChannels(client, pubkey);
if (channels.length === 0) {
return { hasUnread: false, mentionCount: 0 };
}
const readStateEvents = await client.fetchEvents({
kinds: [KIND_READ_STATE],
authors: [pubkey],
"#t": ["read-state"],
since: nowSeconds - READ_STATE_HORIZON_SECONDS,
limit: READ_STATE_FETCH_LIMIT,
});
const [readStateEvents, mutesEvents] = await Promise.all([
client.fetchEvents({
kinds: [KIND_READ_STATE],
authors: [pubkey],
"#t": ["read-state"],
since: nowSeconds - READ_STATE_HORIZON_SECONDS,
limit: READ_STATE_FETCH_LIMIT,
}),
client.fetchEvents({
kinds: [KIND_CHANNEL_MUTES],
authors: [pubkey],
"#d": ["channel-mutes"],
limit: 1,
}),
]);
const readState = await mergeReadStateEvents(
readStateEvents,
pubkey,
args.decryptReadState,
);
let mutedIds = new Set<string>();
if (mutesEvents.length > 0) {
try {
const plaintext = await decryptMutes(mutesEvents[0].content);
const store = parseMutePayload(JSON.parse(plaintext));
if (store) {
mutedIds = mutedChannelIdsFromStore(store);
}
} catch {
// decryption failure → treat as empty mutes set
}
}
const {
participatedRootIds,
followedRootIds,
authoredRootIds,
mutedRootIds,
} = readRelationships(normalizedPubkey);
let hasUnread = false;
let mentionCount = 0;
for (const channel of channels) {
if (mutedIds.has(channel.id)) continue;
const readAt = readState.get(channel.id) ?? null;
const since = readAt === null ? 0 : readAt + 1;
const kinds = unreadKindsForChannel(channel.channelType);
@@ -150,8 +241,17 @@ export async function fetchWorkspaceUnread(args: {
]);
if (!hasUnread) {
hasUnread = unreadEvents.some((event) =>
isUnreadExternalEvent(event, readState, readAt, normalizedPubkey),
hasUnread = unreadEvents.some(
(event) =>
isUnreadExternalEvent(event, readState, readAt, normalizedPubkey) &&
shouldNotifyForEvent(event, normalizedPubkey, {
participatedRootIds,
followedRootIds,
authoredRootIds,
mutedRootIds,
mutedChannelIds: mutedIds,
channelId: channel.id,
}),
);
}