Skip to content
Closed
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
48 changes: 48 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
import { isElectron } from "../../env";
import { useTheme } from "../../hooks/useTheme";
import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import {
getNotificationPermissionState,
requestNotificationPermission,
} from "../../hooks/useThreadNotifications";
import { useThreadActions } from "../../hooks/useThreadActions";
import {
setDesktopUpdateStateQueryData,
Expand Down Expand Up @@ -462,6 +466,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
...(settings.notificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.notificationsEnabled
? ["Notifications"]
: []),
...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
? ["Diff line wrapping"]
: []),
Expand All @@ -488,6 +495,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.notificationsEnabled,
settings.timestampFormat,
theme,
],
Expand Down Expand Up @@ -771,6 +779,46 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
title="Notifications"
description="Get notified when a task completes, or needs approval or input while the app is in the background."
status={
settings.notificationsEnabled && getNotificationPermissionState() === "unsupported" ? (
<span className="text-warning">Notifications are not supported in this browser.</span>
) : settings.notificationsEnabled && getNotificationPermissionState() === "denied" ? (
<span className="text-warning">
Browser notifications are blocked. Allow them in your browser settings to receive
alerts.
</span>
) : null
}
resetAction={
settings.notificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.notificationsEnabled ? (
<SettingResetButton
label="notifications"
onClick={() =>
updateSettings({
notificationsEnabled: DEFAULT_UNIFIED_SETTINGS.notificationsEnabled,
})
}
/>
) : null
}
control={
<Switch
checked={settings.notificationsEnabled}
onCheckedChange={(checked) => {
const enabled = Boolean(checked);
updateSettings({ notificationsEnabled: enabled });
if (enabled) {
void requestNotificationPermission();
}
}}
aria-label="Enable notifications"
/>
}
/>

<SettingsRow
title="Time format"
description="System default follows your browser or OS clock preference."
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export function buildLegacyClientSettingsMigrationPatch(
patch.timestampFormat = legacySettings.timestampFormat;
}

if (Predicate.isBoolean(legacySettings.notificationsEnabled)) {
patch.notificationsEnabled = legacySettings.notificationsEnabled;
}

return patch;
}

Expand Down
93 changes: 93 additions & 0 deletions apps/web/src/hooks/useThreadNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useRef } from "react";
import type { ThreadId } from "@t3tools/contracts";
import { isElectron } from "../env";
import { useStore } from "../store";
import {
type ThreadNotificationSnapshot,
collectThreadNotificationSnapshots,
consolidateNotifications,
diffThreadNotifications,
} from "../lib/threadNotifications";

function isAppBackgrounded(): boolean {
return document.visibilityState === "hidden" || !document.hasFocus();
}

function canSendNotifications(): boolean {
return typeof Notification !== "undefined" && Notification.permission === "granted";
}

export async function requestNotificationPermission(): Promise<boolean> {
if (typeof Notification === "undefined") return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;
const result = await Notification.requestPermission();
return result === "granted";
}

export function getNotificationPermissionState(): NotificationPermission | "unsupported" {
if (typeof Notification === "undefined") return "unsupported";
return Notification.permission;
}

export function useThreadNotifications(enabled: boolean): void {
const previousSnapshotRef = useRef<ReadonlyMap<ThreadId, ThreadNotificationSnapshot> | null>(
null,
);
const bootstrapComplete = useStore((state) => state.bootstrapComplete);
const threads = useStore((state) => state.threads);
const projects = useStore((state) => state.projects);

useEffect(() => {
if (!bootstrapComplete) return;

const currentSnapshot = collectThreadNotificationSnapshots(threads, projects);

if (!enabled) {
// Keep in sync so toggling on doesn't fire for stale transitions.
previousSnapshotRef.current = currentSnapshot;
return;
}

if (previousSnapshotRef.current === null) {
previousSnapshotRef.current = currentSnapshot;
return;
}

if (!isAppBackgrounded() || !canSendNotifications()) {
previousSnapshotRef.current = currentSnapshot;
return;
}

const rawNotifications = diffThreadNotifications(previousSnapshotRef.current, currentSnapshot);

if (rawNotifications.length > 0) {
const consolidated = consolidateNotifications(rawNotifications);
for (const notification of consolidated) {
const browserNotification = new Notification(notification.title, {
body: notification.body,
tag: notification.threadId ?? "t3code-batch",
});

if (notification.threadId) {
const threadId = notification.threadId;
browserNotification.addEventListener("click", () => {
window.focus();
if (isElectron) {
window.location.hash = `#/${threadId}`;
} else {
window.history.pushState(null, "", `/${threadId}`);
window.dispatchEvent(new PopStateEvent("popstate"));
}
});
} else {
browserNotification.addEventListener("click", () => {
window.focus();
});
}
}
}

previousSnapshotRef.current = currentSnapshot;
}, [bootstrapComplete, enabled, projects, threads]);
}
Loading
Loading