mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
26966aacec
commit
ea1c75b0d2
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user