Skip to content
Draft
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 @@ -21,6 +21,7 @@ export default defineConfig({
testMatch: [
"**/smoke.spec.ts",
"**/channels.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
"**/file-attachment.spec.ts",
Expand Down
4 changes: 2 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + merge_personas inequality checks + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas)
["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests
["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
["src/app/AppShell.tsx", 880], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + isNotifiedForThread combined predicate + threadActivityItems context plumbing + mutedRootIds denylist + handleFollowThread/handleUnfollowThread combined handlers
["src/app/AppShell.tsx", 890], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) + dock bounce wiring + mark-all-read context + channel notification callback + desktopEnabled guard + useThreadFollows wiring + isNotifiedForThread combined predicate + threadActivityItems context plumbing + mutedRootIds denylist + handleFollowThread/handleUnfollowThread combined handlers + two-tier badge effect
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough
["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context
["src/features/channels/useUnreadChannels.ts", 717], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread)
["src/features/channels/useUnreadChannels.ts", 830], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread) + two-tier badge: latestHighPriorityByChannelRef + highPriorityUnreadChannelIds memo tracking DMs + @mentions + broadcast replies + markChannelRead observed-latest ref clearing
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"core:default",
"core:webview:allow-set-webview-zoom",
"core:window:allow-set-badge-count",
"core:window:allow-set-badge-label",
"core:window:allow-request-user-attention",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
Expand Down
41 changes: 28 additions & 13 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
requestDockBounce,
revealDesktopAppWindow,
sendDesktopNotification,
setDesktopAppBadgeCount,
setDesktopAppBadge,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { playNotificationSound } from "@/features/notifications/lib/sound";
Expand Down Expand Up @@ -285,6 +285,7 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
unreadChannelIds,
highPriorityUnreadChannelIds,
getEffectiveTimestamp: getChannelReadAt,
readStateVersion,
participatedRootIds,
Expand Down Expand Up @@ -315,16 +316,18 @@ export function AppShell() {
// ReadStateManager mounted via useUnreadChannels above. Channel-backed
// feed items contribute to the badge iff strictly newer than that
// channel's read marker; non-channel items keep their seen-set fallback.
const homeBadgeCount = useHomeFeedNotificationState(
homeFeedQuery.data,
identityQuery.data?.pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
selectedView === "home",
getChannelReadAt,
readStateVersion,
feedProfilesQuery.data?.profiles,
);
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
homeFeedQuery.data,
identityQuery.data?.pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
selectedView === "home",
getChannelReadAt,
readStateVersion,
highPriorityUnreadChannelIds,
feedProfilesQuery.data?.profiles,
);

const isNotifiedForThread = React.useCallback(
(rootId: string) =>
Expand Down Expand Up @@ -488,8 +491,20 @@ export function AppShell() {
}, []);

React.useEffect(() => {
void setDesktopAppBadgeCount(unreadChannelIds.size + homeBadgeCount);
}, [homeBadgeCount, unreadChannelIds.size]);
const numericCount =
highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority;
if (numericCount > 0) {
void setDesktopAppBadge({ kind: "count", count: numericCount });
} else if (unreadChannelIds.size > 0) {
void setDesktopAppBadge({ kind: "dot" });
} else {
void setDesktopAppBadge({ kind: "none" });
}
}, [
homeBadgeCountExcludingHighPriority,
highPriorityUnreadChannelIds.size,
unreadChannelIds.size,
]);

// Dispatch `sprout://message` deep links into the router.
useMessageDeepLinks();
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/channels/useActiveChannelHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function useActiveChannelHeader(
currentPubkey?: string,
) {
const activeDmParticipantPubkeys = React.useMemo(() => {
if (!activeChannel || activeChannel.channelType !== "dm") {
if (activeChannel?.channelType !== "dm") {
return [];
}

Expand Down
13 changes: 7 additions & 6 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ export function useLiveChannelUpdates(
// reactions / edits / system messages aren't "new content".
if (
UNREAD_TRIGGER_KINDS.has(event.kind) &&
channelId !== activeChannelId &&
(normalizedCurrentPubkey.length === 0 ||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey) &&
shouldNotifyForEvent(
Expand All @@ -186,11 +185,13 @@ export function useLiveChannelUpdates(
)
) {
options.onChannelMessage?.(channelId, event);
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
options.onThreadReplyNotification?.(channelId, event);
if (channelId !== activeChannelId) {
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
options.onThreadReplyNotification?.(channelId, event);
}
}
}

Expand Down
135 changes: 122 additions & 13 deletions desktop/src/features/channels/useUnreadChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify";
import {
shouldNotifyForEvent,
isHighPriorityEventForUser,
} from "@/features/notifications/lib/shouldNotify";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
Expand Down Expand Up @@ -241,6 +244,12 @@ export function useUnreadChannels(
// change. Stale entries for channels the user has left are silently
// ignored by the memo (it iterates the current channels list, not the map).
const latestByChannelRef = React.useRef(new Map<string, number>());
const latestHighPriorityByChannelRef = React.useRef(
new Map<string, number>(),
);

const channelsRef = React.useRef(channels);
channelsRef.current = channels;

// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). The NIP-RS rollback (markContextUnread) is the cross-device
Expand Down Expand Up @@ -281,6 +290,7 @@ export function useUnreadChannels(
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals
React.useEffect(() => {
latestByChannelRef.current = new Map();
latestHighPriorityByChannelRef.current = new Map();
forcedUnreadRef.current = new Set();
caughtUpChannelsRef.current = new Set();
participatedRootIdsRef.current = pubkey
Expand All @@ -296,16 +306,25 @@ export function useUnreadChannels(

const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
const callerUnix = toUnixSeconds(readAt);
const observedLatest = latestByChannelRef.current.get(channelId);
const unixSeconds =
toUnixSeconds(readAt) ??
latestByChannelRef.current.get(channelId) ??
null;
Math.max(callerUnix ?? 0, observedLatest ?? 0) || null;
if (unixSeconds === null) return;
// Reading clears any prior manual mark-unread.
if (forcedUnreadRef.current.delete(channelId)) {
bumpLatestVersion();
}
markContextRead(channelId, unixSeconds);
// Clear observed-latest refs when the read marker covers them so the
// unread memo sees `latest === undefined` until a genuinely new event
// arrives. Without this, `latest > readAt` resolves to `T > T` (false)
// but the channel lingers in the set when advanceContext's monotonic
// guard suppresses the readStateVersion bump.
if (observedLatest !== undefined && observedLatest <= unixSeconds) {
latestByChannelRef.current.delete(channelId);
latestHighPriorityByChannelRef.current.delete(channelId);
bumpLatestVersion();
}
},
[markContextRead],
);
Expand Down Expand Up @@ -362,9 +381,28 @@ export function useUnreadChannels(
latestByChannelRef.current.set(channelId, event.created_at);
bumpLatestVersion();
}

// Track high-priority events (DMs, mentions, broadcasts) separately.
const channel = channelsRef.current.find((ch) => ch.id === channelId);
if (
channel?.channelType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey))
) {
const currentHigh =
latestHighPriorityByChannelRef.current.get(channelId) ?? 0;
if (event.created_at > currentHigh) {
latestHighPriorityByChannelRef.current.set(
channelId,
event.created_at,
);
bumpLatestVersion();
}
}

callerOnChannelMessage?.(channelId, event);
},
[callerOnChannelMessage],
[callerOnChannelMessage, normalizedPubkey],
);

const handleSelfChannelMessage = React.useCallback(
Expand Down Expand Up @@ -492,6 +530,7 @@ export function useUnreadChannels(
channelId: string;
ok: true;
maxExternal: number;
maxHighPriority: number;
threadReplies: ThreadActivityItem[];
}
| { channelId: string; ok: false };
Expand Down Expand Up @@ -542,7 +581,11 @@ export function useUnreadChannels(
// Pass 2: compute maxExternal and collect thread reply activity,
// applying the notification filter to both.
let maxExternal = 0;
let maxHighPriority = 0;
const threadReplies: ThreadActivityItem[] = [];
const chType = channels.find(
(ch) => ch.id === channelId,
)?.channelType;
const chName = channels.find((ch) => ch.id === channelId)?.name ?? "";
for (const event of events) {
if (
Expand All @@ -567,6 +610,15 @@ export function useUnreadChannels(
if (event.created_at > maxExternal) {
maxExternal = event.created_at;
}
if (
chType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey ?? ""))
) {
if (event.created_at > maxHighPriority) {
maxHighPriority = event.created_at;
}
}
const evtRef = getThreadReference(event.tags);
if (evtRef.parentId !== null && !isBroadcastReply(event.tags)) {
threadReplies.push({
Expand All @@ -582,7 +634,13 @@ export function useUnreadChannels(
}
}

return { channelId, ok: true, maxExternal, threadReplies };
return {
channelId,
ok: true,
maxExternal,
maxHighPriority,
threadReplies,
};
} catch {
// Transient relay failure for this channel — release the claim
// so we retry on the next effect run instead of staying stuck
Expand All @@ -599,13 +657,26 @@ export function useUnreadChannels(
caughtUpChannelsRef.current.delete(result.channelId);
continue;
}
const { channelId, maxExternal, threadReplies } = result;
const { channelId, maxExternal, maxHighPriority, threadReplies } =
result;
allThreadReplies.push(...threadReplies);
if (maxExternal === 0) continue;
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (maxExternal > current) {
latestByChannelRef.current.set(channelId, maxExternal);
didAdvance = true;
if (maxExternal > 0) {
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (maxExternal > current) {
latestByChannelRef.current.set(channelId, maxExternal);
didAdvance = true;
}
}
if (maxHighPriority > 0) {
const currentHigh =
latestHighPriorityByChannelRef.current.get(channelId) ?? 0;
if (maxHighPriority > currentHigh) {
latestHighPriorityByChannelRef.current.set(
channelId,
maxHighPriority,
);
didAdvance = true;
}
}
}
if (allThreadReplies.length > 0) {
Expand Down Expand Up @@ -678,6 +749,41 @@ export function useUnreadChannels(
readStateVersion,
]);

// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals
const highPriorityUnreadChannelIds = React.useMemo(() => {
if (!isReadStateReady) {
return new Set<string>();
}

return new Set(
channels
.filter((channel) => channel.id !== activeChannelId)
.filter((channel) => {
// Forced-unread channels are NOT high-priority (dot tier only).
// DM channels: any unread DM is high-priority.
if (channel.channelType === "dm") {
const latest = latestByChannelRef.current.get(channel.id);
if (latest === undefined) return false;
const readAt = getEffectiveTimestamp(channel.id);
return readAt === null || latest > readAt;
}
// Non-DM: check if there's a high-priority event newer than read marker.
const latest = latestHighPriorityByChannelRef.current.get(channel.id);
if (latest === undefined) return false;
const readAt = getEffectiveTimestamp(channel.id);
return readAt === null || latest > readAt;
})
.map((channel) => channel.id),
);
}, [
activeChannelId,
channels,
getEffectiveTimestamp,
isReadStateReady,
latestVersion,
readStateVersion,
]);

const unreadChannelIdsRef = React.useRef(unreadChannelIds);
unreadChannelIdsRef.current = unreadChannelIds;

Expand All @@ -691,12 +797,15 @@ export function useUnreadChannels(
if (unixSeconds !== null) {
markContextRead(channelId, unixSeconds);
}
latestByChannelRef.current.delete(channelId);
latestHighPriorityByChannelRef.current.delete(channelId);
}
bumpLatestVersion();
}, [getEffectiveTimestamp, markContextRead]);

return {
unreadChannelIds,
highPriorityUnreadChannelIds,
markAllChannelsRead,
markChannelRead,
markChannelUnread,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/ui/TypingIndicatorRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type TypingIndicatorRowProps = {
};

function resolveFallbackName(channel: Channel | null, pubkey: string) {
if (!channel || channel.channelType !== "dm") {
if (channel?.channelType !== "dm") {
return null;
}

Expand Down
Loading