fix(desktop): split AppShell notification effects (#1248)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-24 14:51:05 -07:00
committed by GitHub
parent 4be43d7086
commit a9ce477a04
2 changed files with 203 additions and 162 deletions

View File

@@ -6,8 +6,6 @@ import { Outlet, useLocation } from "@tanstack/react-router";
import {
deriveShellRoute,
isWindowDragHandleEvent,
shouldBounceForChannelNotification,
toSearchHit,
} from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
import {
@@ -20,6 +18,7 @@ import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications";
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
@@ -33,25 +32,12 @@ import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { msgContextKey } from "@/features/channels/readState/readStateFormat";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { getThreadReference } from "@/features/messages/lib/threading";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
import {
useHomeFeedNotifications,
useHomeFeedNotificationState,
} from "@/features/notifications/hooks";
import {
listenForDesktopNotificationActions,
requestDockBounce,
revealDesktopAppWindow,
sendDesktopNotification,
setDesktopAppBadge,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import {
playNotificationSound,
resolveSlotSound,
} from "@/features/notifications/lib/sound";
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import {
@@ -84,7 +70,7 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import type { SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
@@ -177,57 +163,6 @@ export function AppShell() {
identityQuery.data?.pubkey,
refetchHomeFeedFromLiveSignal,
);
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
if (!shouldBounceForChannelNotification(event.tags)) return;
if (!notificationSettings.settings.desktopEnabled) return;
void requestDockBounce();
},
);
const handleDmNotification = React.useEffectEvent(
(event: RelayEvent, channel: Channel) => {
if (
!notificationSettings.settings.desktopEnabled ||
!notificationSettings.settings.slotAlertsEnabled.dm
) {
return;
}
const channelName = channel.name?.trim() || "Direct message";
const content = event.content.trim();
const body =
content.length > 0
? content.length > 140
? `${content.slice(0, 137).trimEnd()}...`
: content
: "New message";
const threadRootId = getThreadReference(event.tags).rootId ?? null;
void sendDesktopNotification({
title: channelName,
body,
target: {
channelId: channel.id,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(
resolveSlotSound(notificationSettings.settings, "dm"),
);
void requestDockBounce();
});
},
);
const channelsQuery = useChannelsQuery();
const { refetch: refetchChannels } = channelsQuery;
const channels = channelsQuery.data ?? [];
@@ -251,56 +186,18 @@ export function AppShell() {
[channels, selectedChannelId],
);
const handleThreadReplyDesktopNotification = React.useEffectEvent(
(channelId: string, event: RelayEvent) => {
if (
!notificationSettings.settings.desktopEnabled ||
!notificationSettings.settings.slotAlertsEnabled.thread_reply
) {
return;
}
// Replies that @-mention the user are owned by the home-feed mention
// path — skip them here so they don't notify (and sound) twice.
const pubkey = identityQuery.data?.pubkey?.trim().toLowerCase() ?? "";
if (hasMentionForEvent(event, pubkey)) {
return;
}
const channel = channels.find((entry) => entry.id === channelId);
const channelName = channel?.name?.trim() || "Thread";
const content = event.content.trim();
const body =
content.length > 0
? content.length > 140
? `${content.slice(0, 137).trimEnd()}...`
: content
: "New reply";
const threadRootId = getThreadReference(event.tags).rootId ?? null;
void sendDesktopNotification({
title: `Reply in ${channelName}`,
body,
target: {
channelId,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(
resolveSlotSound(notificationSettings.settings, "thread_reply"),
);
void requestDockBounce();
});
},
);
const {
handleChannelNotification,
handleDmNotification,
handleThreadReplyDesktopNotification,
} = useAppShellDesktopNotifications({
channels,
goChannel,
goHome,
notificationSettings: notificationSettings.settings,
openSearchHit,
pubkey: identityQuery.data?.pubkey,
});
const {
followedRootIds,
@@ -517,25 +414,6 @@ export function AppShell() {
[openSearchHit],
);
const handleDesktopNotificationAction = React.useEffectEvent(
async (target: DesktopNotificationTarget) => {
await revealDesktopAppWindow();
if (!target.channelId) {
void goHome();
return;
}
const anchor = toSearchHit(target);
if (!anchor) {
await goChannel(target.channelId);
return;
}
await openSearchHit(anchor);
},
);
// Prevent webview file:/// navigation on file drop outside the composer.
// Scoped to file drags only (text drag-and-drop into inputs still works).
// Composer's onDrop fires first (React synthetic before window bubble).
@@ -602,31 +480,6 @@ export function AppShell() {
// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks();
React.useEffect(() => {
let isCancelled = false;
let cleanup = () => {};
void listenForDesktopNotificationActions((target) => {
if (isCancelled) {
return;
}
void handleDesktopNotificationAction(target);
}).then((dispose) => {
if (isCancelled) {
dispose();
return;
}
cleanup = dispose;
});
return () => {
isCancelled = true;
cleanup();
};
}, []);
const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []);
const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),

View File

@@ -0,0 +1,188 @@
import * as React from "react";
import {
shouldBounceForChannelNotification,
toSearchHit,
} from "@/app/AppShell.helpers";
import { getThreadReference } from "@/features/messages/lib/threading";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import type { NotificationSettings } from "@/features/notifications/hooks";
import {
listenForDesktopNotificationActions,
requestDockBounce,
revealDesktopAppWindow,
sendDesktopNotification,
} from "@/features/notifications/lib/desktop";
import {
playNotificationSound,
resolveSlotSound,
} from "@/features/notifications/lib/sound";
import type { Channel, RelayEvent } from "@/shared/api/types";
export function useAppShellDesktopNotifications({
channels,
goChannel,
goHome,
notificationSettings,
openSearchHit,
pubkey,
}: {
channels: Channel[];
goChannel: (channelId: string) => Promise<unknown>;
goHome: () => Promise<unknown>;
notificationSettings: NotificationSettings;
openSearchHit: (
hit: import("@/shared/api/types").SearchHit,
) => Promise<unknown>;
pubkey?: string;
}) {
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
if (!shouldBounceForChannelNotification(event.tags)) return;
if (!notificationSettings.desktopEnabled) return;
void requestDockBounce();
},
);
const handleDmNotification = React.useEffectEvent(
(event: RelayEvent, channel: Channel) => {
if (
!notificationSettings.desktopEnabled ||
!notificationSettings.slotAlertsEnabled.dm
) {
return;
}
const channelName = channel.name?.trim() || "Direct message";
const content = event.content.trim();
const body =
content.length > 0
? content.length > 140
? `${content.slice(0, 137).trimEnd()}...`
: content
: "New message";
const threadRootId = getThreadReference(event.tags).rootId ?? null;
void sendDesktopNotification({
title: channelName,
body,
target: {
channelId: channel.id,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(resolveSlotSound(notificationSettings, "dm"));
void requestDockBounce();
});
},
);
const handleThreadReplyDesktopNotification = React.useEffectEvent(
(channelId: string, event: RelayEvent) => {
if (
!notificationSettings.desktopEnabled ||
!notificationSettings.slotAlertsEnabled.thread_reply
) {
return;
}
// Replies that @-mention the user are owned by the home-feed mention
// path — skip them here so they don't notify (and sound) twice.
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
if (hasMentionForEvent(event, normalizedPubkey)) {
return;
}
const channel = channels.find((entry) => entry.id === channelId);
const channelName = channel?.name?.trim() || "Thread";
const content = event.content.trim();
const body =
content.length > 0
? content.length > 140
? `${content.slice(0, 137).trimEnd()}...`
: content
: "New reply";
const threadRootId = getThreadReference(event.tags).rootId ?? null;
void sendDesktopNotification({
title: `Reply in ${channelName}`,
body,
target: {
channelId,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(
resolveSlotSound(notificationSettings, "thread_reply"),
);
void requestDockBounce();
});
},
);
const handleDesktopNotificationAction = React.useEffectEvent(
async (
target: import("@/features/notifications/lib/desktop").DesktopNotificationTarget,
) => {
await revealDesktopAppWindow();
if (!target.channelId) {
void goHome();
return;
}
const anchor = toSearchHit(target);
if (!anchor) {
await goChannel(target.channelId);
return;
}
await openSearchHit(anchor);
},
);
React.useEffect(() => {
let isCancelled = false;
let cleanup = () => {};
void listenForDesktopNotificationActions((target) => {
if (isCancelled) {
return;
}
void handleDesktopNotificationAction(target);
}).then((dispose) => {
if (isCancelled) {
dispose();
return;
}
cleanup = dispose;
});
return () => {
isCancelled = true;
cleanup();
};
}, []);
return {
handleChannelNotification,
handleDmNotification,
handleThreadReplyDesktopNotification,
};
}