feat(mobile+desktop): two-tier Slack-style app icon badge#802
Conversation
0721e65 to
68013bb
Compare
Two-tier badge — sidebar screenshotsCaptured via Playwright with the E2E mock bridge. Sidebar crops show the unread indicator behavior; the two-tier dock badge (dot vs numeric) is a native macOS feature that requires manual verification on the Tauri app (see checklist below). 1. Unread dot for regular messageA regular message arrives in 2. Multiple unreads across tiersA DM in 3. Mark as readRight-clicking an unread channel shows "Mark as read". Clicking it clears the unread dot and updates the badge state to 4. Mark unreadRight-clicking a read channel shows "Mark unread". This triggers the forced-unread flow — always dot tier, never numeric, even if the channel previously had @mentions. |
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.
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.
…sing dot badge 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".
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.
…t 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.
…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.
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.
…ition ReadStateNotifier rebuilds on every relay session status change, but passed remoteEnabled: false for non-connected states. The manager created during intermediate transitions (reconnecting) never fetched history or started a live subscription, so cross-device read state events were silently dropped. Always enable remote sync on the manager (the relay notifier handles reconnection internally) and add a ref.listen that calls reinitializeRemote() when the session transitions to connected -- re-fetching history and restarting the live subscription.
…s-device sync Desktop mark-as-read: the catch-up relay fetch Promise.all .then() re-populated latestByChannelRef after markChannelRead deleted it, resurrecting the unread badge. Guard the .then() with getEffectiveTimestamp so it skips channels the user already read. Also move forcedUnreadRef.delete above the null-timestamp early return so force-marked channels always clear on mark-as-read. Mobile mark-as-unread revert: ReadStateManager was missing the forcedContexts guard that desktop has. Without it, _fetchOwnBlobBeforePublish merges the old (higher) timestamp back before the 5s debounced publish fires, then skips publishing because the state now matches the relay. Add _forcedContextIds (persisted to survive manager rebuilds) mirroring desktop's forcedContexts. Cross-device mark-as-unread sync: both platforms used strictly monotonic merge (if value > current), silently discarding the lower timestamp that mark-as-unread publishes. Add per-context source tracking (_contextSourceCreatedAt) — for each context, the value from the event with the highest created_at wins, enabling rollbacks from other devices to propagate.
Parallelize mobile high-priority backfill with Future.wait instead of sequential awaits, emit state after backfill so badge provider re-evaluates, and skip self-authored events in both backfill and live handler. Guard _maxFetchedCreatedAt against far-future relay timestamps to prevent clock poisoning. Merge desktop unreadChannelIds and highPriorityUnreadChannelIds into a single memo, deduplicate channels.find in catch-up loop, and remove dead ?? "" after null guard. Clear macOS badge label explicitly when transitioning from dot to none. Add E2E tests for broadcast reply badge and mark-as-read badge clearing. Add mobile unit tests for unread badge provider.
…ction support The screenshot helper could only inject messages into the active channel, limiting it to content-display screenshots. Badge, notification, and unread state testing need messages in non-active channels. Adds --active-channel (view one channel while messages target others), --right-click (context menus), --hover (hover states), full message field passthrough (mentionPubkeys, extraTags, parentEventId), and multi-channel message support.
…aceholders
The screenshot helper produced full-viewport captures regardless of what
UI region was relevant. Adds --clip x,y,w,h to crop to a specific region
(e.g. 0,0,256,720 for sidebar-only). Also adds {{filename}} placeholder
support to post-screenshots.sh so images can be inlined at specific
positions in the PR comment body instead of always appended at the end.
The IMAGE_URL_MAP was keyed by the tree-path name (pr-802--filename)
instead of the original filename, so {{filename}} placeholders in the
body file never matched. Build the map from the source PNGS array.
Badge E2E test assumed a clean slate but watercooler mock channel has seeded forum history that survives as unread — record baseline badge state before injecting the test message and assert return-to-baseline rather than hardcoded "none". Mobile backfill used limit:50 without since, missing high-priority events beyond page 50. Now uses the read marker as since and limit:200. Batches backfill requests (5 concurrent) to avoid relay saturation. _contextSourceCreatedAt was not persisted to SharedPreferences, losing last-writer-wins protection on app restart. Now persisted alongside forcedContextIds. reinitializeRemote could race with an in-flight _publish at await suspension points. Now awaits the publish Completer before proceeding. Stabilize highPriorityUnreadChannelIds Set identity with content-based comparison to avoid spurious downstream memo re-runs. Validate PNG filenames in post-screenshots.sh before git mktree.
… unread filtering Desktop's value-monotonic merge (ts > current) rejected rolled-back timestamps from mark-as-unread events arriving from other devices. Port mobile's envelope-based merge (event.created_at comparison) so newer events carrying lower context values are accepted. Mobile bumped lastMessageAt for every relay event including reactions, edits, and non-participant thread replies. Add shouldNotifyForEvent gate matching desktop's kind/self-author/thread-participation filters.
…esktop CI Three root causes from manual E2E testing of 2560a59: 1. _fetch() seeded lastMessageAt from channelEventKinds (broad: reactions, edits, thread replies) with limit:1. A non-participant thread reply as the newest event inflated lastMessageAt on every app launch and foreground resume, bypassing the _handleLiveEvent filter. Now uses channelMessageEventKinds with shouldNotifyForEvent client-side filter (limit:20 for non-DM, limit:1 for DM). 2. _forcedContextIds persisted to SharedPreferences on mobile but not on desktop. Stale entries from prior mark-as-unread sessions permanently blocked incoming remote events for those channels. Now session-scoped only (matching desktop). Tie-break merge also rejected rollbacks when event.createdAt == sourceCreatedAt — changed > to != in both platforms. 3. ReadonlySet<string> from Set identity stabilization (81a07a9) wasn't propagated to 7 downstream prop/param sites, breaking tsc. Also paginates kind:39002 membership fetch on both mobile and desktop (until-based cursor loop, page size 500) so channels beyond the previous hard limits (500 mobile, 1000 desktop) are discovered.
nostr::Timestamp::as_u64 was deprecated in favor of as_secs.
…e member counts Kind:30078 read-state events have channel_id=None, so dispatch_persistent_event skipped Redis — other relay pods never saw them, breaking cross-device sync on multi-node staging. Use Uuid::nil() as the pubsub channel for global events; the subscriber in main.rs already handles this sentinel. Mobile channels_provider hardcoded memberCount: 0. Add a batch kind:39002 fetch after metadata dedup to count unique p-tag pubkeys per channel, matching desktop's get_channels approach.
mergeDetails unconditionally overwrote memberCount with details.memberCount, which is always 0 — ChannelDetails is built from kind:39000 metadata that carries no member information. The batch kind:39002 fetch in channelsProvider correctly populates the count, but mergeDetails clobbered it before the UI rendered. Preserve the base channel's memberCount instead. Also restores desktop/scripts/check-file-sizes.mjs to main's simplified format — rebase replayed branch commits that modified the old per-file overrides map, re-introducing the pre-#839 format.
42e079a to
da5ccd3
Compare
* origin/main: (36 commits) fix: use immutable commit-SHA URLs in screenshot PR comments (#842) feat(mobile+desktop): two-tier Slack-style app icon badge (#802) chore: simplify file-size check to a flat 1000-line limit (#839) fix(desktop): robust emoji picker — unify picker + fix custom emoji in editing, status, reactions (#837) feat(desktop): reusable screenshot workflow for agents (#826) desktop(mesh-llm): let a serving node route a different model (#833) chore(release): release version 0.3.9 (#832) fix: native arbitrary-file download + image context-menu flash (#830) fix(desktop): custom emoji reaction rendering + picker autofocus (#831) Mesh-LLM v1: relay-gated direct-iroh inference between users (WAN) (#822) chore(release): release version 0.3.8 (#829) chore(release): release version 0.3.7 (#825) feat: code block rendering, syntax highlighting, and compose fixes (#803) feat: custom emoji — user-owned NIP-30 sets with a client-side union (#816) Install sprout-cli skill at repo root + fix desktop clippy (#818) fix(desktop): use public re-export path for ensure_client_node_for_model (#824) refactor(desktop): feature-gate mesh-llm-sdk behind optional Cargo feature (#823) fix(desktop): align workflow read/save commands to the frontend contract (#820) fix(desktop): disable mesh-llm auto-build to prevent git config corruption (#819) fix(desktop): clear clippy lints in agents/mesh_llm commands (#817) ... # Conflicts: # Cargo.lock # desktop/scripts/check-file-sizes.mjs # desktop/src-tauri/Cargo.toml # desktop/src/app/AppShell.tsx # desktop/src/app/AppTopChrome.tsx # desktop/src/features/messages/hooks.ts # desktop/src/features/workspaces/useWorkspaceInit.ts # desktop/src/shared/api/tauri.ts







This PR adds Slack-style two-tier app icon badge behavior on both desktop and mobile. Previously the desktop badge was a flat sum of all unread channels + home feed items, and mobile had no badge at all.
Two-tier model:
["p", pubkey]), broadcast replies (["broadcast", "1"]) + unseen Home feed items (desktop only)setBadgeLabel(" "); iOS approximates with badge count1Forced-unread (right-click "mark unread") → dot tier only, not numeric.
Desktop
isHighPriorityEventForUser()classifies events; parallellatestHighPriorityByChannelReftracks high-priority timestamps per channel; mergeduseMemocomputes bothunreadChannelIdsandhighPriorityUnreadChannelIdsin one pass with Set identity stabilizationAppBadgeStatediscriminated union (none | dot | count) withsetDesktopAppBadge()— macOS dot viasetBadgeLabel(" "), numeric viasetBadgeCount(n)markChannelReadto usemax(callerTimestamp, latestByChannelRef)+ clear observed-latest refs after read, fixing stale React Query cache andT > Tstuck-unread raceslatestByChannelRefafter mark-as-readhomeBadgeCountfromhighPriorityUnreadChannelIdsso a single @mention shows badge1, not2contextSourceCreatedAt) so mark-as-unread rollbacks propagate cross-device; tie-break accepts!=instead of>Uuid::nil()indispatch_persistent_event— fixes cross-node sync on multi-pod deploymentsuntil-based cursor loop (page size 500)ReadonlySet<string>widening at 7 downstream prop/param sitesMobile
unreadBadgeProvider+isHighPriorityEvent()with historical backfill (batched,since-filtered, 5 concurrent)shouldNotifyForEventgate on_handleLiveEventand_fetch()lastMessageAtseeding — thread replies from non-participants no longer trigger unreadReadStateManagerwithreinitializeRemote()on session connect,_contextSourceCreatedAtLWW merge, tie-break!=fix,_forcedContextIdsmade session-scoped (matching desktop)Channel.mergeDetails()to preservememberCountfromchannelsProviderinstead of clobbering with 0 from kind:39000 metadata.badgepermission viaUNUserNotificationCenter.requestAuthorizationunreadBadgeProviderTooling
screenshot.mjs:--active-channel,--right-click,--hover,--clip,{{filename}}placeholders, full message field passthroughpost-screenshots.sh: PNG filename validation