Skip to content
Open
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default defineConfig({
"**/home-collapsed-top-chrome.spec.ts",
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/thread-unread-screenshots.spec.ts",
"**/workspace-rail.spec.ts",
"**/community-rail.spec.ts",
"**/boot-splash.spec.ts",
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export function AppShell() {
unreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
threadOnlyUnreadChannelIds: threadOnlyUnreadIds,
unreadChannelNotificationCount,
getEffectiveTimestamp: getChannelReadAt,
getOwnTimestamp: getOwnReadAt,
Expand All @@ -355,7 +356,6 @@ export function AppShell() {
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
});

const getThreadReadAt = React.useCallback(
(rootId: string, channelId?: string | null) => {
const threadReadAt = getOwnReadAt(`thread:${rootId}`);
Expand Down Expand Up @@ -911,6 +911,7 @@ export function AppShell() {
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
threadOnlyUnreadChannelIds={threadOnlyUnreadIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/app/useAppShellDesktopNotifications.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
resolveThreadReplySenderName,
threadReplyNotificationTitle,
} from "./useAppShellDesktopNotifications.ts";

const PUBKEY = "a".repeat(64);

test("thread reply title uses resolved sender name with channel", () => {
const senderName = resolveThreadReplySenderName(PUBKEY, {
displayName: "Maya",
nip05Handle: null,
});

assert.equal(senderName, "Maya");
assert.equal(
threadReplyNotificationTitle(senderName, "#design"),
"Maya replied in #design",
);
});

test("thread reply title falls back to Reply when profile is not cached", () => {
const senderName = resolveThreadReplySenderName(PUBKEY, undefined);

assert.equal(senderName, undefined);
assert.equal(
threadReplyNotificationTitle(senderName, "#design"),
"Reply in #design",
);
});

test("empty display name never leaks a truncated pubkey into the title", () => {
const senderName = resolveThreadReplySenderName(PUBKEY, {
displayName: " ",
nip05Handle: null,
});

assert.equal(senderName, undefined);
assert.equal(threadReplyNotificationTitle(senderName, null), "Reply");
});

test("nip05 handle is used when display name is missing", () => {
const senderName = resolveThreadReplySenderName(PUBKEY, {
displayName: null,
nip05Handle: "maya@example.com",
});

assert.equal(senderName, "maya@example.com");
assert.equal(
threadReplyNotificationTitle(senderName, "#design"),
"maya@example.com replied in #design",
);
});

test("resolved sender title omits channel when channel is unresolved", () => {
assert.equal(threadReplyNotificationTitle("Maya", null), "Maya replied");
});
75 changes: 73 additions & 2 deletions desktop/src/app/useAppShellDesktopNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from "react";

import { useQueryClient } from "@tanstack/react-query";

import {
shouldBounceForChannelNotification,
toSearchHit,
Expand All @@ -21,7 +23,61 @@ import {
playNotificationSound,
resolveSlotSound,
} from "@/features/notifications/lib/sound";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import type {
Channel,
Profile,
RelayEvent,
UserProfileSummary,
} from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";

/**
* Resolve a sender's display name for the thread-reply toast title, mirroring
* the home-feed notification path (use-feed-desktop-notifications.ts): resolve
* via `resolveUserLabel`, then discard the truncated-pubkey fallback so a raw
* pubkey/npub never lands in an OS notification title.
*
* Returns `undefined` when no usable name exists (caller falls back to the
* bare "Reply" prefix).
*/
export function resolveThreadReplySenderName(
pubkey: string,
profile:
| Pick<UserProfileSummary, "displayName" | "nip05Handle">
| null
| undefined,
): string | undefined {
if (!profile) return undefined;
const label = resolveUserLabel({
pubkey,
profiles: {
[normalizePubkey(pubkey)]: {
displayName: profile.displayName,
avatarUrl: null,
nip05Handle: profile.nip05Handle,
ownerPubkey: null,
},
},
preferResolvedSelfLabel: true,
});
return label !== truncatePubkey(pubkey) ? label : undefined;
}

/**
* Thread-reply toast title: `"{Sender} replied in #channel"` when the sender's
* profile is cached, otherwise today's `"Reply in #channel"` fallback. Channel
* label omitted when unresolved (see {@link formatNotificationTitle}).
*/
export function threadReplyNotificationTitle(
senderName: string | undefined,
channelLabel: string | null,
): string {
return formatNotificationTitle({
prefix: senderName ? `${senderName} replied` : "Reply",
channelLabel,
});
}

export function useAppShellDesktopNotifications({
channels,
Expand All @@ -40,6 +96,14 @@ export function useAppShellDesktopNotifications({
) => Promise<unknown>;
pubkey?: string;
}) {
// Sender names come from the shared react-query profile cache rather than a
// new prop from AppShell: `useUsersBatchQuery` (which backs message
// timelines, the home feed, and DM metadata) seeds a `["user-profile", pk]`
// entry for every profile it resolves, so reading it here is a pure cache
// hit. A miss just means the profile hasn't been fetched this session — the
// toast falls back to the nameless "Reply" prefix.
const queryClient = useQueryClient();

const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
if (!shouldBounceForChannelNotification(event.tags)) return;
Expand Down Expand Up @@ -103,11 +167,18 @@ export function useAppShellDesktopNotifications({
// channelLabel is "#name" for the toast title; channelName is the raw
// name stored in the navigation target for click-through routing.
const channelLabel = channelName ? `#${channelName}` : null;
const senderName = resolveThreadReplySenderName(
event.pubkey,
queryClient.getQueryData<Profile>([
"user-profile",
normalizePubkey(event.pubkey),
]),
);
const body = truncateNotificationBody(event.content, "New reply");
const threadRootId = getThreadReference(event.tags).rootId ?? null;

void sendDesktopNotification({
title: formatNotificationTitle({ prefix: "Reply", channelLabel }),
title: threadReplyNotificationTitle(senderName, channelLabel),
body,
target: {
channelId,
Expand Down
37 changes: 37 additions & 0 deletions desktop/src/features/channels/unreadChannelCounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export type ObservedUnreadEvent = {
createdAt: number;
rootId: string | null;
highPriority: boolean;
isDm: boolean;
isThreadedReply: boolean;
countsTowardBadge: boolean;
countsTowardAppBadge: boolean;
};
Expand All @@ -23,6 +25,8 @@ export function makeObservedUnreadEvent(input: {
createdAt: input.createdAt,
rootId: input.rootId,
highPriority: input.highPriority,
isDm,
isThreadedReply: input.isThreadedReply,
countsTowardBadge: isDm || input.isThreadedReply || input.highPriority,
countsTowardAppBadge:
isDm || (!input.isThreadedReply && input.highPriority),
Expand Down Expand Up @@ -120,6 +124,39 @@ export function countUnreadHighPriorityObservedEvents(
return count;
}

/**
* A badge event whose only reason for counting is a threaded reply: not a DM
* and not a mention/broadcast. A mention inside a thread is high-priority and
* is intentionally excluded — it keeps the solid numeric badge.
*/
export function isThreadOnlyBadgeObservedEvent(
event: ObservedUnreadEvent,
): boolean {
return event.isThreadedReply === true && !event.isDm && !event.highPriority;
}

/**
* True when at least one badge-counting event is unread and ALL unread
* badge-counting events are thread-only — i.e. the channel's numeric badge is
* entirely explained by replies invisible in the channel scroll, so the
* sidebar can demote it to the thread-glyph badge.
*/
export function unreadBadgeObservedEventsAreThreadOnly(
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
getReadAt: (event: ObservedUnreadEvent) => number | null,
): boolean {
if (!eventsById) return false;
let sawThreadOnly = false;
for (const event of eventsById.values()) {
if (!event.countsTowardBadge) continue;
const readAt = getReadAt(event);
if (readAt !== null && event.createdAt <= readAt) continue;
if (!isThreadOnlyBadgeObservedEvent(event)) return false;
sawThreadOnly = true;
}
return sawThreadOnly;
}

export function observedUnreadEventReadAt(
event: ObservedUnreadEvent,
channelReadAt: number | null,
Expand Down
127 changes: 127 additions & 0 deletions desktop/src/features/channels/unreadReadMarker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import {
countUnreadBadgeObservedEvents,
countUnreadHighPriorityObservedEvents,
countUnreadObservedEvents,
isThreadOnlyBadgeObservedEvent,
makeObservedUnreadEvent,
observedUnreadEventReadAt,
recordObservedUnreadEvent,
unreadBadgeObservedEventsAreThreadOnly,
} from "./unreadChannelCounts.ts";
import {
addThreadActivityItems,
Expand Down Expand Up @@ -477,3 +480,127 @@ test("addThreadActivityItems keeps newest items when input is newest-first", ()
assert.equal(result.items[0].id, "reply-1");
assert.equal(result.items.at(-1).id, "reply-100");
});

function observedEvent({
id,
createdAt,
channelType = "stream",
isThreadedReply = false,
highPriority = false,
rootId = null,
}) {
return makeObservedUnreadEvent({
id,
createdAt,
rootId,
highPriority,
channelType,
isThreadedReply,
});
}

test("thread-only classification: plain thread reply qualifies", () => {
const event = observedEvent({
id: "reply",
createdAt: 10,
isThreadedReply: true,
rootId: "root",
});
assert.equal(isThreadOnlyBadgeObservedEvent(event), true);
});

test("thread-only classification: thread reply that is also a mention does not qualify", () => {
const event = observedEvent({
id: "mention-reply",
createdAt: 10,
isThreadedReply: true,
highPriority: true,
rootId: "root",
});
assert.equal(isThreadOnlyBadgeObservedEvent(event), false);
});

test("thread-only classification: DM reply does not qualify", () => {
const event = observedEvent({
id: "dm-reply",
createdAt: 10,
channelType: "dm",
isThreadedReply: true,
rootId: "root",
});
assert.equal(isThreadOnlyBadgeObservedEvent(event), false);
});

test("channel is thread-only when every unread badge event is a plain thread reply", () => {
const events = new Map([
[
"r1",
observedEvent({
id: "r1",
createdAt: 10,
isThreadedReply: true,
rootId: "root",
}),
],
[
"r2",
observedEvent({
id: "r2",
createdAt: 20,
isThreadedReply: true,
rootId: "root",
}),
],
]);
assert.equal(
unreadBadgeObservedEventsAreThreadOnly(events, () => null),
true,
);
});

test("channel is not thread-only when an unread mention coexists with thread replies", () => {
const events = new Map([
[
"r1",
observedEvent({
id: "r1",
createdAt: 10,
isThreadedReply: true,
rootId: "root",
}),
],
["m1", observedEvent({ id: "m1", createdAt: 20, highPriority: true })],
]);
assert.equal(
unreadBadgeObservedEventsAreThreadOnly(events, () => null),
false,
);
});

test("channel is not thread-only once its thread replies are read", () => {
const events = new Map([
[
"r1",
observedEvent({
id: "r1",
createdAt: 10,
isThreadedReply: true,
rootId: "root",
}),
],
]);
assert.equal(
unreadBadgeObservedEventsAreThreadOnly(events, () => 10),
false,
);
});

// Forced-unread channels record no observed events (the unread derivation
// short-circuits before the classifier); with no events the classifier must
// report not-thread-only so the solid badge treatment is kept.
test("channel without observed events (forced-unread) is not thread-only", () => {
assert.equal(
unreadBadgeObservedEventsAreThreadOnly(undefined, () => null),
false,
);
});
Loading
Loading