mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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:
@@ -12,6 +12,18 @@ const PUBKEY = "a".repeat(64);
|
|||||||
const OTHER = "b".repeat(64);
|
const OTHER = "b".repeat(64);
|
||||||
const CHANNEL_ID = "channel-1";
|
const CHANNEL_ID = "channel-1";
|
||||||
const THREAD_ROOT = "c".repeat(64);
|
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 = {}) {
|
function event(overrides = {}) {
|
||||||
return {
|
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", () => {
|
test("extractMemberChannelIds deduplicates d tags", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
extractMemberChannelIds([
|
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 () => {
|
test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => {
|
||||||
const relay = relayFor([
|
const relay = relayFor([
|
||||||
|
// 1. member events
|
||||||
() => [
|
() => [
|
||||||
event({
|
event({
|
||||||
tags: [
|
tags: [
|
||||||
@@ -111,6 +133,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
// 2. metadata events (parallel with visibility)
|
||||||
() => [
|
() => [
|
||||||
event({
|
event({
|
||||||
tags: [
|
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({
|
event({
|
||||||
id: "unread".padEnd(64, "0"),
|
id: "unread".padEnd(64, "0"),
|
||||||
@@ -128,6 +156,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
|
|||||||
tags: [["h", CHANNEL_ID]],
|
tags: [["h", CHANNEL_ID]],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
// 7. mention events
|
||||||
() => [
|
() => [
|
||||||
event({
|
event({
|
||||||
id: "mention".padEnd(64, "0"),
|
id: "mention".padEnd(64, "0"),
|
||||||
@@ -145,6 +174,8 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
|
|||||||
pubkey: PUBKEY,
|
pubkey: PUBKEY,
|
||||||
nowSeconds: 100,
|
nowSeconds: 100,
|
||||||
decryptReadState: async (value) => value,
|
decryptReadState: async (value) => value,
|
||||||
|
decryptMutes: async (value) => value,
|
||||||
|
readThreadRelationships: readRelationships(),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
|
assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
|
||||||
@@ -173,6 +204,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
|
|||||||
});
|
});
|
||||||
|
|
||||||
const relay = relayFor([
|
const relay = relayFor([
|
||||||
|
// 1. member events
|
||||||
() => [
|
() => [
|
||||||
event({
|
event({
|
||||||
tags: [
|
tags: [
|
||||||
@@ -181,6 +213,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
// 2. metadata events (parallel with visibility)
|
||||||
() => [
|
() => [
|
||||||
event({
|
event({
|
||||||
tags: [
|
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({
|
event({
|
||||||
pubkey: PUBKEY,
|
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],
|
() => [threadReply, selfMention],
|
||||||
|
// 7. mention events
|
||||||
() => [threadReply, selfMention],
|
() => [threadReply, selfMention],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -217,6 +256,386 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
|
|||||||
pubkey: PUBKEY,
|
pubkey: PUBKEY,
|
||||||
nowSeconds: 100,
|
nowSeconds: 100,
|
||||||
decryptReadState: async (value) => value,
|
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 });
|
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 { DM_NOTIFIABLE_EVENT_KINDS } from "@/features/channels/isDmNotifiableKind";
|
||||||
import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot";
|
import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot";
|
||||||
import {
|
import {
|
||||||
@@ -8,13 +9,20 @@ import {
|
|||||||
getThreadReference,
|
getThreadReference,
|
||||||
isBroadcastReply,
|
isBroadcastReply,
|
||||||
} from "@/features/messages/lib/threading";
|
} 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 type { Workspace } from "@/features/workspaces/types";
|
||||||
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
|
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
|
||||||
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
|
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
|
||||||
|
import { nip44DecryptFromSelf } from "@/shared/api/tauri";
|
||||||
import type { ChannelType, RelayEvent } from "@/shared/api/types";
|
import type { ChannelType, RelayEvent } from "@/shared/api/types";
|
||||||
import {
|
import {
|
||||||
CHANNEL_MESSAGE_EVENT_KINDS,
|
CHANNEL_MESSAGE_EVENT_KINDS,
|
||||||
HOME_MENTION_EVENT_KINDS,
|
HOME_MENTION_EVENT_KINDS,
|
||||||
|
KIND_CHANNEL_MUTES,
|
||||||
KIND_DM_VISIBILITY,
|
KIND_DM_VISIBILITY,
|
||||||
KIND_READ_STATE,
|
KIND_READ_STATE,
|
||||||
} from "@/shared/constants/kinds";
|
} from "@/shared/constants/kinds";
|
||||||
@@ -22,6 +30,53 @@ import {
|
|||||||
const KIND_NIP29_GROUP_METADATA = 39000;
|
const KIND_NIP29_GROUP_METADATA = 39000;
|
||||||
const KIND_NIP29_GROUP_MEMBERS = 39002;
|
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 MEMBER_CHANNEL_LIMIT = 1000;
|
||||||
const METADATA_LIMIT = 1000;
|
const METADATA_LIMIT = 1000;
|
||||||
const UNREAD_EXISTENCE_LIMIT = 50;
|
const UNREAD_EXISTENCE_LIMIT = 50;
|
||||||
@@ -98,32 +153,68 @@ export async function fetchWorkspaceUnread(args: {
|
|||||||
pubkey: string;
|
pubkey: string;
|
||||||
nowSeconds?: number;
|
nowSeconds?: number;
|
||||||
decryptReadState?: (ciphertext: string) => Promise<string>;
|
decryptReadState?: (ciphertext: string) => Promise<string>;
|
||||||
|
decryptMutes?: (ciphertext: string) => Promise<string>;
|
||||||
|
readThreadRelationships?: (pubkey: string) => ThreadRelationships;
|
||||||
}): Promise<WorkspaceUnreadObserverResult> {
|
}): Promise<WorkspaceUnreadObserverResult> {
|
||||||
const { client, pubkey } = args;
|
const { client, pubkey } = args;
|
||||||
const normalizedPubkey = pubkey.toLowerCase();
|
const normalizedPubkey = pubkey.toLowerCase();
|
||||||
const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
|
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);
|
const channels = await fetchObservedChannels(client, pubkey);
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
return { hasUnread: false, mentionCount: 0 };
|
return { hasUnread: false, mentionCount: 0 };
|
||||||
}
|
}
|
||||||
const readStateEvents = await client.fetchEvents({
|
|
||||||
kinds: [KIND_READ_STATE],
|
const [readStateEvents, mutesEvents] = await Promise.all([
|
||||||
authors: [pubkey],
|
client.fetchEvents({
|
||||||
"#t": ["read-state"],
|
kinds: [KIND_READ_STATE],
|
||||||
since: nowSeconds - READ_STATE_HORIZON_SECONDS,
|
authors: [pubkey],
|
||||||
limit: READ_STATE_FETCH_LIMIT,
|
"#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(
|
const readState = await mergeReadStateEvents(
|
||||||
readStateEvents,
|
readStateEvents,
|
||||||
pubkey,
|
pubkey,
|
||||||
args.decryptReadState,
|
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 hasUnread = false;
|
||||||
let mentionCount = 0;
|
let mentionCount = 0;
|
||||||
|
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
|
if (mutedIds.has(channel.id)) continue;
|
||||||
const readAt = readState.get(channel.id) ?? null;
|
const readAt = readState.get(channel.id) ?? null;
|
||||||
const since = readAt === null ? 0 : readAt + 1;
|
const since = readAt === null ? 0 : readAt + 1;
|
||||||
const kinds = unreadKindsForChannel(channel.channelType);
|
const kinds = unreadKindsForChannel(channel.channelType);
|
||||||
@@ -150,8 +241,17 @@ export async function fetchWorkspaceUnread(args: {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!hasUnread) {
|
if (!hasUnread) {
|
||||||
hasUnread = unreadEvents.some((event) =>
|
hasUnread = unreadEvents.some(
|
||||||
isUnreadExternalEvent(event, readState, readAt, normalizedPubkey),
|
(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