diff --git a/src/App.tsx b/src/App.tsx index dd9e7109ce..d70af1cd3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,7 +80,7 @@ import { markSelection, selectionStill, type SelectionMark } from "./lib/selecti import { followsHerdrFocus } from "./lib/followFocus.ts"; import { useScreenWakeLock } from "./lib/wakeLock.ts"; import { useSidebarLayout } from "./lib/useSidebarLayout.ts"; -import { useAlerts } from "./lib/useAlerts.ts"; +import { alertsBell, useAlertPrefs, useAlertRefs, useAlertSignals, useNotificationState, useNotificationSwitch } from "./lib/useAlerts.ts"; import { Droplet } from "./components/Droplet.tsx"; import { dockPane, draggedPane, paneDragData, dropSide, otherSlot, PANE_DRAG_TYPE, sameTarget, slotSide, storedSplit, storeSplit, type SplitSide, type SplitSlot, type SplitState, type SplitTarget } from "./lib/split.ts"; import { paneDrag } from "./lib/paneDrag.ts"; @@ -210,6 +210,7 @@ export function App() { const t = useT(); const apple = /Mac|iPhone|iPad/.test(navigator.platform); const { settings, resolvedTheme, resolvedEntry, update: updateSettings } = useSettings(); + const { alerts, alertsRef, alertsOn, alertsOnRef } = useAlertPrefs({ settings }); const [machines, setMachines] = useState([]); const [machineRosterLoaded, setMachineRosterLoaded] = useState(false); // the place the address names, read once at load: a link wins over this window's last selection @@ -473,7 +474,7 @@ export function App() { // the earlier sessions of a workspace's folder (components/SessionHistory.tsx), while open const [sessionHistory, setSessionHistory] = useState<{ machineId: string; workspaceId: string; agent: SessionHistoryAgent; paneId: string | null; askingPaneId: string | null; selectedPaneId: string | null } | null>(null); const { connected, setConnected, connectedBefore, setConnectedBefore, paneLost, outputStopped, setOutputStopped } = useConnectionChip(); - const { dropIn, chime, enableNotifications, setPushOn, bell, bellVisible, alertsOffDot, onRoster, onPaneStatus, onPaneExited } = useAlerts({ settings, updateSettings, t, locked, selectionRef, drawerOpenRef, splitRef, splitShownRef, machinesRef }); + const { notifications, setNotifications, pushOn, setPushOn, pushOnRef } = useNotificationState(); // the shared settings and pins, the same in every browser of this server, once the app is in const { receive: receivePreferences, saveError: settingsSaveError, paused: settingsSavePaused, retrying: settingsSaveRetrying, retry: retrySettingsSave, resubscribed: preferencesResubscribed, disconnected: preferencesDisconnected, closed: preferencesClosed } = usePreferencesSync(locked === false); const receivePreferencesRef = useRef(receivePreferences); receivePreferencesRef.current = receivePreferences; @@ -482,6 +483,7 @@ export function App() { const preferencesClosedRef = useRef(preferencesClosed); preferencesClosedRef.current = preferencesClosed; const lockedRef = useRef(locked); lockedRef.current = locked; + const { statusRef, turnStartRef, lastTurnRef, alertInAppRef, alertSoundRef } = useAlertRefs({ settings }); const followFocusRef = useRef(settings.followHerdrFocus); followFocusRef.current = settings.followHerdrFocus; const refetchTimer = useRef(null); @@ -567,6 +569,7 @@ export function App() { if (refetchTimer.current !== null) window.clearTimeout(refetchTimer.current); }, []); + const { dropIn, chime, onRoster, onPaneStatus, onPaneExited } = useAlertSignals({ settings, selectionRef, drawerOpenRef, splitRef, splitShownRef, machinesRef, alertsRef, alertsOnRef, pushOnRef, statusRef, turnStartRef, lastTurnRef, alertInAppRef, alertSoundRef }); // One SSE subscription watches every PC, even when no terminal is selected. useEffect(() => { @@ -637,6 +640,7 @@ export function App() { const handleServerMessage = useServerMessageHandler(setOutputStopped); + const { enableNotifications, disableNotifications } = useNotificationSwitch({ updateSettings, locked, notifications, setNotifications, alerts, alertsRef, alertsOn, setPushOn }); const unlock = useCallback(() => { setLocked(false); @@ -1166,6 +1170,7 @@ export function App() { }, []); + const { bell, bellVisible, alertsOffDot } = alertsBell({ t, settings, alertsOn, notifications, pushOn, enableNotifications, disableNotifications }); const connWord = t(connected ? "live" : outputStopped ? "disconnected" : connectedBefore ? "reconnecting" : "connecting"); const crumb = selectedPane && selectedTitle !== null ? headerCrumb({ machine: selectedMachine?.name ?? selectedMachineId, machineCount: machines.length, workspace: selectedWorkspace?.label ?? selectedPane.workspace_id, title: selectedTitle, cwd: selectedPane.cwd }) diff --git a/src/lib/useAlerts.ts b/src/lib/useAlerts.ts index d04c2d030e..5dbafaef7f 100644 --- a/src/lib/useAlerts.ts +++ b/src/lib/useAlerts.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; import type { AgentStatus, HerdrPane, ServerMessage } from "../../shared/protocol.ts"; +import type { AlertPrefs } from "../../shared/notify-policy.ts"; import { paneStorageId, type Machine } from "../../shared/machines.ts"; import { sendTestPush } from "./api.ts"; import { alertPaneTitle } from "../components/Sidebar.tsx"; @@ -23,18 +24,6 @@ import { canPlayAlertSound, playAlertSound, unlockAlertSound, type AlertSoundKin import { createAlertTurnPlayer } from "./alertTurns.ts"; import { sameTarget, type SplitState } from "./split.ts"; -export interface AlertsParams { - settings: Settings; - updateSettings: (patch: Partial) => void; - t: Translate; - locked: boolean | null; - selectionRef: RefObject; - drawerOpenRef: RefObject; - splitRef: RefObject; - splitShownRef: RefObject; - machinesRef: RefObject; -} - type PaneStatusMessage = Extract; type PaneExitedMessage = Extract; type TakeNotificationTargetRef = RefObject<(target: NotificationTarget) => unknown>; @@ -45,8 +34,8 @@ const alerting = (list: Machine[]) => list.map((machine) => ({ snapshot: machine.snapshot && { panes: machine.snapshot.panes.map((pane) => ({ pane_id: pane.pane_id, agent_status: alertStatus(pane.agent_status, (pane as HerdrPane).background_wait) })) }, })); -/** Keeps this device's notification and alert state, and what the event stream does with a status change. */ -export function useAlerts({ settings, updateSettings, t, locked, selectionRef, drawerOpenRef, splitRef, splitShownRef, machinesRef }: AlertsParams) { +/** This device's alert choices and its Alerts switch, with refs for the event stream. */ +export function useAlertPrefs({ settings }: { settings: Settings }) { // this device's alert choices: sent with its push subscription, and applied to tab alerts here const alerts = useMemo(() => alertPrefs(settings), [settings.alertInput, settings.alertDone]); const alertsRef = useRef(alerts); @@ -55,11 +44,21 @@ export function useAlerts({ settings, updateSettings, t, locked, selectionRef, d const alertsOn = settings.alertsOn; const alertsOnRef = useRef(alertsOn); alertsOnRef.current = alertsOn; + return { alerts, alertsRef, alertsOn, alertsOnRef }; +} + +/** The browser's notification permission and whether this device has a push subscription. */ +export function useNotificationState() { const [notifications, setNotifications] = useState(() => notificationState()); // this device has a server-side push subscription: alerts come from the server, not the tab const [pushOn, setPushOn] = useState(false); const pushOnRef = useRef(pushOn); pushOnRef.current = pushOn; + return { notifications, setNotifications, pushOn, setPushOn, pushOnRef }; +} + +/** The per-pane turn bookkeeping and the in-app alert and sound switches, as refs. */ +export function useAlertRefs({ settings }: { settings: Settings }) { // last-seen agent status per pane: the baseline that decides whether a push is news const statusRef = useRef>(new Map()); // when each pane's turn began, so an in-app alert knows a long turn from a quick one @@ -70,6 +69,28 @@ export function useAlerts({ settings, updateSettings, t, locked, selectionRef, d alertInAppRef.current = settings.alertInApp; const alertSoundRef = useRef(settings.alertSound); alertSoundRef.current = settings.alertSound; + return { statusRef, turnStartRef, lastTurnRef, alertInAppRef, alertSoundRef }; +} + +export interface AlertSignalsParams { + settings: Settings; + selectionRef: RefObject; + drawerOpenRef: RefObject; + splitRef: RefObject; + splitShownRef: RefObject; + machinesRef: RefObject; + alertsRef: RefObject; + alertsOnRef: RefObject; + pushOnRef: RefObject; + statusRef: RefObject>; + turnStartRef: RefObject>; + lastTurnRef: RefObject>; + alertInAppRef: RefObject; + alertSoundRef: RefObject; +} + +/** The in-app alert and the alert sound, and what the event stream does with a status change. */ +export function useAlertSignals({ settings, selectionRef, drawerOpenRef, splitRef, splitShownRef, machinesRef, alertsRef, alertsOnRef, pushOnRef, statusRef, turnStartRef, lastTurnRef, alertInAppRef, alertSoundRef }: AlertSignalsParams) { // In-app alerts (components/Droplet.tsx): only while the app is on screen - a hidden app has // its system notifications - and never for the pane already open in front of the user. const dropIn = useCallback((machine: Machine, pane: HerdrPane, kind: DropletKind) => { @@ -144,7 +165,22 @@ export function useAlerts({ settings, updateSettings, t, locked, selectionRef, d if (pane) showPaneEndedNotification(message.pane_id, `${machine.name} ยท ${alertPaneTitle(pane)}`, () => takeNotificationTargetRef.current({ machine_id: machine.id, pane_id: message.pane_id, view: "chat", workspace_id: pane.workspace_id }), machine.id, pane.workspace_id); } }, []); + return { dropIn, chime, onRoster, onPaneStatus, onPaneExited }; +} +export interface NotificationSwitchParams { + updateSettings: (patch: Partial) => void; + locked: boolean | null; + notifications: NotificationState; + setNotifications: Dispatch>; + alerts: AlertPrefs; + alertsRef: RefObject; + alertsOn: boolean; + setPushOn: Dispatch>; +} + +/** The Alerts switch: turning this device's alerts on and off, and keeping its push subscription. */ +export function useNotificationSwitch({ updateSettings, locked, notifications, setNotifications, alerts, alertsRef, alertsOn, setPushOn }: NotificationSwitchParams) { const enableNotifications = useCallback(async () => { // in-app alerts need no permission: the switch goes on whatever the browser answers updateSettings({ alertsOn: true }); @@ -189,7 +225,21 @@ export function useAlerts({ settings, updateSettings, t, locked, selectionRef, d cancelled = true; }; }, [locked, notifications, alerts, alertsOn]); + return { enableNotifications, disableNotifications }; +} + +export interface AlertsBellParams { + t: Translate; + settings: Settings; + alertsOn: boolean; + notifications: NotificationState; + pushOn: boolean; + enableNotifications: () => Promise; + disableNotifications: () => Promise; +} +/** What the Alerts item in the header's More menu shows and does. */ +export function alertsBell({ t, settings, alertsOn, notifications, pushOn, enableNotifications, disableNotifications }: AlertsBellParams) { // The Alerts item says what this device does, whatever the browser's permission: in-app alerts // need none, so they count as on. A device that has not answered the permission question is // asked by the item; one that has answered gets a plain switch. @@ -217,5 +267,5 @@ export function useAlerts({ settings, updateSettings, t, locked, selectionRef, d const bellVisible = notifications === "default" || notifications === "granted" || settings.alertInApp; // the menu's button carries a dot while alerts are off here: the one state of the menu worth a glance const alertsOffDot = bellVisible && !bell.on; - return { dropIn, chime, enableNotifications, setPushOn, bell, bellVisible, alertsOffDot, onRoster, onPaneStatus, onPaneExited }; + return { bell, bellVisible, alertsOffDot }; }