Skip to content

feat(mobile+desktop): two-tier Slack-style app icon badge#802

Merged
wpfleger96 merged 19 commits into
mainfrom
worktree-wpfleger+two-tier-badge
Jun 4, 2026
Merged

feat(mobile+desktop): two-tier Slack-style app icon badge#802
wpfleger96 merged 19 commits into
mainfrom
worktree-wpfleger+two-tier-badge

Conversation

@wpfleger96
Copy link
Copy Markdown
Collaborator

@wpfleger96 wpfleger96 commented May 30, 2026

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:

  • Numeric badge: DMs, direct @mentions (["p", pubkey]), broadcast replies (["broadcast", "1"]) + unseen Home feed items (desktop only)
  • Dot badge: general unread activity — macOS only via setBadgeLabel(" "); iOS approximates with badge count 1
  • No badge: all channels read

Forced-unread (right-click "mark unread") → dot tier only, not numeric.

Desktop

  • isHighPriorityEventForUser() classifies events; parallel latestHighPriorityByChannelRef tracks high-priority timestamps per channel; merged useMemo computes both unreadChannelIds and highPriorityUnreadChannelIds in one pass with Set identity stabilization
  • AppBadgeState discriminated union (none | dot | count) with setDesktopAppBadge() — macOS dot via setBadgeLabel(" "), numeric via setBadgeCount(n)
  • Fix markChannelRead to use max(callerTimestamp, latestByChannelRef) + clear observed-latest refs after read, fixing stale React Query cache and T > T stuck-unread races
  • Fix catch-up relay fetch race that re-populated latestByChannelRef after mark-as-read
  • Deduplicate homeBadgeCount from highPriorityUnreadChannelIds so a single @mention shows badge 1, not 2
  • Envelope-based read state merge (contextSourceCreatedAt) so mark-as-unread rollbacks propagate cross-device; tie-break accepts != instead of >
  • Publish global events (kind:30078 read-state) to Redis via Uuid::nil() in dispatch_persistent_event — fixes cross-node sync on multi-pod deployments
  • Paginate kind:39002 membership fetch with until-based cursor loop (page size 500)
  • ReadonlySet<string> widening at 7 downstream prop/param sites
  • E2E tests: dot badge, numeric badge (@mention, DM, broadcast reply), mark-as-read, mark-as-unread

Mobile

  • unreadBadgeProvider + isHighPriorityEvent() with historical backfill (batched, since-filtered, 5 concurrent)
  • shouldNotifyForEvent gate on _handleLiveEvent and _fetch() lastMessageAt seeding — thread replies from non-participants no longer trigger unread
  • Cross-device read state sync: always-enabled ReadStateManager with reinitializeRemote() on session connect, _contextSourceCreatedAt LWW merge, tie-break != fix, _forcedContextIds made session-scoped (matching desktop)
  • Paginate kind:39002 membership fetch; batch-fetch member counts; fix Channel.mergeDetails() to preserve memberCount from channelsProvider instead of clobbering with 0 from kind:39000 metadata
  • iOS .badge permission via UNUserNotificationCenter.requestAuthorization
  • Unit tests for unreadBadgeProvider

Tooling

  • screenshot.mjs: --active-channel, --right-click, --hover, --clip, {{filename}} placeholders, full message field passthrough
  • post-screenshots.sh: PNG filename validation

@wpfleger96 wpfleger96 force-pushed the worktree-wpfleger+two-tier-badge branch 10 times, most recently from 0721e65 to 68013bb Compare June 3, 2026 19:35
wpfleger96 added a commit that referenced this pull request Jun 3, 2026
wpfleger96 added a commit that referenced this pull request Jun 3, 2026
@wpfleger96
Copy link
Copy Markdown
Collaborator Author

wpfleger96 commented Jun 3, 2026

Two-tier badge — sidebar screenshots

Captured 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 message

A regular message arrives in #random while viewing #general. The sidebar shows the unread dot next to random. Internally this triggers badge state dot (no numeric count).

01-unread-dot

2. Multiple unreads across tiers

A DM in alice-tyler, an @mention in #random, and a regular message in #engineering arrive simultaneously. All three show sidebar unread dots. Internally the badge state is count: 2 — the DM and @mention are high-priority (numeric tier), while #engineering is general (dot tier only).

02-multiple-unreads

3. Mark as read

Right-clicking an unread channel shows "Mark as read". Clicking it clears the unread dot and updates the badge state to none if no other channels are unread.

03-mark-as-read

4. Mark unread

Right-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.

04-mark-unread

wpfleger96 added a commit that referenced this pull request Jun 3, 2026
@wpfleger96 wpfleger96 marked this pull request as ready for review June 4, 2026 00:40
@wpfleger96 wpfleger96 requested a review from a team as a code owner June 4, 2026 00:40
@wpfleger96
Copy link
Copy Markdown
Collaborator Author

Dot badge for regular channel reply messages:
image

Numeric badge for high-priority notifications (@mention events in this case):
image

wpfleger96 added 13 commits June 3, 2026 20:47
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.
@wpfleger96 wpfleger96 force-pushed the worktree-wpfleger+two-tier-badge branch from 42e079a to da5ccd3 Compare June 4, 2026 00:51
@wpfleger96
Copy link
Copy Markdown
Collaborator Author

Mobile now shows accurate channel member count instead of 0 members every time:
image

@wpfleger96 wpfleger96 enabled auto-merge (squash) June 4, 2026 01:13
@wpfleger96 wpfleger96 merged commit 3b78dc5 into main Jun 4, 2026
16 checks passed
@wpfleger96 wpfleger96 deleted the worktree-wpfleger+two-tier-badge branch June 4, 2026 01:16
michaelneale added a commit that referenced this pull request Jun 4, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants