Skip to content

feat: always notify on DM messages like Slack/Discord#405

Merged
wesbillman merged 6 commits into
mainfrom
dm-always-notify
Apr 27, 2026
Merged

feat: always notify on DM messages like Slack/Discord#405
wesbillman merged 6 commits into
mainfrom
dm-always-notify

Conversation

@wesbillman

@wesbillman wesbillman commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

DM notifications now fire for every incoming DM, matching Slack/Discord behavior. Previously, only DMs containing explicit @mentions triggered desktop notifications.

Approach

After review feedback from Tyler and Pip, the original server-side UNION ALL approach was replaced with a client-side WebSocket hook for instant DM notifications, plus a first-load suppression fix for offline catch-up.

Three layers of notification delivery:

  1. Real-time WS hook (useLiveChannelUpdates.ts) — instant DM notification via onDmMessage callback, with:

    • 500ms warmup guard to suppress EOSE backlog
    • Dedup via seenDmEventIdsRef (capped at 200)
    • Self-message filtering
    • Active-channel awareness (no toast for the DM you're reading)
    • Reconnect re-arm (warmup guard resets on WS reconnect)
  2. Feed poll catch-up (hooks.ts) — offline DMs caught on next feed poll:

    • seenItemIdsRef initialized from localStorage instead of empty set
    • Removed hasInitializedFeedRef early-return that was swallowing first-load items
    • Seen IDs persisted after each notification cycle
  3. Server-side channel_type enrichment (relay API) — Home feed items now carry channelType for display and filtering

Changes

  • crates/sprout-db/src/feed.rs — Reverted UNION ALL, kept shared helpers
  • crates/sprout-relay/src/api/feed.rschannel_type field in feed response
  • desktop/src-tauri/src/models.rsFeedItemInfo.channel_type in Tauri bridge
  • desktop/src/features/channels/useLiveChannelUpdates.ts — DM WS hook with warmup, dedup, reconnect handling
  • desktop/src/app/AppShell.tsxhandleDmNotification wired to sendDesktopNotification
  • desktop/src/features/notifications/hooks.ts — First-load suppression fix, localStorage persistence
  • desktop/src/features/notifications/lib/feed.ts — DM title, excluded DMs from feed notification path
  • desktop/src/shared/api/types.tschannelType on FeedItem
  • desktop/src/shared/api/tauri.tschannel_type transformer

Test plan

  • Receive a DM while the app is open → desktop notification fires instantly
  • Receive a DM while viewing that DM channel → no notification
  • Receive a DM while the app is closed → notification fires on next startup
  • Switch accounts → notifications use correct seen set from localStorage
  • WebSocket reconnect → no stale notifications from replay backlog
  • @mention in a regular channel → still triggers feed notification
  • DM with @mention → single notification (not duplicated)

🤖 Generated with Claude Code

wesbillman and others added 3 commits April 27, 2026 08:35
DM messages previously only triggered desktop notifications when they
contained explicit @mentions, because the notification system is driven
by the home feed's mentions query which joins event_mentions — a table
only populated for events with p-tags. DMs without @mentions were
invisible to the notification pipeline.

This adds a UNION ALL to query_mentions that also returns all messages
from DM channels (channel_type = 'dm'), excluding the user's own. The
feed response now includes channel_type on each item so the frontend
can distinguish DMs. eligibleFeedNotificationItems always includes DM
items regardless of the mentions toggle, and notificationTitle shows
"Direct message" instead of "@Mention" for DM items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add channel_type field to FeedItemInfo in Tauri serde bridge so the
  relay value actually reaches the frontend (was silently dropped).
- Wrap each UNION ALL half in parenthesized subqueries with independent
  ORDER BY … LIMIT so active DMs cannot push @mentions out of results.
- Add NOT EXISTS guard on event_mentions in the DM branch to prevent
  duplicate notifications when a DM also contains an explicit @mention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract EVENT_COLS / EVENT_COLS_UNALIASED constants, push_channel_id_filter(),
and collect_stored_events() to eliminate duplication across the three feed
query functions. No behavioral change — generated SQL is identical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wesbillman

Copy link
Copy Markdown
Collaborator Author

@codex review please

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9e57f7747

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +128 to +130
let channel_type_map: HashMap<uuid::Uuid, String> = all_channels
.into_iter()
.map(|c| (c.id, c.channel_type))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate channel type from feed query results

This map is derived from list_channels(None), which only returns the 1000 most recent channels (sprout-db/src/channel.rs), so older-but-accessible channels are missing. When feed events come from those channels, channel_type later falls back to an empty string and the desktop logic no longer classifies them as DMs, so DM notifications can be skipped when mentions are disabled. This only manifests in larger deployments, but it directly breaks the new “DMs always notify” behavior.

Useful? React with 👍 / 👎.

wesbillman and others added 3 commits April 27, 2026 10:05
…DM notifications

The original approach added a UNION ALL subquery in query_mentions to include
DM messages in the feed. After review feedback, this is replaced with a
client-side WebSocket hook that fires instant DM desktop notifications.

Changes:
- Revert UNION ALL in query_mentions (back to single indexed query)
- Add onDmMessage callback in useLiveChannelUpdates with warmup guard,
  dedup, self-message filtering, and active-channel awareness
- Wire handleDmNotification in AppShell through sendDesktopNotification
- Fix first-load suppression in useFeedDesktopNotifications: initialize
  seenItemIdsRef from localStorage instead of empty set, persist after
  each notification cycle, remove hasInitializedFeedRef early-return
- Exclude DMs from eligibleFeedNotificationItems to prevent double toasts
- Re-arm warmup guard on WebSocket reconnect to suppress replay backlog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses three review blockers:

1. 500ms warmup window permanently dropped real DMs — replaced with
   deterministic `event.created_at < subscriptionStartedAt` check so
   only backlog events are suppressed, never live messages.

2. Reconnect race condition from overlapping setTimeout timers —
   reconnect handler now simply updates subscriptionStartedAt, which
   naturally suppresses replayed historical events without any timers.

3. Empty localStorage causes notification flood on first feed — added
   a guard that seeds seenItemIdsRef without notifying when the seen
   set is empty and the feed has items (first load or cleared storage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…constant

The helper is used for both mention and DM dedup — the old name was
misleading. Also replaced inline MAX_SEEN_FEED_ITEMS with the existing
module-level HOME_FEED_SEEN_MAX_ITEMS constant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@tlongwell-block tlongwell-block left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent Review (Pip / @tlongwell-block)

Score: 7/10 — Mergeable. Architecture is correct, three original blockers are fixed. Two minor follow-up items remain.

What's solid

  • WS hook for instant DM notifications — correct, follows existing patterns
  • Timestamp-based backlog suppression — deterministic, no timing races
  • Feed catch-up with localStorage persistence — offline mentions now notify
  • DM exclusion from feed path prevents double-toasting
  • Backend refactor (shared helpers) is clean and behavior-preserving
  • channel_type enrichment flows correctly through the full stack

Follow-up items (non-blocking, for a separate PR)

1. DMs during brief disconnect are lost.
On reconnect, dmSubscriptionStartedAtRef is set to Date.now()/1000. Replay events from the disconnect window have created_at < now, so they're suppressed. The feed path also excludes DMs (eligibleFeedNotificationItems filters channelType !== "dm"). Result: DMs arriving during a 5-60s disconnect are permanently missed by both paths.

Fix: On reconnect, set dmSubscriptionStartedAtRef to lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS (matching the relay's replay since filter) instead of Date.now(). This lets genuinely-missed DMs through while the dedup set prevents double-notification of already-seen events.

2. Empty-seen-set guard can suppress first real feed alert.
If the first feed poll returns 0 items (no mentions yet), seenItemIdsRef stays empty. When the second poll returns a real mention, the guard (size === 0 && items.length > 0) triggers and seeds without notifying. The first real alert is dropped.

Fix: Track initialization separately — use a simple hasSeededRef boolean that's set after the first seed, regardless of whether it came from localStorage or the guard. Only seed once.

Verified non-issues

  • Reconnect race: JS event loop guarantees emitReconnectIfNeeded() fires (updating the timestamp) before any replay events can be dispatched to handlers. The ordering in relayClientSession.ts:311-312 is safe.
  • Boundary second: An event with created_at === subscriptionStartedAt passes through. This is at most one false-positive notification, never a loss. Acceptable.

@wesbillman wesbillman merged commit 8abeb3d into main Apr 27, 2026
13 checks passed
@wesbillman wesbillman deleted the dm-always-notify branch April 27, 2026 20:10
tlongwell-block added a commit that referenced this pull request Apr 28, 2026
* origin/main:
  Add multi-workspace support to desktop app (#409)
  feat(mobile): add #channel autocomplete to compose bar (#411)
  fix: close race window that dropped active channel messages (#410)
  feat(pulse): rich text editor with @mentions, media uploads, and formatting (#407)
  feat(mobile): multi-workspace support (#408)
  feat: always notify on DM messages like Slack/Discord (#405)
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