fix(desktop): stop DMs from firing duplicate desktop notifications#2105
Merged
Conversation
An incoming DM notified twice: once via the live WebSocket path (useUnreadChannels -> handleDmNotification) and once via the home-feed polling path (useFeedDesktopNotifications). The intended DM-exclusion guard in eligibleFeedNotificationItems filtered on item.channelType !== "dm", but the backend feed emits channel_type: None and channel_name: "" (feed_item_from_event), so every DM passed the guard as undefined !== "dm". Channel enrichment ran only after eligibility, too late to rescue the filter. Fix at the eligibility seam: - enrichFeedItemChannel now fills channelName and channelType independently instead of early-returning whenever a name is present. - eligibleFeedNotificationItems takes the loaded channel list and enriches mention and needs-action items BEFORE the DM filter. - useFeedDesktopNotifications passes channels through and drops the now-redundant post-hoc enrichment in the notify loop. Failover invariant preserved: a known DM notifies via WS only; a DM in a channel not yet in the channel list stays feed-eligible (the WS path also bails on unknown channels), so no notification is lost. Adds unit coverage for the three guard cases and a relay-mode Playwright spec proving exactly one notification per incoming DM (red=2 before the fix, green=1 after, 3/3 repeats). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Live testing the DM dedupe fix against the native Tauri binary still produced two toasts for a plain DM. Root cause: native get_feed serializes FeedItemInfo.channel_type (Option<String>) as null — the key is always present — while the enrichment guard in enrichFeedItemChannel tests channelType === undefined. null !== undefined, so DM feed items were never enriched and null !== "dm" sailed through the exclusion filter. The relay-mode e2e bridge omitted the key entirely (undefined), which is why the Playwright spec went green while the real binary stayed red. Fix at one seam: RawFeedItem.channel_type is now honestly string | null, and fromRawFeedItem canonicalizes null to undefined so FeedItem's declared optional contract holds at runtime everywhere downstream. The e2e bridge feed now emits channel_type: null exactly like native serde, so the existing dm-double-notification spec exercises the native wire shape: with canonicalization removed (via a cast — the null-honest type makes the naive regression a compile error), the spec fails with a duplicate toast; with it, green. Adds unit coverage for the null and present channel_type shapes. The leaked toast titled 'Needs Action in #alice-tyler' was a mention item, not a needs_action item: notificationTitle compares category === "mention" but native emits "mentions", so mention items fall through to the fallback title. That plural/singular category contract bug is broader than this fix and is filed separately. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
kalvinnchau
pushed a commit
that referenced
this pull request
Jul 19, 2026
…y-membership-check * origin/main: fix(desktop): stop DMs from firing duplicate desktop notifications (#2105) release(helm): buzz chart 0.1.6 (#2109) Route lag-tolerant reads to an optional Postgres read replica (#2084) Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Tyler: "DMs send two notifications." Every DM now carries a recipient
ptag (added to let bots auto-reply in DMs), so an incoming DM matches both the live WebSocket DM subscription and the home-feed mention query — two toasts per DM.Fix (two commits, two layered bugs)
Commit 1 — the eligibility seam. The intended DM-exclusion guard in
eligibleFeedNotificationItemsfiltered onitem.channelType !== "dm", but the feed emits no resolved channel type and enrichment ran only after eligibility. Fix: enrich mention items from the loaded channel list before the DM filter. Failover invariant: a DM in a channel not yet loaded stays feed-eligible (the WS path also bails on unknown channels), so no notification is lost.Commit 2 — the null/undefined boundary (caught by live-testing the native binary; the relay-mode e2e alone missed it). Native
get_feedserializesFeedItemInfo.channel_type(Option<String>) asnull— the key is always present — while the enrichment guard testschannelType === undefined.null !== undefined, so DM items were never enriched andnull !== "dm"sailed through. The e2e bridge omitted the key entirely (undefined), which is why Playwright went green while the real app stayed red. Fix at one seam:RawFeedItem.channel_typeis honestlystring | nullandfromRawFeedItemcanonicalizesnull → undefined, restoringFeedItem's declared contract for all downstream consumers. The bridge now emitschannel_type: nullexactly like native serde, so the e2e exercises the native wire shape.Verification
channel_typecanonicalization; full desktop suite 3171/3171 green.dm-double-notification.spec.ts(relay mode, real signed events through relay ingest): red (2 toasts) at the pre-fix baseline; red again when canonicalization is bypassed at the new bridge shape (bypass requires a type cast — the honeststring | nullmakes the naive regression a compile error); green (exactly 1 toast) with the fix.tauri devapp on this branch, 3 plain-DM probes from alice → exactly 3 toasts (all WS-path, titlealice-tyler), zero feed-path leaks across multiple 30s feed polls; confirmed both via in-app instrumentation (not committed) andusernoteddelivery records scoped to the app's notification source.Related (not fixed here)
Native feed emits
category: "mentions"(plural) while the frontend compares=== "mention"(singular) everywhere — mention items always fall through to fallback titles/labels ("Needs Action in #…" toast title, inbox category labels). Broader contract bug, filed separately.