From 842dd64bfcb5b741137e449a32e7ea92ec47e932 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 21:23:10 -0400 Subject: [PATCH 1/7] feat(notifications): two-tier Slack-style app badge Numeric badge for DMs, @mentions, and broadcast mentions; dot-only badge for general channel unread activity. macOS dock shows a red dot via setBadgeLabel(""); iOS approximates with badge count 1. --- desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src/app/AppShell.tsx | 18 ++- .../features/channels/useUnreadChannels.ts | 116 ++++++++++++++++-- .../src/features/notifications/lib/desktop.ts | 29 +++++ .../notifications/lib/shouldNotify.test.mjs | 38 +++++- .../notifications/lib/shouldNotify.ts | 18 +++ mobile/lib/app.dart | 12 ++ .../features/channels/channels_provider.dart | 17 ++- .../unread_badge/is_high_priority_event.dart | 14 +++ .../unread_badge/unread_badge_provider.dart | 64 ++++++++++ mobile/pubspec.lock | 8 ++ mobile/pubspec.yaml | 1 + 12 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 mobile/lib/features/channels/unread_badge/is_high_priority_event.dart create mode 100644 mobile/lib/features/channels/unread_badge/unread_badge_provider.dart diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index fa26f0285..64cc252d4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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", 885], // 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", 815], // 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 ["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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 9edce35d1..4d1f535ee 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -32,7 +32,7 @@ import { requestDockBounce, revealDesktopAppWindow, sendDesktopNotification, - setDesktopAppBadgeCount, + setDesktopAppBadge, type DesktopNotificationTarget, } from "@/features/notifications/lib/desktop"; import { playNotificationSound } from "@/features/notifications/lib/sound"; @@ -285,6 +285,7 @@ export function AppShell() { markChannelRead, markChannelUnread, unreadChannelIds, + highPriorityUnreadChannelIds, getEffectiveTimestamp: getChannelReadAt, readStateVersion, participatedRootIds, @@ -488,8 +489,19 @@ export function AppShell() { }, []); React.useEffect(() => { - void setDesktopAppBadgeCount(unreadChannelIds.size + homeBadgeCount); - }, [homeBadgeCount, unreadChannelIds.size]); + const numericCount = highPriorityUnreadChannelIds.size + homeBadgeCount; + if (numericCount > 0) { + void setDesktopAppBadge({ kind: "count", count: numericCount }); + } else if (unreadChannelIds.size > 0) { + void setDesktopAppBadge({ kind: "dot" }); + } else { + void setDesktopAppBadge({ kind: "none" }); + } + }, [ + homeBadgeCount, + highPriorityUnreadChannelIds.size, + unreadChannelIds.size, + ]); // Dispatch `sprout://message` deep links into the router. useMessageDeepLinks(); diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index bfdb4b351..208b3c45d 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -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"; @@ -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()); + const latestHighPriorityByChannelRef = React.useRef( + new Map(), + ); + + 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 @@ -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 @@ -362,9 +372,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( @@ -492,6 +521,7 @@ export function useUnreadChannels( channelId: string; ok: true; maxExternal: number; + maxHighPriority: number; threadReplies: ThreadActivityItem[]; } | { channelId: string; ok: false }; @@ -542,7 +572,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 ( @@ -567,6 +601,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({ @@ -582,7 +625,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 @@ -599,13 +648,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) { @@ -678,6 +740,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(); + } + + 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; @@ -697,6 +794,7 @@ export function useUnreadChannels( return { unreadChannelIds, + highPriorityUnreadChannelIds, markAllChannelsRead, markChannelRead, markChannelUnread, diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index f42292132..48548ca21 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -5,11 +5,17 @@ import { onAction, requestPermission, } from "@tauri-apps/plugin-notification"; +import { isMacPlatform } from "@/shared/lib/platform"; export type DesktopNotificationPermissionState = | NotificationPermission | "unsupported"; +export type AppBadgeState = + | { kind: "none" } + | { kind: "dot" } + | { kind: "count"; count: number }; + export type DesktopNotificationTarget = { channelId: string | null; channelName?: string | null; @@ -208,6 +214,29 @@ export async function setDesktopAppBadgeCount(count: number): Promise { } } +export async function setDesktopAppBadge(state: AppBadgeState): Promise { + if (typeof window !== "undefined") { + (window as TestWindow).__SPROUT_E2E_APP_BADGE_COUNT__ = + state.kind === "count" ? state.count : 0; + } + + if (!isTauri()) { + return; + } + + try { + if (state.kind === "count") { + await getCurrentWindow().setBadgeCount(state.count); + } else if (state.kind === "dot" && isMacPlatform()) { + await getCurrentWindow().setBadgeLabel(""); + } else { + await getCurrentWindow().setBadgeCount(undefined); + } + } catch { + // Ignore unsupported platforms and best-effort badge sync failures. + } +} + export async function requestDockBounce(): Promise { if (!isTauri()) { return; diff --git a/desktop/src/features/notifications/lib/shouldNotify.test.mjs b/desktop/src/features/notifications/lib/shouldNotify.test.mjs index e099f6733..82f4bf054 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.test.mjs +++ b/desktop/src/features/notifications/lib/shouldNotify.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldNotifyForEvent } from "./shouldNotify.ts"; +import { + isHighPriorityEventForUser, + shouldNotifyForEvent, +} from "./shouldNotify.ts"; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -317,3 +320,36 @@ test("empty currentPubkey with participated thread still notifies (no mute)", () true, ); }); + +// ── isHighPriorityEventForUser ──────────────────────────────────────────────── + +test("isHighPriorityEventForUser returns true when p-tag matches currentPubkey", () => { + const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY)]); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), true); +}); + +test("isHighPriorityEventForUser returns true for broadcast reply", () => { + const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), true); +}); + +test("isHighPriorityEventForUser returns false when no matching p-tag and no broadcast tag", () => { + const event = makeEvent([replyTag(ROOT_ID), pTag(OTHER_PUBKEY)]); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), false); +}); + +test("isHighPriorityEventForUser p-tag matching is case-insensitive", () => { + const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY.toUpperCase())]); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), true); +}); + +test("isHighPriorityEventForUser returns false when currentPubkey is empty", () => { + // Short-circuits before p-tag check; broadcast absent so also false + const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY)]); + assert.equal(isHighPriorityEventForUser(event, ""), false); +}); + +test("isHighPriorityEventForUser returns false for event with no tags at all", () => { + const event = makeEvent([]); + assert.equal(isHighPriorityEventForUser(event, PUBKEY), false); +}); diff --git a/desktop/src/features/notifications/lib/shouldNotify.ts b/desktop/src/features/notifications/lib/shouldNotify.ts index 5c32b0d2e..84355c1cd 100644 --- a/desktop/src/features/notifications/lib/shouldNotify.ts +++ b/desktop/src/features/notifications/lib/shouldNotify.ts @@ -49,3 +49,21 @@ export function shouldNotifyForEvent( return false; } + +export function isHighPriorityEventForUser( + event: RelayEvent, + currentPubkey: string, +): boolean { + if ( + currentPubkey.length > 0 && + event.tags.some( + (tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey, + ) + ) { + return true; + } + if (isBroadcastReply(event.tags)) { + return true; + } + return false; +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index a709e2f88..b63235072 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,8 +1,10 @@ +import 'package:app_badge_plus/app_badge_plus.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; @@ -37,6 +39,16 @@ class App extends HookConsumerWidget { ref.watch(userStatusCacheProvider); } + ref.listen(unreadBadgeProvider, (_, next) { + if (next.highPriorityCount > 0) { + AppBadgePlus.updateBadge(next.highPriorityCount); + } else if (next.generalUnreadCount > 0) { + AppBadgePlus.updateBadge(1); + } else { + AppBadgePlus.updateBadge(0); + } + }); + return MaterialApp( title: 'Sprout', theme: AppTheme.light(colorScheme: lightScheme), diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 13997807b..4ae71e217 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -7,6 +7,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; import 'channel.dart'; import 'channel_management_provider.dart' show channelDetailsProvider; +import 'unread_badge/is_high_priority_event.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; @@ -26,6 +27,10 @@ class ChannelsNotifier extends AsyncNotifier> { final List _unsubscribers = []; int _subscriptionVersion = 0; Timer? _backstopTimer; + final Map _latestHighPriorityByChannel = {}; + + Map get latestHighPriorityByChannel => + Map.unmodifiable(_latestHighPriorityByChannel); @override Future> build() { @@ -293,7 +298,6 @@ class ChannelsNotifier extends AsyncNotifier> { state = state.whenData((channels) { final idx = channels.indexWhere((c) => c.id == channelId); if (idx == -1) { - // Unknown channel — queue a full refresh to pick it up. refresh(); return channels; } @@ -307,6 +311,16 @@ class ChannelsNotifier extends AsyncNotifier> { eventTime.isAfter(channel.lastMessageAt!)) { updated[idx] = channel.copyWith(lastMessageAt: eventTime); } + + final myPk = ref.read(myPubkeyProvider); + if (myPk != null && + (channel.isDm || isHighPriorityEvent(event.tags, myPk))) { + final current = _latestHighPriorityByChannel[channelId] ?? 0; + if (event.createdAt > current) { + _latestHighPriorityByChannel[channelId] = event.createdAt; + } + } + return updated; }); } @@ -341,6 +355,7 @@ class ChannelsNotifier extends AsyncNotifier> { unsubscribe(); } _unsubscribers.clear(); + _latestHighPriorityByChannel.clear(); _backstopTimer?.cancel(); _backstopTimer = null; } diff --git a/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart b/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart new file mode 100644 index 000000000..4195e3a54 --- /dev/null +++ b/mobile/lib/features/channels/unread_badge/is_high_priority_event.dart @@ -0,0 +1,14 @@ +bool isHighPriorityEvent(List> tags, String currentPubkey) { + final normalizedPubkey = currentPubkey.toLowerCase(); + for (final tag in tags) { + if (tag.length >= 2 && + tag[0] == 'p' && + tag[1].toLowerCase() == normalizedPubkey) { + return true; + } + if (tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1') { + return true; + } + } + return false; +} diff --git a/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart new file mode 100644 index 000000000..c57be87d4 --- /dev/null +++ b/mobile/lib/features/channels/unread_badge/unread_badge_provider.dart @@ -0,0 +1,64 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../channels_provider.dart'; +import '../read_state/read_state_provider.dart'; +import '../read_state/read_state_time.dart'; + +class UnreadBadgeState { + const UnreadBadgeState({ + this.highPriorityCount = 0, + this.generalUnreadCount = 0, + }); + + final int highPriorityCount; + final int generalUnreadCount; +} + +final unreadBadgeProvider = Provider((ref) { + final channelsAsync = ref.watch(channelsProvider); + final readState = ref.watch(readStateProvider); + + return channelsAsync.when( + data: (channels) { + // Safe to ref.read the notifier here: _latestHighPriorityByChannel is + // only mutated inside _handleLiveEvent's state.whenData block, which + // always emits a new channelsProvider state — so the ref.watch above + // guarantees we re-run whenever the map changes. + final notifier = ref.read(channelsProvider.notifier); + final highPriorityMap = notifier.latestHighPriorityByChannel; + + int highPriority = 0; + int general = 0; + + for (final channel in channels) { + if (!channel.isMember || channel.isArchived) continue; + + final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt); + if (lastMessageAt == null) continue; + + final readAt = readState.effectiveTimestamp(channel.id); + final isUnread = readAt == null || lastMessageAt > readAt; + if (!isUnread) continue; + + if (channel.isDm) { + highPriority++; + } else { + final highPriorityAt = highPriorityMap[channel.id]; + if (highPriorityAt != null && + (readAt == null || highPriorityAt > readAt)) { + highPriority++; + } else { + general++; + } + } + } + + return UnreadBadgeState( + highPriorityCount: highPriority, + generalUnreadCount: general, + ); + }, + loading: () => const UnreadBadgeState(), + error: (_, _) => const UnreadBadgeState(), + ); +}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 8569de00e..fcf0da714 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.10" + app_badge_plus: + dependency: "direct main" + description: + name: app_badge_plus + sha256: c0d9e318d0c516764945d0d1997d4e99e9b7d26523e8404a35c70c9fe5ec249b + url: "https://pub.dev" + source: hosted + version: "1.2.10" args: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8c6b78193..9f9814f23 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: image_picker: ^1.1.2 video_player: ^2.10.1 package_info_plus: ^10.0.0 + app_badge_plus: ^1.2.10 dev_dependencies: flutter_test: From 1d50c46ac1e74ad8ddf1a0291242ee60b1203592 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 12:57:28 -0400 Subject: [PATCH 2/7] fix(notifications): resolve badge race condition and mobile state bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unread tracker dropped events for the active channel before recording their timestamps, so messages arriving during the brief navigation window between channels never produced a badge even though the dock bounced. Decouple timestamp recording from the active-channel guard — the unread memo already filters the active channel. On mobile, the backstop timer wiped the high-priority classification map every 60 seconds (and on every foreground resume), demoting @mentions to general tier. Move the clear to identity/session reset only. Also backfill the map from recent event history on startup so pre-existing @mentions get numeric badges immediately. --- .../channels/useLiveChannelUpdates.ts | 13 ++-- .../src/features/notifications/lib/desktop.ts | 16 ----- .../features/channels/channels_provider.dart | 60 ++++++++++++++++++- .../channels/channels_provider_test.dart | 5 +- 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 5c2bb2f30..aef2ab713 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -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( @@ -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); + } } } diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 48548ca21..986e0cd97 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -198,22 +198,6 @@ export async function listenForDesktopNotificationActions( }; } -export async function setDesktopAppBadgeCount(count: number): Promise { - if (typeof window !== "undefined") { - (window as TestWindow).__SPROUT_E2E_APP_BADGE_COUNT__ = count; - } - - if (!isTauri()) { - return; - } - - try { - await getCurrentWindow().setBadgeCount(count > 0 ? count : undefined); - } catch { - // Ignore unsupported platforms and best-effort badge sync failures. - } -} - export async function setDesktopAppBadge(state: AppBadgeState): Promise { if (typeof window !== "undefined") { (window as TestWindow).__SPROUT_E2E_APP_BADGE_COUNT__ = diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 4ae71e217..a82bcbb11 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -47,12 +47,14 @@ class ChannelsNotifier extends AsyncNotifier> { ref.onDispose(() { _clearLiveSubscriptions(); + _latestHighPriorityByChannel.clear(); _backstopTimer?.cancel(); _backstopTimer = null; }); if (sessionState.status != SessionStatus.connected) { _clearLiveSubscriptions(); + _latestHighPriorityByChannel.clear(); // Preserve the last successfully loaded channels while reconnecting // instead of re-entering a loading/error state. The UI will show cached // channels with a "Reconnecting…" banner overlay, which is far better @@ -284,6 +286,10 @@ class ChannelsNotifier extends AsyncNotifier> { _unsubscribers.addAll(subscriptions.whereType()); + // Backfill high-priority map from recent history so unread @mentions that + // arrived before app launch are correctly classified as high-priority tier. + unawaited(_backfillHighPriority(channels)); + _backstopTimer?.cancel(); _backstopTimer = Timer.periodic( _backstopInterval, @@ -291,6 +297,59 @@ class ChannelsNotifier extends AsyncNotifier> { ); } + Future _backfillHighPriority(List channels) async { + final myPk = ref.read(myPubkeyProvider); + if (myPk == null) return; + + final session = ref.read(relaySessionProvider.notifier); + + for (final channel in channels) { + if (!channel.isMember || channel.isArchived) continue; + + // All DM messages are high-priority — no need to scan event history. + if (channel.isDm) { + final lastMsg = channel.lastMessageAt; + if (lastMsg != null) { + _latestHighPriorityByChannel[channel.id] = + lastMsg.millisecondsSinceEpoch ~/ 1000; + } + continue; + } + + // For non-DM channels, fetch recent events and scan for high-priority ones. + try { + final events = await session.fetchHistory( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channel.id], + }, + limit: 50, + ), + ); + + var maxHighPriority = 0; + for (final event in events) { + if (isHighPriorityEvent(event.tags, myPk) && + event.createdAt > maxHighPriority) { + maxHighPriority = event.createdAt; + } + } + + if (maxHighPriority > 0) { + final current = _latestHighPriorityByChannel[channel.id] ?? 0; + if (maxHighPriority > current) { + _latestHighPriorityByChannel[channel.id] = maxHighPriority; + } + } + } catch (error) { + debugPrint( + '[ChannelsNotifier] backfill failed for ${channel.id}: $error', + ); + } + } + } + void _handleLiveEvent(NostrEvent event) { final channelId = event.channelId; if (channelId == null) return; @@ -355,7 +414,6 @@ class ChannelsNotifier extends AsyncNotifier> { unsubscribe(); } _unsubscribers.clear(); - _latestHighPriorityByChannel.clear(); _backstopTimer?.cancel(); _backstopTimer = null; } diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index e4a158d17..a62df820c 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -225,8 +225,9 @@ void main() { await container.read(channelsProvider.future); - // Two history fetches: memberships (kind:39002) then metadata (kind:39000). - expect(session.historyFilters, hasLength(2)); + // Two history fetches for channel loading, plus one per non-DM channel + // for high-priority event backfill. + expect(session.historyFilters.length, greaterThanOrEqualTo(2)); expect(session.historyFilters[0].kinds, [39002]); expect(session.historyFilters[0].tags['#p'], [myPk]); expect(session.historyFilters[1].kinds, [39000]); From 6eb39641d00c7167ecde88a9630b15692d0a2d14 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 14:03:04 -0400 Subject: [PATCH 3/7] fix(notifications): resolve double-count, stale mark-as-read, and missing dot badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markChannelRead used the caller-supplied channel.lastMessageAt (stale React Query cache) instead of the live event timestamp in latestByChannelRef. Mark-as-read advanced the read marker to the old value while the ref held a newer timestamp — channel stayed unread. Additionally, ChannelScreen's auto-read effect advances readAt to match the latest message's created_at while viewing. After navigation, latestByChannelRef holds the same value → T > T → false → no badge. Clearing the refs on read lets only genuinely new events re-trigger. homeBadgeCount double-counted @mentions already tracked in highPriorityUnreadChannelIds — a single mention produced badge "2". --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/app/AppShell.tsx | 1 + .../features/channels/useUnreadChannels.ts | 19 +++++++++++++++---- desktop/src/features/notifications/hooks.ts | 7 +++++++ .../src/features/notifications/lib/desktop.ts | 5 ++++- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 64cc252d4..d557e9d2f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -39,7 +39,7 @@ const overrides = new Map([ ["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", 815], // 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 + ["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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4d1f535ee..2295c707b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -324,6 +324,7 @@ export function AppShell() { selectedView === "home", getChannelReadAt, readStateVersion, + highPriorityUnreadChannelIds, feedProfilesQuery.data?.profiles, ); diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 208b3c45d..d3d3cccf0 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -306,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], ); @@ -788,6 +797,8 @@ export function useUnreadChannels( if (unixSeconds !== null) { markContextRead(channelId, unixSeconds); } + latestByChannelRef.current.delete(channelId); + latestHighPriorityByChannelRef.current.delete(channelId); } bumpLatestVersion(); }, [getEffectiveTimestamp, markContextRead]); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 3637ee984..6d4038a2a 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -268,6 +268,7 @@ export function useHomeFeedNotificationState( // Invalidation signal for the channel-marker projection; bump triggers // recompute. Pass 0 to opt out. readStateVersion: number, + highPriorityChannelIds: ReadonlySet, profiles?: UserProfileLookup, ) { useFeedDesktopNotifications( @@ -323,6 +324,11 @@ export function useHomeFeedNotificationState( const seenFeedIdSet = new Set(seenFeedIds); return currentFeedItems.filter((item) => { + if (item.channelId && highPriorityChannelIds.has(item.channelId)) { + // Already counted in the high-priority sidebar badge; skip to avoid + // double-counting the same @mention event in both totals. + return false; + } if (item.channelId) { // Channel-backed items: trust the NIP-RS marker when we have one. // If the channel has no marker yet (cold start, mock mode without a @@ -338,6 +344,7 @@ export function useHomeFeedNotificationState( }, [ currentFeedItems, getChannelReadAt, + highPriorityChannelIds, isHomeActive, readStateVersion, seenFeedIds, diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 986e0cd97..d512ef173 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -41,6 +41,7 @@ type DesktopNotificationOptions = NotificationOptions & { type TestWindow = Window & { __SPROUT_E2E_APP_BADGE_COUNT__?: number; + __SPROUT_E2E_APP_BADGE_STATE__?: AppBadgeState["kind"]; }; function hasNotificationApi() { @@ -200,8 +201,10 @@ export async function listenForDesktopNotificationActions( export async function setDesktopAppBadge(state: AppBadgeState): Promise { if (typeof window !== "undefined") { - (window as TestWindow).__SPROUT_E2E_APP_BADGE_COUNT__ = + const testWindow = window as TestWindow; + testWindow.__SPROUT_E2E_APP_BADGE_COUNT__ = state.kind === "count" ? state.count : 0; + testWindow.__SPROUT_E2E_APP_BADGE_STATE__ = state.kind; } if (!isTauri()) { From 14c53a0db3601d1fefd1df7478504f2f2d373885 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 15:40:31 -0400 Subject: [PATCH 4/7] test(notifications): add E2E tests for two-tier badge and mark-as-read The two-tier badge behavior and mark-as-read bug fixes had no Playwright coverage. Extend the mock bridge to support mention tags on emitted messages and add five tests: dot vs numeric badge classification, Bug 6 deduplication (count=1 not 2), Bug 7 mark-as-read fix, and forced-unread producing dot-only badge. --- desktop/playwright.config.ts | 1 + desktop/src/testing/e2eBridge.ts | 13 ++- desktop/tests/e2e/badge.spec.ts | 173 +++++++++++++++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 desktop/tests/e2e/badge.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e8779a953..0fd5f01d9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 502279323..b422c9908 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -453,6 +453,7 @@ declare global { parentEventId?: string | null; pubkey?: string; kind?: number; + mentionPubkeys?: string[]; }) => RelayEvent; __SPROUT_E2E_EMIT_MOCK_TYPING__?: (input: { channelName: string; @@ -1769,13 +1770,18 @@ function emitMockChannelMessage( parentEventId?: string | null, pubkey?: string, kind?: number, + mentionPubkeys?: string[], ) { const eventKind = kind ?? 9; if (!parentEventId) { const event = createMockEvent( eventKind, content, - [["h", channelId]], + buildTopLevelMessageTags( + channelId, + mentionPubkeys, + pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + ), pubkey, ); recordMockMessage(channelId, event); @@ -1802,7 +1808,7 @@ function emitMockChannelMessage( authorPubkey, parentEventId, rootEventId, - undefined, + mentionPubkeys, ), authorPubkey, ); @@ -4746,6 +4752,7 @@ export function maybeInstallE2eTauriMocks() { parentEventId, pubkey, kind, + mentionPubkeys, }) => { const channel = mockChannels.find( (candidate) => candidate.name === channelName, @@ -4760,6 +4767,7 @@ export function maybeInstallE2eTauriMocks() { parentEventId, pubkey, kind, + mentionPubkeys, ); }; window.__SPROUT_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => { @@ -5159,6 +5167,7 @@ export function maybeInstallE2eTauriMocks() { case "plugin:window|unminimize": case "plugin:window|set_focus": case "plugin:window|set_badge_count": + case "plugin:window|set_badge_label": return null; case "get_channel_workflows": return handleGetChannelWorkflows( diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts new file mode 100644 index 000000000..793a6e020 --- /dev/null +++ b/desktop/tests/e2e/badge.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, + kind?: number, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ currentChannelName, kind: k }) => { + return ( + ( + window as Window & { + __SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind?: number; + }) => boolean; + } + ).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + kind: k, + }) ?? false + ); + }, + { currentChannelName: channelName, kind }, + ); + }) + .toBe(true); +} + +async function getBadgeState(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const w = window as Window & { + __SPROUT_E2E_APP_BADGE_STATE__?: string; + __SPROUT_E2E_APP_BADGE_COUNT__?: number; + }; + return { + state: w.__SPROUT_E2E_APP_BADGE_STATE__ ?? "none", + count: w.__SPROUT_E2E_APP_BADGE_COUNT__ ?? 0, + }; + }); +} + +async function waitForBadgeState( + page: import("@playwright/test").Page, + expected: { state: string; count?: number }, +) { + await expect + .poll(async () => getBadgeState(page), { timeout: 5_000 }) + .toEqual( + expect.objectContaining({ + state: expected.state, + ...(expected.count !== undefined ? { count: expected.count } : {}), + }), + ); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +test("dot badge for regular message in inactive channel", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "random"); + + await page.evaluate( + ({ pubkey }) => { + window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Regular message, no mention", + kind: 40002, + pubkey, + }); + }, + { pubkey: TEST_IDENTITIES.alice.pubkey }, + ); + + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await waitForBadgeState(page, { state: "dot" }); +}); + +test("numeric badge for @mention in inactive channel", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "random"); + + await page.evaluate( + ({ pubkey, mentionPubkey }) => { + window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Hey @tyler check this out", + kind: 40002, + pubkey, + mentionPubkeys: [mentionPubkey], + }); + }, + { + pubkey: TEST_IDENTITIES.alice.pubkey, + mentionPubkey: DEFAULT_MOCK_PUBKEY, + }, + ); + + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await waitForBadgeState(page, { state: "count", count: 1 }); +}); + +test("numeric badge for DM message", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "alice-tyler"); + + await page.evaluate((pubkey) => { + window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "alice-tyler", + content: "Hey, got a minute?", + pubkey, + }); + }, TEST_IDENTITIES.alice.pubkey); + + await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible(); + await waitForBadgeState(page, { state: "count", count: 1 }); +}); + +test("mark-as-read via context menu clears channel unread indicator", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "random"); + + await page.evaluate( + ({ pubkey }) => { + window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Message to be marked read", + kind: 40002, + pubkey, + }); + }, + { pubkey: TEST_IDENTITIES.alice.pubkey }, + ); + + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + + await page.getByTestId("channel-random").click({ button: "right" }); + await page.getByText("Mark as read").click(); + + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); +}); + +test("mark-as-unread via context menu shows dot badge", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + + await page.getByTestId("channel-random").click({ button: "right" }); + await page.getByText("Mark unread").click(); + + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await waitForBadgeState(page, { state: "dot" }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index b13930d1c..d760f9596 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -190,6 +190,7 @@ export async function installBridge(page: Page, options: BridgeOptions) { const testWindow = window as Window & { __SPROUT_E2E__?: Record; __SPROUT_E2E_APP_BADGE_COUNT__?: number; + __SPROUT_E2E_APP_BADGE_STATE__?: string; __SPROUT_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; __SPROUT_E2E_NOTIFICATIONS__?: Array<{ body: string | null; @@ -207,6 +208,7 @@ export async function installBridge(page: Page, options: BridgeOptions) { relayWsUrl: relayWsUrl ?? currentConfig.relayWsUrl, }; testWindow.__SPROUT_E2E_APP_BADGE_COUNT__ = 0; + testWindow.__SPROUT_E2E_APP_BADGE_STATE__ = "none"; testWindow.__SPROUT_E2E_CLICK_NOTIFICATION__ = (index: number) => { const notification = notificationInstances[index]; if (!notification) { From bbfbae66bd1fb82dec23c98cef3cfcea1afb7d00 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 16:42:15 -0400 Subject: [PATCH 5/7] fix(notifications): split homeBadgeCount to prevent sidebar-home-count regression useHomeFeedNotificationState was returning a single deduped count that excluded items already in highPriorityUnreadChannelIds. This correctly prevented double-counting in the app badge formula but also zeroed out sidebar-home-count, breaking the integration test that expects the Home badge to show "1" for an unseen @mention. Return { homeBadgeCount, homeBadgeCountExcludingHighPriority } so the sidebar displays the full count while the app badge still deduplicates. --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/app/AppShell.tsx | 28 ++++++++------- desktop/src/features/notifications/hooks.ts | 40 ++++++++++++--------- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d557e9d2f..bb84a274f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,7 +34,7 @@ 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", 885], // 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/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 diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 2295c707b..84c6ddec5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -316,17 +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, - highPriorityUnreadChannelIds, - 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) => @@ -490,7 +491,8 @@ export function AppShell() { }, []); React.useEffect(() => { - const numericCount = highPriorityUnreadChannelIds.size + homeBadgeCount; + const numericCount = + highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority; if (numericCount > 0) { void setDesktopAppBadge({ kind: "count", count: numericCount }); } else if (unreadChannelIds.size > 0) { @@ -499,7 +501,7 @@ export function AppShell() { void setDesktopAppBadge({ kind: "none" }); } }, [ - homeBadgeCount, + homeBadgeCountExcludingHighPriority, highPriorityUnreadChannelIds.size, unreadChannelIds.size, ]); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 6d4038a2a..ecbf5aaf1 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -314,33 +314,39 @@ export function useHomeFeedNotificationState( // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt return React.useMemo(() => { + const zero = { homeBadgeCount: 0, homeBadgeCountExcludingHighPriority: 0 }; if (!settings.homeBadgeEnabled || isHomeActive) { - return 0; + return zero; } if (currentFeedItems.length === 0) { - return 0; + return zero; } const seenFeedIdSet = new Set(seenFeedIds); - return currentFeedItems.filter((item) => { - if (item.channelId && highPriorityChannelIds.has(item.channelId)) { - // Already counted in the high-priority sidebar badge; skip to avoid - // double-counting the same @mention event in both totals. - return false; - } + let total = 0; + let excludingHighPriority = 0; + for (const item of currentFeedItems) { + let isUnread: boolean; if (item.channelId) { - // Channel-backed items: trust the NIP-RS marker when we have one. - // If the channel has no marker yet (cold start, mock mode without a - // relay client), fall back to the local seen-set so a freshly-seen - // feed item doesn't keep tripping the badge forever. const readAt = getChannelReadAt(item.channelId); - if (readAt !== null) { - return item.createdAt > readAt; - } + isUnread = + readAt !== null + ? item.createdAt > readAt + : !seenFeedIdSet.has(item.id); + } else { + isUnread = !seenFeedIdSet.has(item.id); + } + if (!isUnread) continue; + total++; + if (!(item.channelId && highPriorityChannelIds.has(item.channelId))) { + excludingHighPriority++; } - return !seenFeedIdSet.has(item.id); - }).length; + } + return { + homeBadgeCount: total, + homeBadgeCountExcludingHighPriority: excludingHighPriority, + }; }, [ currentFeedItems, getChannelReadAt, From bf028441139f213521c7263f7e60f1f87ba14f58 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 18:25:52 -0400 Subject: [PATCH 6/7] fix(notifications): enable dot badge capability and fix mobile badge bugs Desktop: setBadgeLabel("") for the dot badge silently failed because core:window:allow-set-badge-label was missing from Tauri capabilities. Mobile: channels loaded with lastMessageAt: null (kind:39000 metadata doesn't carry message timestamps), so unread detection and badge computation treated every channel as having no messages. Added a limit:1 fetch per channel on initial load. Also fixed ref.listen not firing for the initial badge state, and added iOS badge permission request via UNUserNotificationCenter.requestAuthorization. --- desktop/src-tauri/capabilities/default.json | 1 + mobile/ios/Podfile.lock | 6 ++ mobile/ios/Runner/AppDelegate.swift | 2 + mobile/lib/app.dart | 17 +++-- .../features/channels/channels_provider.dart | 63 ++++++++++++++++++- 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 035b32799..57e0e87f4 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -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", diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index dba244fe3..22c2eb9ec 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - app_badge_plus (1.2.10): + - Flutter - connectivity_plus (0.0.1): - Flutter - Flutter (1.0.0) @@ -22,6 +24,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) @@ -33,6 +36,8 @@ DEPENDENCIES: - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) EXTERNAL SOURCES: + app_badge_plus: + :path: ".symlinks/plugins/app_badge_plus/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: @@ -53,6 +58,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/video_player_avfoundation/darwin" SPEC CHECKSUMS: + app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e5361f296..464168c6b 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,6 +1,7 @@ import AVFoundation import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -10,6 +11,7 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index b63235072..1c6c974a9 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,5 +1,6 @@ import 'package:app_badge_plus/app_badge_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -39,14 +40,22 @@ class App extends HookConsumerWidget { ref.watch(userStatusCacheProvider); } - ref.listen(unreadBadgeProvider, (_, next) { - if (next.highPriorityCount > 0) { - AppBadgePlus.updateBadge(next.highPriorityCount); - } else if (next.generalUnreadCount > 0) { + void applyBadge(UnreadBadgeState state) { + if (state.highPriorityCount > 0) { + AppBadgePlus.updateBadge(state.highPriorityCount); + } else if (state.generalUnreadCount > 0) { AppBadgePlus.updateBadge(1); } else { AppBadgePlus.updateBadge(0); } + } + + useEffect(() { + applyBadge(ref.read(unreadBadgeProvider)); + return null; + }, const []); + ref.listen(unreadBadgeProvider, (_, next) { + applyBadge(next); }); return MaterialApp( diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index a82bcbb11..d8704d0f2 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -70,7 +70,10 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - Future> _fetch({bool subscribeLive = false}) async { + Future> _fetch({ + bool subscribeLive = false, + bool fetchLastMessage = true, + }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); @@ -154,6 +157,52 @@ class ChannelsNotifier extends AsyncNotifier> { channels.add(channel); } + // Step 3: fetch the most recent message per channel to populate lastMessageAt. + // kind:39000 metadata doesn't carry message timestamps, so channels load with + // lastMessageAt: null. Without this, unread detection and badge computation + // see every channel as having no messages. Skipped on backstop refreshes since + // live subscriptions keep lastMessageAt current after the initial load. + if (fetchLastMessage) { + final lastMessageResults = await Future.wait( + channels.map((channel) async { + if (!channel.isMember || channel.isArchived) return null; + try { + final events = await session.fetchHistory( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channel.id], + }, + limit: 1, + ), + ); + if (events.isEmpty) return null; + return MapEntry(channel.id, events.first.createdAt); + } catch (_) { + return null; + } + }), + ); + + final lastMessageMap = {}; + for (final entry + in lastMessageResults.whereType>()) { + lastMessageMap[entry.key] = entry.value; + } + + for (var i = 0; i < channels.length; i++) { + final ts = lastMessageMap[channels[i].id]; + if (ts != null) { + channels[i] = channels[i].copyWith( + lastMessageAt: DateTime.fromMillisecondsSinceEpoch( + ts * 1000, + isUtc: true, + ), + ); + } + } + } + channels.sort((left, right) { final typeOrder = (_channelTypeOrder[left.channelType] ?? 99) - @@ -388,9 +437,21 @@ class ChannelsNotifier extends AsyncNotifier> { Future _backstopRefresh() async { try { final sessionState = ref.read(relaySessionProvider); + final prevChannels = state.value ?? const []; + final prevLastMessage = { + for (final c in prevChannels) + if (c.lastMessageAt != null) c.id: c.lastMessageAt, + }; final channels = await _fetch( subscribeLive: sessionState.status == SessionStatus.connected, + fetchLastMessage: false, ); + for (var i = 0; i < channels.length; i++) { + final prev = prevLastMessage[channels[i].id]; + if (channels[i].lastMessageAt == null && prev != null) { + channels[i] = channels[i].copyWith(lastMessageAt: prev); + } + } state = AsyncData(channels); } catch (error) { debugPrint('[ChannelsNotifier] backstop refresh failed: $error'); From 0ac37a54812ae54a21eecab33278ce6f41fc1e79 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 19:01:35 -0400 Subject: [PATCH 7/7] fix(notifications): use space character for macOS dot badge NSDockTile.setBadgeLabel("") is treated as nil on newer macOS versions (Ventura+), clearing the badge instead of showing a red dot. The W3C Badging API spec documents this and recommends a space character as the reliable fallback for a numberless badge circle. --- desktop/src/features/notifications/lib/desktop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index d512ef173..faab2e98d 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -215,7 +215,7 @@ export async function setDesktopAppBadge(state: AppBadgeState): Promise { if (state.kind === "count") { await getCurrentWindow().setBadgeCount(state.count); } else if (state.kind === "dot" && isMacPlatform()) { - await getCurrentWindow().setBadgeLabel(""); + await getCurrentWindow().setBadgeLabel(" "); } else { await getCurrentWindow().setBadgeCount(undefined); }