Skip to content

Commit 7b1a060

Browse files
wesbillmanPinky
andcommitted
fix(desktop): restore channel unread badges
Treat interested non-DM thread replies as channel unread events again while keeping normal channel messages dot-only. Split sidebar badge counts from desktop app badge state so mentions/DMs/thread replies remain numeric, ordinary channel traffic only bolds the row, and any channel unread still lights the desktop app badge dot. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
1 parent ed556f3 commit 7b1a060

9 files changed

Lines changed: 266 additions & 119 deletions

File tree

desktop/src/app/AppShell.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,6 @@ export function AppShell() {
390390
channels,
391391
);
392392

393-
// Badge count is computed here (rather than inside useHomeFeedNotifications)
394-
// so it can consume the NIP-RS read-state lifted from the single
395-
// ReadStateManager mounted via useUnreadChannels above. Channel-backed
396-
// feed items contribute to the badge iff strictly newer than that
397-
// channel's read marker; non-channel items keep their seen-set fallback.
398393
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
399394
useHomeFeedNotificationState(
400395
homeFeedQuery.data,
@@ -592,14 +587,18 @@ export function AppShell() {
592587
}, []);
593588

594589
React.useEffect(() => {
595-
const numericCount =
590+
const count =
596591
unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority;
597-
if (numericCount > 0) {
598-
void setDesktopAppBadge({ kind: "count", count: numericCount });
599-
} else {
600-
void setDesktopAppBadge({ kind: "none" });
601-
}
602-
}, [homeBadgeCountExcludingHighPriority, unreadChannelNotificationCount]);
592+
void setDesktopAppBadge(
593+
count
594+
? { kind: "count", count }
595+
: { kind: unreadChannelIds.size ? "dot" : "none" },
596+
);
597+
}, [
598+
homeBadgeCountExcludingHighPriority,
599+
unreadChannelIds,
600+
unreadChannelNotificationCount,
601+
]);
603602

604603
// Dispatch `buzz://message` deep links into the router.
605604
useMessageDeepLinks();

desktop/src/features/channels/unreadChannelCounts.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,30 @@ export type ObservedUnreadEvent = {
33
createdAt: number;
44
rootId: string | null;
55
highPriority: boolean;
6+
countsTowardBadge: boolean;
7+
countsTowardAppBadge: boolean;
68
};
79

10+
export function makeObservedUnreadEvent(input: {
11+
id: string;
12+
createdAt: number;
13+
rootId: string | null;
14+
highPriority: boolean;
15+
channelType: string | undefined;
16+
isThreadedReply: boolean;
17+
}): ObservedUnreadEvent {
18+
const isDm = input.channelType === "dm";
19+
return {
20+
id: input.id,
21+
createdAt: input.createdAt,
22+
rootId: input.rootId,
23+
highPriority: input.highPriority,
24+
countsTowardBadge: isDm || input.isThreadedReply || input.highPriority,
25+
countsTowardAppBadge:
26+
isDm || (!input.isThreadedReply && input.highPriority),
27+
};
28+
}
29+
830
export function mapsEqual(
931
a: ReadonlyMap<string, number>,
1032
b: ReadonlyMap<string, number>,
@@ -54,6 +76,34 @@ export function countUnreadObservedEvents(
5476
return count;
5577
}
5678

79+
export function countUnreadBadgeObservedEvents(
80+
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
81+
getReadAt: (event: ObservedUnreadEvent) => number | null,
82+
): number {
83+
if (!eventsById) return 0;
84+
let count = 0;
85+
for (const event of eventsById.values()) {
86+
if (!event.countsTowardBadge) continue;
87+
const readAt = getReadAt(event);
88+
if (readAt === null || event.createdAt > readAt) count += 1;
89+
}
90+
return count;
91+
}
92+
93+
export function countUnreadAppBadgeObservedEvents(
94+
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
95+
getReadAt: (event: ObservedUnreadEvent) => number | null,
96+
): number {
97+
if (!eventsById) return 0;
98+
let count = 0;
99+
for (const event of eventsById.values()) {
100+
if (!event.countsTowardAppBadge) continue;
101+
const readAt = getReadAt(event);
102+
if (readAt === null || event.createdAt > readAt) count += 1;
103+
}
104+
return count;
105+
}
106+
57107
export function countUnreadHighPriorityObservedEvents(
58108
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
59109
getReadAt: (event: ObservedUnreadEvent) => number | null,

desktop/src/features/channels/unreadReadMarker.test.mjs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import test from "node:test";
33

44
import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts";
55
import {
6+
countUnreadAppBadgeObservedEvents,
7+
countUnreadBadgeObservedEvents,
68
countUnreadHighPriorityObservedEvents,
79
countUnreadObservedEvents,
810
observedUnreadEventReadAt,
@@ -136,8 +138,22 @@ test("observedUnreadEventReadAt_nullChannelMarkerThreadMarkerCanClear", () => {
136138

137139
// --- Fix 2b: sidebar badge evaluates all observed events, not a single aggregate frontier ---
138140

139-
function observed(id, createdAt, rootId = null, highPriority = false) {
140-
return { id, createdAt, rootId, highPriority };
141+
function observed(
142+
id,
143+
createdAt,
144+
rootId = null,
145+
highPriority = false,
146+
countsTowardBadge = true,
147+
countsTowardAppBadge = countsTowardBadge,
148+
) {
149+
return {
150+
id,
151+
createdAt,
152+
rootId,
153+
highPriority,
154+
countsTowardBadge,
155+
countsTowardAppBadge,
156+
};
141157
}
142158

143159
function readAtFor(channelMarker, threadMarkers) {
@@ -219,6 +235,51 @@ test("countUnreadObservedEvents_topLevelUsesChannelMarker", () => {
219235
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1);
220236
});
221237

238+
test("countUnreadBadgeObservedEvents_skipsBoldOnlyGeneralChannelItems", () => {
239+
const events = new Map([
240+
["plain", observed("plain", 500, null, false, false)],
241+
["thread", observed("thread", 600, "root-1")],
242+
]);
243+
244+
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 2);
245+
assert.equal(
246+
countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())),
247+
1,
248+
);
249+
assert.equal(
250+
countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())),
251+
1,
252+
);
253+
});
254+
255+
test("countUnreadObservedEvents_countsThreadRepliesForChannelUnread", () => {
256+
const events = new Map([
257+
["reply", observed("reply", 500, "root-1", false, true, false)],
258+
]);
259+
260+
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1);
261+
assert.equal(
262+
countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())),
263+
1,
264+
);
265+
assert.equal(
266+
countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())),
267+
0,
268+
);
269+
});
270+
271+
test("highPriorityObservedEvents_countsMentionBadgeForGeneralMessage", () => {
272+
const events = new Map([
273+
["mention", observed("mention", 500, null, true, true)],
274+
]);
275+
const getReadAt = readAtFor(300, new Map());
276+
277+
assert.equal(countUnreadObservedEvents(events, getReadAt), 1);
278+
assert.equal(countUnreadBadgeObservedEvents(events, getReadAt), 1);
279+
assert.equal(countUnreadAppBadgeObservedEvents(events, getReadAt), 1);
280+
assert.equal(countUnreadHighPriorityObservedEvents(events, getReadAt), 1);
281+
});
282+
222283
test("recordObservedUnreadEvent_reportsOutOfOrderInsertForInvalidation", () => {
223284
const channelId = "chan";
224285
const observedByChannel = new Map();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Per-pubkey JSON array of thread root ids, capped to newest entries and
2+
// tolerant of malformed or unavailable localStorage.
3+
export function makeRootIdStore(prefix: string, maxEntries = 1000) {
4+
const storageKey = (pubkey: string) => `${prefix}:${pubkey}`;
5+
return {
6+
read(pubkey: string): Set<string> {
7+
try {
8+
const raw = window.localStorage.getItem(storageKey(pubkey));
9+
if (!raw) return new Set();
10+
const parsed = JSON.parse(raw);
11+
if (!Array.isArray(parsed)) return new Set();
12+
return new Set(
13+
parsed.filter((id): id is string => typeof id === "string"),
14+
);
15+
} catch {
16+
return new Set();
17+
}
18+
},
19+
write(pubkey: string, rootIds: Set<string>): void {
20+
try {
21+
const arr = [...rootIds];
22+
const capped =
23+
arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr;
24+
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped));
25+
} catch {
26+
// Ignore storage errors (private browsing, quota exceeded).
27+
}
28+
},
29+
};
30+
}

desktop/src/features/channels/useLiveChannelUpdates.test.mjs

Lines changed: 0 additions & 22 deletions
This file was deleted.

desktop/src/features/channels/useLiveChannelUpdates.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ export type UseLiveChannelUpdatesOptions = {
2828
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
2929
onLiveMention?: () => void;
3030
/**
31-
* Fired for live main-channel "new content" events in a member channel
32-
* authored by someone other than the current user. Non-DM thread replies
33-
* are routed through onThreadReplyNotification instead; DM thread replies
34-
* also fire this callback so the DM unread dot/count stays channel-level.
35-
* Used to drive the in-session "latest message at" map that powers sidebar
36-
* unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set.
31+
* Fired for live "new content" events in a member channel authored by
32+
* someone other than the current user. Thread replies also fire
33+
* onThreadReplyNotification so Home inbox activity stays in sync. Used to
34+
* drive the observed unread-event map that powers sidebar unread state.
35+
* See `UNREAD_TRIGGER_KINDS` for the exact kind set.
3736
*/
3837
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
3938
/**
@@ -73,13 +72,6 @@ const UNREAD_TRIGGER_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
7372

7473
export const EMPTY_SET: ReadonlySet<string> = new Set();
7574

76-
export function shouldRouteChannelUnreadEvent(
77-
channel: Pick<Channel, "channelType"> | undefined,
78-
isThreadedReply: boolean,
79-
): boolean {
80-
return !isThreadedReply || channel?.channelType === "dm";
81-
}
82-
8375
function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
8476
return (
8577
currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey
@@ -237,18 +229,11 @@ export function useLiveChannelUpdates(
237229
if (isThreadedReply) {
238230
options.onThreadReplyCandidate?.(channelId, event);
239231
}
240-
} else if (
241-
shouldRouteChannelUnreadEvent(
242-
dmChannelMap.get(channelId),
243-
isThreadedReply,
244-
)
245-
) {
232+
} else {
246233
options.onChannelMessage?.(channelId, event);
247234
if (isThreadedReply) {
248235
options.onThreadReplyNotification?.(channelId, event);
249236
}
250-
} else {
251-
options.onThreadReplyNotification?.(channelId, event);
252237
}
253238

254239
if (shouldNotify && isThreadedReply) {

0 commit comments

Comments
 (0)