Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,18 @@ user-idle = { version = "0.6", default-features = false }
plist = "1"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation", "Win32_UI_Shell"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
tauri-winrt-notification = "0.7.3"
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Storage_EnhancedStorage",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_UI_Shell",
"Win32_UI_Shell_PropertiesSystem",
] }

[dependencies]
atomic-write-file = "0.3"
Expand Down
598 changes: 596 additions & 2 deletions desktop/src-tauri/src/commands/notifications.rs

Large diffs are not rendered by default.

83 changes: 66 additions & 17 deletions desktop/src/app/useAppShellDesktopNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
shouldBounceForChannelNotification,
} from "@/app/AppShell.helpers";
import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts";
import { isThreadReply } from "@/features/messages/lib/threading";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import type { NotificationSettings } from "@/features/notifications/hooks";
import {
Expand Down Expand Up @@ -59,24 +60,69 @@ export function useAppShellDesktopNotifications({
const resolveSenderName = useNotificationSenderName();

const handleChannelNotification = React.useEffectEvent(
(_channelId: string, event: RelayEvent) => {
(channelId: string, event: RelayEvent) => {
if (!enabled) return;
if (!shouldBounceForChannelNotification(event.tags)) return;
if (!notificationSettings.desktopEnabled) return;
void requestDockBounce();

const bounce = () => {
if (shouldBounceForChannelNotification(event.tags)) {
void requestDockBounce();
}
};

// Thread replies and DMs each have their own desktop-notification path
// (thread-reply and DM). This handler owns every OTHER top-level channel
// message — WhatsApp-style: notify for every message in a channel until it
// is muted. Muted channels never reach here (shouldNotifyForEvent excludes
// them upstream, and only fires this callback for unmuted channels).
// Top-level @-mentions are notified here too (with mention-specific copy)
// rather than via the home-feed path, so a mention reliably toasts.
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
if (isThreadReply(event.tags)) {
bounce();
return;
}
const channel = channels.find((c) => c.id === channelId);
if (channel?.channelType === "dm") {
bounce();
return;
}

const isMention = hasMentionForEvent(event, normalizedPubkey);
const channelName = channel?.name?.trim() ?? null;
const { title, body } = formatMessageNotification({
source: isMention ? "mention" : "channel",
senderName: resolveSenderName(event.pubkey),
channelName,
content: event.content,
});

void sendDesktopNotification({
title,
body,
target: buildEventNotificationTarget(event, {
id: channelId,
name: channelName ?? "",
}),
}).then((didSend) => {
if (!didSend) return;
void requestDockBounce();
});
},
);

const handleDmNotification = React.useEffectEvent(
(event: RelayEvent, channel: Channel) => {
if (!enabled) return;
if (
!notificationSettings.desktopEnabled ||
!notificationSettings.slotAlertsEnabled.dm
) {
return;
}
if (!notificationSettings.desktopEnabled) return;

// The DM desktop toast follows the same rule as channel/mention toasts:
// deliver whenever desktop alerts are on (muted channels are excluded
// upstream). `slotAlertsEnabled.dm` is a per-category SOUND flag, surfaced
// only under Settings > Notifications > Sound, so it must NOT gate the
// toast — gating it here made DMs silently stop toasting whenever the DM
// sound row was off, unlike channels/mentions which never checked it. It
// now gates only the sound, below.
const channelName = channel.name?.trim() || "Direct message";
const { title, body } = formatMessageNotification({
source: "dm",
Expand All @@ -94,7 +140,10 @@ export function useAppShellDesktopNotifications({
}),
}).then((didSend) => {
if (!didSend) return;
if (shouldPlayNotificationSound(channel.id, silentChannelIds)) {
if (
notificationSettings.slotAlertsEnabled.dm &&
shouldPlayNotificationSound(channel.id, silentChannelIds)
) {
playNotificationSound(resolveSlotSound(notificationSettings, "dm"));
}
void requestDockBounce();
Expand All @@ -105,12 +154,9 @@ export function useAppShellDesktopNotifications({
const handleThreadReplyDesktopNotification = React.useEffectEvent(
(channelId: string, event: RelayEvent) => {
if (!enabled) return;
if (
!notificationSettings.desktopEnabled ||
!notificationSettings.slotAlertsEnabled.thread_reply
) {
return;
}
if (!notificationSettings.desktopEnabled) return;
// As with DMs, `slotAlertsEnabled.thread_reply` is a per-category SOUND
// flag and must not gate the toast — it gates only the sound, below.

// Replies that @-mention the user are owned by the home-feed mention
// path — skip them here so they don't notify (and sound) twice.
Expand All @@ -137,7 +183,10 @@ export function useAppShellDesktopNotifications({
}),
}).then((didSend) => {
if (!didSend) return;
if (shouldPlayNotificationSound(channelId, silentChannelIds)) {
if (
notificationSettings.slotAlertsEnabled.thread_reply &&
shouldPlayNotificationSound(channelId, silentChannelIds)
) {
playNotificationSound(
resolveSlotSound(notificationSettings, "thread_reply"),
);
Expand Down
36 changes: 33 additions & 3 deletions desktop/src/features/notifications/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
onAction,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform";
import {
isLinuxPlatform,
isMacPlatform,
isWindowsPlatform,
} from "@/shared/lib/platform";

// Backend event emitted when a native Linux notification is clicked or a
// queued macOS activation becomes available. See src-tauri notification code.
Expand Down Expand Up @@ -146,6 +150,27 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
}
}

// `@tauri-apps/plugin-notification`'s injected init script special-cases
// Windows: `isPermissionGranted()` skips the real backend call entirely
// and just trusts its own in-memory `window.Notification.permission`,
// which starts at "default" on every cold start. Because "default" reads
// as not-granted, the script immediately latches its own permission to
// "denied" before Buzz's code ever runs — so the checks below would just
// read back that self-inflicted "denied" forever, without a real OS-level
// notification permission ever having been checked. The plugin's desktop
// backend (including Windows) always grants permission unconditionally,
// so ask it directly here instead of trusting the shim's cached value.
if (isTauri() && isWindowsPlatform()) {
try {
const granted = await invoke<boolean | null>(
"plugin:notification|is_permission_granted",
);
return granted === null ? "default" : granted ? "granted" : "denied";
} catch {
return "default";
}
}

if (window.Notification.permission !== "default") {
return window.Notification.permission;
}
Expand Down Expand Up @@ -418,14 +443,19 @@ export async function revealDesktopAppWindow(): Promise<void> {
export async function sendDesktopNotification(
payload: DesktopNotificationPayload,
): Promise<boolean> {
if ((await getDesktopNotificationPermissionState()) !== "granted") {
const permissionState = await getDesktopNotificationPermissionState();
if (permissionState !== "granted") {
return false;
}

// Linux needs a retained D-Bus connection. macOS needs a native notification
// center delegate because the Tauri plugin does not deliver desktop clicks.
// Windows needs native WinRT Toast Notifications bound to AUMID.
// See src-tauri/src/commands/notifications.rs.
if (isTauri() && (isLinuxPlatform() || isMacPlatform())) {
if (
isTauri() &&
(isLinuxPlatform() || isMacPlatform() || isWindowsPlatform())
) {
try {
await invoke("show_native_notification", {
title: payload.title,
Expand Down
10 changes: 8 additions & 2 deletions desktop/src/features/notifications/lib/notificationFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,18 @@ export type MessageNotificationSource =
| "approval"
| "needs_action"
| "dm"
| "thread_reply";
| "thread_reply"
// A plain new message in a channel (WhatsApp-style: notify for everything in
// a channel until it's muted).
| "channel";

const MESSAGE_BODY_FALLBACKS: Record<MessageNotificationSource, string> = {
mention: "Something in Buzz needs your attention.",
approval: "A workflow is waiting for your approval.",
needs_action: "Something in Buzz needs your attention.",
dm: "New message",
thread_reply: "New reply",
channel: "New message",
};

/**
Expand Down Expand Up @@ -106,7 +110,9 @@ export function formatMessageNotification(opts: {
? senderName
? `${senderName} replied`
: "Reply"
: (senderName ?? "Needs Action");
: source === "channel"
? (senderName ?? "New message")
: (senderName ?? "Needs Action");

return { title: formatNotificationTitle({ prefix, channelLabel }), body };
}
9 changes: 9 additions & 0 deletions desktop/src/shared/lib/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean {
);
}

/** Returns true on Windows desktops. */
export function isWindowsPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
}

return /win/i.test(navigator.platform);
}

/**
* The platform's normal application-shortcut modifier:
* - macOS: Command (Meta)
Expand Down