From 5cdcb7e91fc27861b7298301701a664575fe7993 Mon Sep 17 00:00:00 2001 From: Dustin Do Date: Tue, 7 Jul 2026 18:26:49 +0700 Subject: [PATCH] feat(t101): electron notification channel mutes --- CLAUDE.md | 6 +- CONTEXT.md | 2 +- core/notifications.js | 7 +- core/notifications.test.ts | 45 ++++++ core/settings-store.js | 4 + core/settings-store.test.ts | 7 + ...-for-arch-and-refactoring-predict-issue.md | 3 +- ...r-defers-to-pinned-workspace-tab-instea.md | 3 +- ...101-electron-notification-channel-mutes.md | 124 ++++++++++++++++ main.js | 16 ++- src/app.tsx | 28 ++-- src/components/settings-dialog.tsx | 136 ++++++++---------- src/components/toolbar.tsx | 1 + src/lib/CLAUDE.md | 6 +- src/lib/notif-mutes.test.ts | 45 +++++- src/lib/notif-mutes.ts | 30 ++++ 16 files changed, 362 insertions(+), 101 deletions(-) rename docs/tasks/{ => done}/096-investigate-for-arch-and-refactoring-predict-issue.md (99%) rename docs/tasks/{ => done}/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md (98%) create mode 100644 docs/tasks/done/101-electron-notification-channel-mutes.md diff --git a/CLAUDE.md b/CLAUDE.md index 1fe9bd8..144104a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ A lightweight Electron app that connects to a remote Chromium-based browser via - **Pins (live-tab holders)**: A pin holds a remote tab (`targetId`), hidden from the Tabs list while linked. Click activates the linked tab or opens+links a fresh one; cmd/middle-click opens an unlinked throwaway tab. Created from a live tab only (toolbar star, right-click tab → Pin, or drag a tab into the Pinned section). A linked pin mirrors its tab's live title/favicon (restoring the saved title when the tab closes); the active pin shows an Arc-style URL-drift cue (a `/` separator and a favicon "Back to Pinned URL" button) when its tab navigates off the saved URL. Closing a pin's tab reverts it to unlinked; un-pinning (confirm dialog) returns the tab to the Tabs list. Cmd+1..9 indexes all pins then visible tabs; Ctrl+Tab cycles open pins + tabs. Link resolution is pure (`src/lib/pins.ts`); persistence/effects live in main + `app.tsx`. See `docs/adr/0004-pin-live-tab-model.md`. - **Unread badges by group**: Sidebar unread counts are computed by `aggregateUnread` (`src/lib/unread-aggregator.ts`) and keyed by `groupKey` (from the notification entry) falling back to `groupKeyForUrl(url)` — Slack's per-workspace `slack:{teamId}`, else URL origin. Every tab/pin of the same app shares one count whether or not it captured the notification, and a dormant pin still badges by resolving its saved URL through the same key derivation. - **Local tabs**: Real local web pages rendered as in-DOM Electron ``s on a shared `persist:local` session (`src/components/local-webviews.tsx`) — full device access (OS notifications, speaker/mic, camera, screen-share) that CDP screencast tabs can't have. Because a `` is an in-page OOPIF, React overlays (dialogs, menus, tooltips, the settings sheet) stack **above the live page via CSS z-index** — no native z-order, no freeze. `activeKind: 'cdp' | 'local'` chooses the surface and routes the toolbar/nav hotkeys (`RemotePage` vs the active webview's methods). The renderer holds `LocalTab` metadata and maps webview DOM events to it; only the active webview is shown (others `display:none`, kept alive in the background). All open local tabs persist + restore on launch; pinned ones (a `pinned` flag, distinct from CDP PINNED pins) sort atop the LOCAL TABS section. Unpacked MV3 extensions load into the local session only (`localExtensionPaths`) and their content scripts inject into webview guests; the toolbar shows a Chrome-like action icon per extension (opens its popup in a popover), and popup/options also open as a local tab via the `chrome-extension://` URL. Permissions auto-granted behind the `autoGrantLocalMedia` setting (a `media` request triggers `askForMediaAccess`); packaging ships mic/cam/audio-capture Info.plist keys + entitlements (`build/entitlements.mac.plist`, hardened runtime). See `docs/adr/0005-local-tabs-base-window.md`. -- **Web build (no Electron)**: The same renderer runs as a plain web app via `web/server.mjs` — a Node HTTP proxy that serves the built `dist/` and exposes the whole `window.cdp` surface over **SSE** (`GET /api/events`, server→browser pushes incl. screencast frames) + **POST** (`/api/invoke`, `/api/send`, `/api/cdp-batch`, and REST for tabs/config/ui-state/pins/notifications). An optional **WebSocket** transport (`/api/ws`) supersedes SSE+POST when reachable — the user picks `Auto / Fastest (WS) / Streaming / Basic` in settings (2×2 toggle, web-only, `localStorage`). When WS is ready, frames + events + input all ride the one full-duplex socket. WS needs three lines in the nginx custom config (`proxy_http_version 1.1`, `proxy_set_header Upgrade $http_upgrade`, `proxy_set_header Connection $http_connection`); without them the client silently falls back to SSE+POST. See `docs/adr/0007-web-websocket-transport.md`. The proxy→CDP hop is still WS. The renderer installs a web `window.cdp` (`src/lib/cdp-web-transport.ts`, a thin assembler) when no preload exists, satisfying the same `CdpBridge` contract; the transport is split into named seams — a **Downlink** (`src/lib/downlink-dispatcher.ts`: one live WS-or-SSE source, decoder→filter→fan-out→toast-once dispatcher) and an **Uplink** (`src/lib/uplink-router.ts`: WS/stream/POST adapters + ready-transport router), with E2E sealed/opened once per direction through `src/lib/crypto-context.ts`. Input is coalesced via `src/lib/input-coalesce.ts`; the proxy acks frames itself, **except** for a WS client that announces ack-after-paint support (a plaintext `frame-ack-mode` control) — for that client the proxy **defers** its remote-ack and gates the next Screencast Frame on the client's post-paint `frame-ack`, so at most one frame is in flight on the link and a slow link can't accrue a stale-frame backlog (`core/frame-ack-gate.js`, the pure one-in-flight gate + a watchdog that frees the slot if a paint-ack never lands — its window is adaptive to measured paint latency via `core/paint-ack-pacer.js`, t096; the renderer fires the ack from `viewport.tsx` after it paints, via `window.cdp.ackPaintedFrame`; SSE/non-supporting clients keep the eager self-ack — see `docs/tasks/done/056-*`); theme follows `matchMedia`. **Always-on latency metrics** (`src/lib/latency-metrics.ts`, t057) ride the same seams: the WS uplink fires a plaintext `ping` (monotonic stamp) every 20s — a keepalive against proxy idle-reap plus an RTT/jitter EWMA probe — and the server echoes `{ t: "pong", seq, ts }` (RTT is measured only on the client clock); every Screencast Frame envelope carries a server `serverTs` so the client computes frame age (`now − serverTs + rtt/2`), recorded by the dispatcher before fan-out. Collection runs continuously (no `?perf=1`); the HUD is `src/components/latency-hud.tsx` (t059), always-on in the status bar. RTT/jitter report unavailable on the SSE+POST fallback. A `window.webCaps` flag (read through one accessor — `getCaps()` in `src/lib/caps.ts`, never inline) gates Electron-only surfaces. Local tabs are gated **structurally at the data source**: `useLocalTabs()` (`src/hooks/use-local-tabs.ts`) reads `caps.localTabs` once and returns an empty list + no-op handlers on web, so the renderer can't drive local-tab logic there (`LocalWebviews` never mounts, the new-tab kind toggle is hidden, Cmd+T/Cmd+Shift+T resolve to CDP only). Extensions are still gated at render only. `window.local` is a no-op stub (the safety net, not the mechanism). See `docs/conventions/feature-gates.md`. Pure shared logic lives in `core/` CJS modules — `cdp-endpoints.js` (`/json` URL builders), `settings-store.js` (settings/pins/ui-state), `notifications-sidechain.js` (Notification Side-Channel state machine + store, DI), `remote-page-connector.js` (Remote Page connect choreography, DI), `notifications.js` (dedup/cap/toast gating, Slack workspace key: `parseSlackContext`/`slackGroupKey`), `theme-emulation.js`, `crypto-envelope.js` (AES-256-GCM server side), `line-splitter.js` (NDJSON reassembly), `frame-throttle.js`, `frame-ack-gate.js`, `quality-tier.js`, and `notif-mutes.js` (per-device mute key + per-device unread, web push gate) — consumed by both `main.js` and `web/server.mjs`. Run `pnpm web`. See `docs/adr/0006-web-proxy-sse-transport.md`. The web build is an installable **PWA** (`public/manifest.webmanifest` with `APP_TITLE`-injected name + `public/sw.js`); the manifest is phone-and-iPad friendly (`"orientation": "any"` since t081 — iOS ignores the field, Android honors it; `viewport-fit=cover`; `interactive-widget=resizes-content` in the viewport meta so iOS shrinks the layout viewport when the keyboard opens; full height is driven by `--app-h` (set by `initAppHeight` in `src/lib/app-height.ts` to `visualViewport.height` — `100dvh` is only the pre-JS fallback); on iOS the keyboard also shifts the visual viewport up (`visualViewport.offsetTop`), so `app-height.ts` publishes `--vv-top` and toggles `html.kb-open` so `#root` translates to follow the visual viewport and bottom-anchored composers collapse the home-indicator inset the keyboard covers; `font-size: max(16px,1em)` on inputs prevents iOS auto-zoom on focus; safe-area insets are applied per-component — sidebar scroll content uses `pb-[max(0.5rem,env(safe-area-inset-bottom))]`, status bar uses `pb-[env(safe-area-inset-bottom)]`; sidebar defaults to 180px on viewports ≤1100px; an install nudge banner (`install-banner.tsx`) prompts Safari-tab visits to Add to Home Screen). Has a web-only **push-notification** toggle (`webPush` ui-state) that drives real **Web Push** on installed PWAs (iOS 16.4+) — VAPID-signed payloads from the server (`web-push` library) reach a service-worker `push` handler that fires `showNotification` even when the PWA is backgrounded or the screen is locked; clicks post-message back to the page and route through the same `notificationActivate` listeners as in-app clicks — on the Phone Shell that listener deep-routes into the **Conversation Reader** (t080): warm taps carry the payload entry (store entry wins via `resolvePushEntry`), cold taps (no window) ride a one-shot `?notif=` URL the SW sets on `openWindow`, consumed by `src/lib/push-route.ts` helpers once the store loads (gone entry → Inbox). The push payload also stamps the conversation identity (`channelId`/`slackKind`/`slackTs`/`slackThreadTs`) and an `unread` count, which the SW mirrors to the home-screen icon via `setAppBadge` (the page keeps it live as entries are read). **Per-device delivery (t093):** capture is global but *delivery* is per-device — each push subscription carries a `deviceId`, and `sendPushToAll` reads that device's master + mutes from ui-state (`notificationsEnabled_` + `notifMutes_`, written via the same device-keyed remap seam as `webPush_`; `core/settings-store.js` round-trips device-suffixed keys by prefix so they survive a PWA refresh). For each sub it **skips** the push when that device's master is off or it muted the entry's `muteKey` (`core/notif-mutes.js`: slack→`groupKey`, else `adapter`), and otherwise stamps a **per-device `unread`** (`unreadExcluding`, excluding that device's muted sources) so the badge stays honest per device. Defaults are opt-out (no stored master = on, no stored mutes = nothing muted; a sub with no `deviceId` keeps receiving). The global `notificationsEnabled` stays Electron-only (gates `shouldNotifyOs`). Foreground tabs still get the in-page `Notification` API as before. Subscriptions persist in `web-push-subs.json` next to the settings file. **Push delivery hardening (t095, ADR-0014):** the server is the authoritative source of `deviceId`, reconciled by push endpoint (`core/push-subscriptions.js`:`reconcileDeviceId`) — after a storage wipe the same endpoint recovers its prior `deviceId` and per-device prefs; the SW push handler (`src/lib/push-notification.ts`:`buildNotificationContent`) always calls `showNotification` (real payload or generic fallback) to avoid WebKit **userVisibleOnly** revocation; the server fans out with `urgency:"high"`, `TTL:1800` (`core/push-send-options.js`) for timely, non-stale delivery; on app foreground the client re-validates the subscription once (`src/lib/push-revalidate.ts`:`createPushRevalidateGate`) to recover a revoked sub before the next push arrives. See `CONTEXT.md` for **Web Push Subscription** and **userVisibleOnly revocation** glossary entries. The toggle is disabled in Safari-tab mode (Web Push needs standalone display), and lowers input latency with a **streaming input channel** — one long-lived `POST /api/input-stream` (fetch `ReadableStream` body over HTTP/2, NDJSON frames reassembled by `core/line-splitter.js`) that a probe/`stream-ack` confirms before use and that falls back to `/api/cdp-batch` if a proxy buffers it. Streaming needs `proxy_request_buffering off` upstream to activate; when it can't (the default behind nginx + an SSO proxy), mouse input is **event-driven** so it doesn't flood the fallback: a **hover gate** (`createHoverGate`) holds buttons-up moves and emits one resting position only when the cursor stops (drag moves bypass it and track live; clicks carry their own coords), and the `/api/cdp-batch` fallback is **single-flight with move-collapsing** (`createSingleFlight` — one POST in flight, consecutive `mouseMoved` collapse to the latest) so the rate auto-adapts to link RTT instead of backing up fire-and-forget POSTs and starving clicks. See `docs/tasks/done/013-*`. An optional **E2E mode** (set `E2E_PASSPHRASE` on the server) seals every `/api` body + SSE frame in AES-256-GCM (`core/crypto-envelope.js` server / `src/lib/crypto-envelope.ts` browser; the single owner is `src/lib/crypto-context.ts` — the uplink seals once before leaving, the downlink opens once on arrival) so content stays opaque to a corporate TLS-intercepting proxy; a verifier handshake rejects a wrong passphrase, and with E2E off everything is plaintext as before. It defeats network content inspection, not endpoint screen capture. See `docs/tasks/done/012-*`. +- **Web build (no Electron)**: The same renderer runs as a plain web app via `web/server.mjs` — a Node HTTP proxy that serves the built `dist/` and exposes the whole `window.cdp` surface over **SSE** (`GET /api/events`, server→browser pushes incl. screencast frames) + **POST** (`/api/invoke`, `/api/send`, `/api/cdp-batch`, and REST for tabs/config/ui-state/pins/notifications). An optional **WebSocket** transport (`/api/ws`) supersedes SSE+POST when reachable — the user picks `Auto / Fastest (WS) / Streaming / Basic` in settings (2×2 toggle, web-only, `localStorage`). When WS is ready, frames + events + input all ride the one full-duplex socket. WS needs three lines in the nginx custom config (`proxy_http_version 1.1`, `proxy_set_header Upgrade $http_upgrade`, `proxy_set_header Connection $http_connection`); without them the client silently falls back to SSE+POST. See `docs/adr/0007-web-websocket-transport.md`. The proxy→CDP hop is still WS. The renderer installs a web `window.cdp` (`src/lib/cdp-web-transport.ts`, a thin assembler) when no preload exists, satisfying the same `CdpBridge` contract; the transport is split into named seams — a **Downlink** (`src/lib/downlink-dispatcher.ts`: one live WS-or-SSE source, decoder→filter→fan-out→toast-once dispatcher) and an **Uplink** (`src/lib/uplink-router.ts`: WS/stream/POST adapters + ready-transport router), with E2E sealed/opened once per direction through `src/lib/crypto-context.ts`. Input is coalesced via `src/lib/input-coalesce.ts`; the proxy acks frames itself, **except** for a WS client that announces ack-after-paint support (a plaintext `frame-ack-mode` control) — for that client the proxy **defers** its remote-ack and gates the next Screencast Frame on the client's post-paint `frame-ack`, so at most one frame is in flight on the link and a slow link can't accrue a stale-frame backlog (`core/frame-ack-gate.js`, the pure one-in-flight gate + a watchdog that frees the slot if a paint-ack never lands — its window is adaptive to measured paint latency via `core/paint-ack-pacer.js`, t096; the renderer fires the ack from `viewport.tsx` after it paints, via `window.cdp.ackPaintedFrame`; SSE/non-supporting clients keep the eager self-ack — see `docs/tasks/done/056-*`); theme follows `matchMedia`. **Always-on latency metrics** (`src/lib/latency-metrics.ts`, t057) ride the same seams: the WS uplink fires a plaintext `ping` (monotonic stamp) every 20s — a keepalive against proxy idle-reap plus an RTT/jitter EWMA probe — and the server echoes `{ t: "pong", seq, ts }` (RTT is measured only on the client clock); every Screencast Frame envelope carries a server `serverTs` so the client computes frame age (`now − serverTs + rtt/2`), recorded by the dispatcher before fan-out. Collection runs continuously (no `?perf=1`); the HUD is `src/components/latency-hud.tsx` (t059), always-on in the status bar. RTT/jitter report unavailable on the SSE+POST fallback. A `window.webCaps` flag (read through one accessor — `getCaps()` in `src/lib/caps.ts`, never inline) gates Electron-only surfaces. Local tabs are gated **structurally at the data source**: `useLocalTabs()` (`src/hooks/use-local-tabs.ts`) reads `caps.localTabs` once and returns an empty list + no-op handlers on web, so the renderer can't drive local-tab logic there (`LocalWebviews` never mounts, the new-tab kind toggle is hidden, Cmd+T/Cmd+Shift+T resolve to CDP only). Extensions are still gated at render only. `window.local` is a no-op stub (the safety net, not the mechanism). See `docs/conventions/feature-gates.md`. Pure shared logic lives in `core/` CJS modules — `cdp-endpoints.js` (`/json` URL builders), `settings-store.js` (settings/pins/ui-state), `notifications-sidechain.js` (Notification Side-Channel state machine + store, DI), `remote-page-connector.js` (Remote Page connect choreography, DI), `notifications.js` (dedup/cap/toast gating, Slack workspace key: `parseSlackContext`/`slackGroupKey`), `theme-emulation.js`, `crypto-envelope.js` (AES-256-GCM server side), `line-splitter.js` (NDJSON reassembly), `frame-throttle.js`, `frame-ack-gate.js`, `quality-tier.js`, and `notif-mutes.js` (per-device mute key + per-device unread, web push gate) — consumed by both `main.js` and `web/server.mjs`. Run `pnpm web`. See `docs/adr/0006-web-proxy-sse-transport.md`. The web build is an installable **PWA** (`public/manifest.webmanifest` with `APP_TITLE`-injected name + `public/sw.js`); the manifest is phone-and-iPad friendly (`"orientation": "any"` since t081 — iOS ignores the field, Android honors it; `viewport-fit=cover`; `interactive-widget=resizes-content` in the viewport meta so iOS shrinks the layout viewport when the keyboard opens; full height is driven by `--app-h` (set by `initAppHeight` in `src/lib/app-height.ts` to `visualViewport.height` — `100dvh` is only the pre-JS fallback); on iOS the keyboard also shifts the visual viewport up (`visualViewport.offsetTop`), so `app-height.ts` publishes `--vv-top` and toggles `html.kb-open` so `#root` translates to follow the visual viewport and bottom-anchored composers collapse the home-indicator inset the keyboard covers; `font-size: max(16px,1em)` on inputs prevents iOS auto-zoom on focus; safe-area insets are applied per-component — sidebar scroll content uses `pb-[max(0.5rem,env(safe-area-inset-bottom))]`, status bar uses `pb-[env(safe-area-inset-bottom)]`; sidebar defaults to 180px on viewports ≤1100px; an install nudge banner (`install-banner.tsx`) prompts Safari-tab visits to Add to Home Screen). Has a web-only **push-notification** toggle (`webPush` ui-state) that drives real **Web Push** on installed PWAs (iOS 16.4+) — VAPID-signed payloads from the server (`web-push` library) reach a service-worker `push` handler that fires `showNotification` even when the PWA is backgrounded or the screen is locked; clicks post-message back to the page and route through the same `notificationActivate` listeners as in-app clicks — on the Phone Shell that listener deep-routes into the **Conversation Reader** (t080): warm taps carry the payload entry (store entry wins via `resolvePushEntry`), cold taps (no window) ride a one-shot `?notif=` URL the SW sets on `openWindow`, consumed by `src/lib/push-route.ts` helpers once the store loads (gone entry → Inbox). The push payload also stamps the conversation identity (`channelId`/`slackKind`/`slackTs`/`slackThreadTs`) and an `unread` count, which the SW mirrors to the home-screen icon via `setAppBadge` (the page keeps it live as entries are read). **Per-device delivery (t093):** capture is global but *delivery* is per-device — each push subscription carries a `deviceId`, and `sendPushToAll` reads that device's master + mutes from ui-state (`notificationsEnabled_` + `notifMutes_`, written via the same device-keyed remap seam as `webPush_`; `core/settings-store.js` round-trips device-suffixed keys by prefix so they survive a PWA refresh). For each sub it **skips** the push when that device's master is off or it muted the entry's `muteKey` (`core/notif-mutes.js`: slack→`groupKey`, else `adapter`), and otherwise stamps a **per-device `unread`** (`unreadExcluding`, excluding that device's muted sources) so the badge stays honest per device. Defaults are opt-out (no stored master = on, no stored mutes = nothing muted; a sub with no `deviceId` keeps receiving). The global `notificationsEnabled` stays Electron-only (gates `shouldNotifyOs`), and on Electron a plain global `notifMutes` now gives the same **per-source mute channels** as the PWA (t101): `shouldNotifyOs` and the dock badge honor it, the Settings notification card is one shared component across builds (push row web-only; Slack mute rows sourced from the sweep's capture health on web vs `slackMuteRows(notifications)` — captured hijack entries labelled by their workspace `source` — on Electron), and `app.tsx`'s badge counts apply mutes on both builds. Foreground tabs still get the in-page `Notification` API as before. Subscriptions persist in `web-push-subs.json` next to the settings file. **Push delivery hardening (t095, ADR-0014):** the server is the authoritative source of `deviceId`, reconciled by push endpoint (`core/push-subscriptions.js`:`reconcileDeviceId`) — after a storage wipe the same endpoint recovers its prior `deviceId` and per-device prefs; the SW push handler (`src/lib/push-notification.ts`:`buildNotificationContent`) always calls `showNotification` (real payload or generic fallback) to avoid WebKit **userVisibleOnly** revocation; the server fans out with `urgency:"high"`, `TTL:1800` (`core/push-send-options.js`) for timely, non-stale delivery; on app foreground the client re-validates the subscription once (`src/lib/push-revalidate.ts`:`createPushRevalidateGate`) to recover a revoked sub before the next push arrives. See `CONTEXT.md` for **Web Push Subscription** and **userVisibleOnly revocation** glossary entries. The toggle is disabled in Safari-tab mode (Web Push needs standalone display), and lowers input latency with a **streaming input channel** — one long-lived `POST /api/input-stream` (fetch `ReadableStream` body over HTTP/2, NDJSON frames reassembled by `core/line-splitter.js`) that a probe/`stream-ack` confirms before use and that falls back to `/api/cdp-batch` if a proxy buffers it. Streaming needs `proxy_request_buffering off` upstream to activate; when it can't (the default behind nginx + an SSO proxy), mouse input is **event-driven** so it doesn't flood the fallback: a **hover gate** (`createHoverGate`) holds buttons-up moves and emits one resting position only when the cursor stops (drag moves bypass it and track live; clicks carry their own coords), and the `/api/cdp-batch` fallback is **single-flight with move-collapsing** (`createSingleFlight` — one POST in flight, consecutive `mouseMoved` collapse to the latest) so the rate auto-adapts to link RTT instead of backing up fire-and-forget POSTs and starving clicks. See `docs/tasks/done/013-*`. An optional **E2E mode** (set `E2E_PASSPHRASE` on the server) seals every `/api` body + SSE frame in AES-256-GCM (`core/crypto-envelope.js` server / `src/lib/crypto-envelope.ts` browser; the single owner is `src/lib/crypto-context.ts` — the uplink seals once before leaving, the downlink opens once on arrival) so content stays opaque to a corporate TLS-intercepting proxy; a verifier handshake rejects a wrong passphrase, and with E2E off everything is plaintext as before. It defeats network content inspection, not endpoint screen capture. See `docs/tasks/done/012-*`. - **Clipboard paste (t065)**: Two gesture-driven one-way bridges — no ambient background sync (focus/permission wall + privacy). **Local→remote text**: ⌘/Ctrl+V reads the local clipboard (`window.cdp.readClipboard()` via Electron IPC / `navigator.clipboard` on web) and calls `RemotePage.paste(text)` → `Input.insertText` (plain) or pre-seed + forwarded ⌘V (rich). **Local→remote image**: `window.cdp.readClipboardImage()` (Electron IPC, reads `clipboard.readImage()`) or the native browser `paste` event (web — Safari/iPad blocks `navigator.clipboard.readText`/images; instead ⌘V is not `preventDefault`ed so the browser fires a `paste` ClipboardEvent on the document); either path calls `RemotePage.pasteImage(dataUrl)` → `Runtime.evaluate` synthesizes a paste `ClipboardEvent` with a `DataTransfer` carrying the image as a `File`. **Typing surface guard**: bare `?` (and other bare-char shortcuts) forward to the remote page when `activeKind` is `cdp` or `local` (`isTypingSurface` in `src/lib/typing-surface.ts`); the shortcut overlay opens via `⌘/` instead. `core/clipboard.js` owns the pure `Browser.grantPermissions` enum-fallback helpers and `selectPasteRoute`. - **Notifications side-channel**: A per-target read-only CDP socket (no screencast, no input) stays attached to background tabs that match a Notification Adapter (Teams, Outlook, Slack). Lifecycle and state machine live in `core/notifications-sidechain.js` (`createNotificationCenter`, DI) — consumed by both `main.js` and `web/server.mjs`; the server runs it headless. A capture script (per adapter, in `inject/`) is injected at document-start and ships toasts through a `__cdpNotify` binding. Pure dedup/cap/read-model helpers remain in `core/notifications.js`. Each adapter carries a `name`, hostname `match` regex, capture `script`, `iconUrl`, optional `activate` tagged union (`spa-link` | `thread`) for deep-opens, and an optional `groupKey(url)` hook (URL-derived per-workspace bucketing) — adding an adapter is one config entry in `ADAPTERS`. Capture style varies by site: Teams/Outlook use a `MutationObserver` on the site's own in-app toast DOM; Slack uses a two-modality design (ADR-0011): the **Slack Content Sweep** (web build only) is the authoritative capture path — the server polls Slack's web API using extracted `xoxc`/`d`-cookie creds from a live Slack tab, synthesizes entries keyed `slack:{groupId}:{channel}:{ts}` (`groupId = enterprise_id || teamId`, t092 — an Enterprise Grid registers the org as a pseudo-team alongside its member workspaces and both surface the same shared channels, so keying by the Grid group collapses the org+workspace duplicate via the existing id-dedup; the concrete `teamId` is kept on the entry for the deep-link), and is the sole Slack store writer. The in-page hijack script (`inject/slack-notify.js`) is demoted to a **"sweep now" trigger**: a fired notification immediately schedules a sweep of that workspace so delivery is sub-second without the hijack writing to the store (no cross-path dedup needed). A 15s periodic sweep is the completeness backstop; a **parked tab** keeps one Slack tab alive on the remote browser so creds self-refresh — but the keeper **defers to a pin** (t098): a pinned workspace is owned by its pin and is never reopened as a stray duplicate when its tab closes, and because one live Slack tab refreshes *every* workspace's creds (shared `d` cookie + `localConfig_v2` carries all teams' tokens) the sweep is unaffected; a single cred lifeline opens one tab (preferring a pinned URL) only when no Slack tab is live and no unpinned workspace would open one. A workspace is persisted in `slack-workspaces.json` (non-secret metadata only — no creds on disk) when first seen as its own tab. Clicking a notification activates the tab, then the renderer's activation registry (`src/lib/notification-activation.ts`) maps the `activate` intent to a Remote Page intention (`navigateSpa` for Outlook + Slack channel deep-links, `openTeamsThread` for Teams chats). Teams has no conversation URL (the URL stays bare `/v2/`), so thread-id clicks drive `openTeamsThread`; Slack reuses `spa-link` to `/client/{team}/{channel}` (best-effort — degrades to tab-only when the notification carries no channel id). See `docs/adr/0003-notifications-side-channel.md` and `docs/adr/0011-slack-content-sweep-guaranteed-delivery.md`. @@ -64,7 +64,7 @@ cdp-browser/ ├── main.js # Electron main process, CDP API + WS management ├── preload.js # IPC bridge (contextBridge) ├── core/ # Backend-agnostic shared CommonJS modules (consumed by main.js + web/server.mjs) -│ ├── notifications.js # Pure notification logic (dedup, cap, OS-toast gating, Slack workspace key: parseSlackContext/slackGroupKey) +│ ├── notifications.js # Pure notification logic (dedup, cap, OS-toast gating — shouldNotifyOs honors per-source mutes since t101, Slack workspace key: parseSlackContext/slackGroupKey) │ ├── notifications-sidechain.js # Notification Side-Channel state machine (createNotificationCenter, DI); Slack-only cred extraction (xoxc+d-cookie, carries enterpriseId for Grid grouping t092) via onCreds dep + listCreds/getCreds/markCredsStale/setSelfUserId accessors (t069). markCredsStale re-extracts over the live socket (refreshCreds) so a rotated token self-heals; recordCreds fires onCredsStuck when the re-extract reads the same stale token so the server reloads the keeper-owned parked tab, never a pin (t099). Side-channel cdpCall has a timeout + rejects pending on close, and reconcile reaps a hung non-OPEN socket on a still-live target (t096) │ ├── remote-page-connector.js # Remote Page connect choreography (createRemotePageConnector, DI) + screencast rate ceiling (SCREENCAST_TARGET_FPS/EVERY_NTH_FRAME) │ ├── theme-emulation.js # Pure theme-sync logic (emulatedMediaParams) @@ -88,7 +88,7 @@ cdp-browser/ │ ├── slack-sweep-runner.js # Effectful sweep orchestrator (createSlackSweeper, DI): per-workspace clientCounts→plan→history→reduce→ingest+read-sync; first-sweep seeding (no cold-start spam); sole authoritative Slack writer (t071). users.counts fallback for team_is_restricted Grid children — seed-to-now + unread-set read-sync (t075). Threads groupId into the entry id/groupKey while keeping the concrete teamId for deep-link (t092). ADR-0011 │ ├── slack-render.js # Pure content renderer: renderBody (mentions/channel-refs/links resolved, mrkdwn stripped, entities decoded) + composeTitle ("{sender} in #{channel}", DM: sender) (t073) + toReaderMessages (reader history shaping, t077). ADR-0011/0012 │ ├── notification-health.js # Pure capture-health aggregator: buildHealth (ONE row per Grid group keyed groupId — merged status/label/teamIds, t092; healthy/degraded/unsupported) + shouldAlert (one-time degrade gate); /api/notifications/health (t074, ADR-0011) -│ ├── notif-mutes.js # Pure per-device mute logic: muteKey(entry) (slack→groupKey, else adapter) + isMuted + unreadExcluding(list,mutes,masterOn) (per-device unread, 0 when master off). Mirrored in src/lib/notif-mutes.ts; gates per-device push in web/server.mjs (t093) +│ ├── notif-mutes.js # Pure mute logic: muteKey(entry) (slack→groupKey, else adapter) + isMuted + unreadExcluding(list,mutes,masterOn) (unread, 0 when master off). Mirrored in src/lib/notif-mutes.ts; gates per-device push in web/server.mjs (t093) AND the Electron OS-notify (shouldNotifyOs) + dock badge via a global notifMutes (t101) │ ├── push-subscriptions.js # Pure E0: endpoint-reconciled deviceId (t095, ADR-0014). reconcileDeviceId(existingSubs, {endpoint}) → {deviceId, isNew} — server-authoritative identity surviving storage wipe │ ├── push-send-options.js # Pure E2: push notification send options (t095). pushSendOptions() → {urgency:"high", TTL:1800} for timely, non-stale delivery │ (Channel Exclude logic lives in src/lib/slack-excludes.ts — renderer edits ui-state, server sweep reads it, t072) diff --git a/CONTEXT.md b/CONTEXT.md index aa18679..45fbcef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -73,7 +73,7 @@ A user-configured per-channel mute for the **Slack Content Sweep**, stored in se _Avoid_: mute list, blocklist, filter. **Delivery Mute**: -A per-device, per-source suppression of notification *interruptions* (push, foreground toast, badge bump) without removing entries from the Inbox or bell list — muted entries still appear, dimmed. Stored in server ui-state under `notifMutes_` (a set of mute keys, survives the iPad PWA's localStorage wipe). The **mute key** (`muteKey`, `core/notif-mutes.js` / `src/lib/notif-mutes.ts`) unifies both axes: a Slack entry keys by its merged `groupKey` (`slack:{groupId}`, per workspace), every other adapter by `adapter` name (per service). Complementary to but distinct from **Channel Exclude** (which removes Slack channels from capture globally, not per-device, and hides them everywhere). See ADR-0013. +A per-device, per-source suppression of notification *interruptions* (push, foreground toast, badge bump) without removing entries from the Inbox or bell list — muted entries still appear, dimmed. Stored in server ui-state under `notifMutes_` on the web build (a set of mute keys per device, survives the iPad PWA's localStorage wipe); Electron is single-device, so it stores a plain global `notifMutes` instead (t101). The **mute key** (`muteKey`, `core/notif-mutes.js` / `src/lib/notif-mutes.ts`) unifies both axes: a Slack entry keys by its merged `groupKey` (`slack:{groupId}`, per workspace), every other adapter by `adapter` name (per service). Complementary to but distinct from **Channel Exclude** (which removes Slack channels from capture globally, not per-device, and hides them everywhere). See ADR-0013. _Avoid_: global mute, notification filter, blocklist. **Web Push Subscription**: diff --git a/core/notifications.js b/core/notifications.js index 60766bf..2b49617 100644 --- a/core/notifications.js +++ b/core/notifications.js @@ -3,6 +3,8 @@ // pattern of src/lib/adaptive-viewport.ts, but as CommonJS since the Electron main // process can't import the renderer's TS/ESM modules. Tested by notifications.test.ts. +const { isMuted } = require("./notif-mutes") + // Returns the first adapter whose `match(hostname)` accepts the URL's host, or null. function matchAdapter(url, adapters) { let host @@ -77,8 +79,11 @@ function ingest(list, payload, cap) { // OS toast fires unless you can already see the site's own in-app toast — i.e. its // tab is the active one AND the app window is focused. If you've switched tabs or // alt-tabbed to another app, the in-app toast is out of view, so the OS toast fires. -function shouldNotifyOs(entry, { activeTabId, enabled, windowFocused }) { +function shouldNotifyOs(entry, { activeTabId, enabled, windowFocused, mutes }) { if (!enabled) return false + // Per-source mute (t093 model, wired to the Electron OS-notify path in t101). A muted + // source stays silent even when backgrounded; absent/empty `mutes` mutes nothing (opt-out). + if (isMuted(mutes, entry)) return false const inView = entry.targetId === activeTabId && windowFocused return !inView } diff --git a/core/notifications.test.ts b/core/notifications.test.ts index 84ba671..e0528bd 100644 --- a/core/notifications.test.ts +++ b/core/notifications.test.ts @@ -200,4 +200,49 @@ describe("shouldNotifyOs", () => { shouldNotifyOs(entry, { activeTabId: "tab-B", enabled: false, windowFocused: false }), ).toBe(false) }) + + // t101 — per-source mutes gate the OS notification (Electron parity with the PWA). + it("stays silent when the entry's source is muted", () => { + const teams = { targetId: "tab-A", adapter: "teams" } + expect( + shouldNotifyOs(teams, { + activeTabId: "tab-B", + enabled: true, + windowFocused: true, + mutes: ["teams"], + }), + ).toBe(false) + }) + it("mutes a Slack workspace by its groupKey while another workspace stays loud", () => { + const muted = { targetId: "tab-A", adapter: "slack", groupKey: "slack:T1" } + const loud = { targetId: "tab-A", adapter: "slack", groupKey: "slack:T2" } + const opts = { activeTabId: "tab-B", enabled: true, windowFocused: true, mutes: ["slack:T1"] } + expect(shouldNotifyOs(muted, opts)).toBe(false) + expect(shouldNotifyOs(loud, opts)).toBe(true) + }) + it("mutes nothing when mutes is empty or omitted (opt-out default)", () => { + const teams = { targetId: "tab-A", adapter: "teams" } + expect( + shouldNotifyOs(teams, { + activeTabId: "tab-B", + enabled: true, + windowFocused: true, + mutes: [], + }), + ).toBe(true) + expect( + shouldNotifyOs(teams, { activeTabId: "tab-B", enabled: true, windowFocused: true }), + ).toBe(true) + }) + it("master off still wins over an unmuted source", () => { + const teams = { targetId: "tab-A", adapter: "teams" } + expect( + shouldNotifyOs(teams, { + activeTabId: "tab-B", + enabled: false, + windowFocused: true, + mutes: [], + }), + ).toBe(false) + }) }) diff --git a/core/settings-store.js b/core/settings-store.js index 7eb5246..95b11da 100644 --- a/core/settings-store.js +++ b/core/settings-store.js @@ -13,6 +13,10 @@ const UI_DEFAULTS = { forceOnClient: false, switchEffect: "blur", notificationsEnabled: true, + // Electron-only global per-source mutes (t101): muteKeys (slack:{teamId} | adapter name) + // silenced on this single-device desktop. The web build never writes this plain key — it + // remaps to the device-keyed `notifMutes_` slot (t093) and this global stays []. + notifMutes: [], syncTheme: true, autoGrantLocalMedia: true, restoreLocalPins: true, diff --git a/core/settings-store.test.ts b/core/settings-store.test.ts index 04c70b5..0563c38 100644 --- a/core/settings-store.test.ts +++ b/core/settings-store.test.ts @@ -50,6 +50,13 @@ describe("settings-store", () => { expect(ui.notificationsEnabled_dev1).toBe(false) }) + it("round-trips a plain global notifMutes (Electron single-device mutes, t101)", () => { + const s = createSettingsStore({ initial: {}, persist }) + expect(s.getUiState().notifMutes).toEqual([]) // default: nothing muted + s.setUiState({ notifMutes: ["teams", "slack:T1"] }) + expect(s.getUiState().notifMutes).toEqual(["teams", "slack:T1"]) + }) + it("round-trips the t100 per-device client prefs (quality tier / transport / HUD)", () => { const s = createSettingsStore({ initial: {}, persist }) s.setUiState({ diff --git a/docs/tasks/096-investigate-for-arch-and-refactoring-predict-issue.md b/docs/tasks/done/096-investigate-for-arch-and-refactoring-predict-issue.md similarity index 99% rename from docs/tasks/096-investigate-for-arch-and-refactoring-predict-issue.md rename to docs/tasks/done/096-investigate-for-arch-and-refactoring-predict-issue.md index d5166c9..16babae 100644 --- a/docs/tasks/096-investigate-for-arch-and-refactoring-predict-issue.md +++ b/docs/tasks/done/096-investigate-for-arch-and-refactoring-predict-issue.md @@ -1,10 +1,11 @@ # 096 — Arch + refactoring sweep: verified fixes from the predict-issues / improve-architecture investigation -- **Status:** in-progress +- **Status:** done - **Mode:** HITL - **Estimate:** multi-session (~2–3d; ships as one PR with internal commit boundaries by area) - **Depends on:** none - **Blocks:** none +- **Shipped:** PR #10 (`f0a456a`) ## Goal diff --git a/docs/tasks/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md b/docs/tasks/done/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md similarity index 98% rename from docs/tasks/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md rename to docs/tasks/done/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md index 59f3755..c3b3fe8 100644 --- a/docs/tasks/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md +++ b/docs/tasks/done/098-slack-keeper-defers-to-pinned-workspace-tab-instea.md @@ -1,10 +1,11 @@ # 098 — slack keeper defers to pinned workspace tab instead of forcing reopen -- **Status:** in-progress +- **Status:** done - **Mode:** HITL - **Estimate:** 0.5d - **Depends on:** none - **Blocks:** none +- **Shipped:** PR #10 (`f0a456a`) — folded into the t096 sweep ## Goal diff --git a/docs/tasks/done/101-electron-notification-channel-mutes.md b/docs/tasks/done/101-electron-notification-channel-mutes.md new file mode 100644 index 0000000..5510782 --- /dev/null +++ b/docs/tasks/done/101-electron-notification-channel-mutes.md @@ -0,0 +1,124 @@ +# 101 — electron notification channel mutes (parity with pwa) + +- **Status:** done +- **Mode:** AFK +- **Estimate:** 1d +- **Depends on:** none +- **Blocks:** none + +## Goal + +The web PWA lets a user silence a noisy notification source per device — a "Mute on this device" +list with a row per service (Teams, Outlook) and per Slack workspace; a muted source stops +pushing, toasting, and bumping any badge, while still listing (dimmed) in the Inbox. The Electron +app has none of this: its Settings show only a single master "Desktop notifications" toggle, and +`shouldNotifyOs` ignores mutes entirely. After this task the Electron app has the same per-source +mute channels: toggling one silences its OS notification **and** removes it from the dock badge + +sidebar unread counts, at parity with the PWA. + +## Why now + +The user runs both surfaces and asked for Electron parity. Slack in particular is the noisiest +source; on the desktop app today it's all-or-nothing (the master toggle) with no way to silence one +workspace or Teams while keeping the rest. The mute *logic* already exists and is shared +(`core/notif-mutes.js` ↔ `src/lib/notif-mutes.ts`); this task wires it into the Electron OS-notify +path, badge, persistence, and Settings UI — mostly connecting existing pieces, not new invention. + +## Acceptance criteria + +- [ ] Electron Settings shows a per-source mute list: Teams + Outlook rows (always), plus one row + per Slack workspace **seen in the captured notification list** (label = the workspace name the + hijack entry carries in `source`; key = its `slack:{teamId}` groupKey). +- [ ] Toggling a mute row on Electron suppresses that source's **OS notification** (`shouldNotifyOs` + returns false for a muted entry) — verified with a live capture. +- [ ] A muted source is also excluded from the **dock badge** (`app.setBadgeCount`) and the + **sidebar tab/pin unread badges** — full parity with the PWA (no half-muted "silent but still + counting" state). A muted source still appears (dimmed) in the Inbox/bell list. +- [ ] The master toggle (`notificationsEnabled`) still works as today (off silences everything, + zeroes the badge); mutes compose under it. +- [ ] Electron mutes persist across app restart in `settings.json` as a plain global `notifMutes` + array (single device — no `deviceId` suffix). +- [ ] Web build is behavior-unchanged: it still remaps `notifMutes` → `notifMutes_`, + still reads its device slot, and the new global `notifMutes` default never leaks into a web + device's view. +- [ ] `pnpm typecheck`, `pnpm test`, `pnpm test:e2e`, `pnpm build`, `node --check web/server.mjs`, + `node --check main.js` all green; Biome clean on touched files. + +## Test plan + +### Layer 1 — Pure logic (TDD) + +- [ ] `core/notifications.js` `shouldNotifyOs` — a muted entry returns false even when enabled + + not-in-view; an unmuted entry is unaffected; empty/undefined `mutes` mutes nothing (opt-out). +- [ ] `core/notifications.js` `shouldNotifyOs` — master off still wins over mutes (returns false). +- [ ] `core/settings-store.js` — a plain global `notifMutes` round-trips through `getUiState`/ + `setUiState` and persists (it becomes a settable global key; the device-suffixed + `notifMutes_` path is unchanged). +- [ ] Slack-mute-row derivation (new pure helper) — from a notification list, returns one + `{ key, label }` per distinct Slack workspace (`slack:{teamId}` → representative `source`), + de-duped, stable order; ignores non-Slack entries; empty list → []. + +### Layer 2 — Manual smoke (CDP/IPC, Electron) + +Requires a live Remote Browser + a capturing adapter (Teams/Slack): + +- [ ] Trigger a Teams (or Slack) notification, confirm the OS notification fires; mute that source + in Settings; trigger again → no OS notification, entry still lists dimmed in the bell. +- [ ] Muting drops the dock badge count by that source's unread; unmuting restores it. +- [ ] Restart the app → the mute persists (still muted). +- [ ] Master off → no OS notifications, badge 0; master on → mutes still applied. + +### Layer 3 — Visual review + +- [ ] Screenshots via Chrome MCP against `pnpm dev` (web) confirming the existing PWA mute card is + unchanged, and (Electron, manual) the new Electron mute list renders: master + Teams/Outlook + + Slack workspace rows, no push row, no Slack-capture-health card. +- [ ] The four states: no captured Slack yet (only Teams/Outlook rows show), one workspace seen, + multiple workspaces, a muted row (switch on + source dimmed in the bell). + +## Design notes + +- **Contracts changed:** + - `core/notifications.js` `shouldNotifyOs(entry, opts)` — `opts` gains `mutes: string[]`; returns + false when `isMuted(mutes, entry)` (from `core/notif-mutes.js`). Backward compatible: absent + `mutes` ⇒ nothing muted. + - `core/settings-store.js` — `notifMutes` becomes a settable global ui-state key (default `[]`). + The web transport still remaps to `notifMutes_` and deletes the plain key before POST, + so this global is exercised only by Electron; on web it stays `[]` and is overridden by the + device slot in `getUiState`. + - `main.js` — `shouldNotifyOs` call passes `settings.notifMutes`; `updateBadge()` sets the dock + count via `unreadExcluding(list, settings.notifMutes, settings.notificationsEnabled)` instead of + the raw `unreadCount()`. +- **New modules / helpers:** + - One pure helper deriving Slack mute rows from the notification list (for the Electron UI, which + has no sweep/health). Home: alongside the other Slack presentation helpers in + `src/lib/notifications-view.ts` (mirrors `slackGroupMeta`, which already reads a workspace name + from `entry.source`), or `src/lib/notif-mutes.ts` — decide at build time by cohesion. Pure + + tested either way. +- **UI (shared, not duplicated — per review):** the `caps.web ? bigCard : smallCard` split in + `settings-dialog.tsx` collapses into **one** Notifications card used by both builds. Master + + the mute list (`MuteRow` over `[...ADAPTER_MUTE_ROWS, ...slackRows]`) render identically; only + the push row (`{caps.web && …}`) and the Slack-row *source* differ inline (web: sweep health; + Electron: `slackMuteRows(notifications)` — a new `notifications` prop threaded through Toolbar). + `app.tsx` ungates `muteOpts` / `unreadExcluding` so both builds' badges honor mutes + master. +- **New ADR needed?** No — this extends the existing per-source mute model (t093, `notif-mutes`) to + the Electron surface; no new architecture, just wiring an existing seam into the Electron path. + +## Out of scope + +- Web-push on Electron (it uses native OS notifications; the push toggle stays web-only). +- A Slack workspace registry / capture-health panel on Electron (no sweep there; the mute list is + sourced from captured entries, which is sufficient for muting what you've seen). +- Per-device muting on Electron (single device — one global `notifMutes`; no `deviceId`). +- Channel-level (per Slack channel) mutes on Electron — the PWA's `slackExcludes` Channel Exclude is + sweep-driven and stays web-only; Electron muting is per-workspace. + +## Definition of Done + +- [x] Layer 1 tests written and green (`shouldNotifyOs` mutes ×4, `settings-store` global notifMutes, + `slackMuteRows` ×5). Full gates: typecheck · 1024 unit · 49 e2e · build · node --check · Biome. +- [ ] Layer 2 Electron smoke via `pnpm install:local` (OS notify silenced when muted + dock badge + + persist across restart) — done at ship time on the installed app. +- [x] Layer 3: web card unchanged by construction (same components + adapters-then-Slack row order, + `caps.web`-true path preserved); Electron mute list confirmed via the install:local smoke. +- [x] Moved to `docs/tasks/done/` with the `t101` ID in branch + commit. diff --git a/main.js b/main.js index 3aa83a7..6e827d1 100644 --- a/main.js +++ b/main.js @@ -458,6 +458,8 @@ ipcMain.handle("cdp:set-ui-state", (_, partial) => { settingsStore.setUiState(partial) // Theme emulation tracks the syncTheme toggle; re-apply on the live socket. if ("syncTheme" in partial) applyThemeEmulation(activeWs) + // A mute/master change alters the dock-badge count (t101) — refresh it now. + if ("notifMutes" in partial || "notificationsEnabled" in partial) updateBadge() }) // Pins (live-tab holders). The renderer owns link state (`targetId`); main is the @@ -485,6 +487,7 @@ ipcMain.handle("cdp:get-theme-source", () => settingsStore.getThemeSource()) // injects only Electron effects (capture-script reads, /json target list, the // persisted store file, the OS Notification + dock badge gated by shouldNotifyOs). const { shouldNotifyOs } = require("./core/notifications") +const { unreadExcluding } = require("./core/notif-mutes") const { createNotificationCenter } = require("./core/notifications-sidechain") // Persisted store (separate from settings.json to keep that file lean). @@ -502,9 +505,17 @@ function saveNotifications(list) { } catch {} } -// Dock badge mirrors total unread (macOS). 0 clears it. +// Dock badge mirrors unread minus muted sources, and zeroes when the master is off (t101, +// PWA parity). 0 clears it. macOS. function updateBadge() { - if (typeof app.setBadgeCount === "function") app.setBadgeCount(notificationCenter.unreadCount()) + if (typeof app.setBadgeCount !== "function") return + app.setBadgeCount( + unreadExcluding( + notificationCenter.list(), + settings.notifMutes ?? [], + settings.notificationsEnabled ?? true, + ), + ) } // Retain shown Notification objects: Electron/V8 garbage-collects a Notification with no @@ -533,6 +544,7 @@ const notificationCenter = createNotificationCenter({ activeTabId, enabled: settings.notificationsEnabled ?? true, windowFocused, + mutes: settings.notifMutes ?? [], }) && Notification.isSupported() ) { diff --git a/src/app.tsx b/src/app.tsx index 1b71796..fd9bb45 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -820,28 +820,28 @@ export default function App() { return m }, [pins, tabs]) - // Per-device delivery prefs applied to the badges (t093, web only). On Electron these are - // inert (no mutes, master not used for badges) so byTab/byPin + the bell/inbox badge stay - // byte-unchanged. The Inbox/bell *lists* always read the unfiltered `notifications`. + // Delivery prefs applied to the badges — per-device on web (t093), one global list on Electron + // (t101). Both builds now honor mutes + the master in badge counts (full parity); the Inbox/bell + // *lists* always read the unfiltered `notifications`. const muteOpts = useMemo( - () => (caps.web ? { mutes: notifMutes, master: notificationsEnabled } : undefined), - [caps.web, notifMutes, notificationsEnabled], + () => ({ mutes: notifMutes, master: notificationsEnabled }), + [notifMutes, notificationsEnabled], ) // Per-tab and per-pin unread badge counts, grouped so every tab/pin of the same app // shares one count and a dormant pin badges by its saved URL's origin. Excludes this - // device's muted sources on web; byte-unchanged on Electron (muteOpts undefined). + // device's muted sources on both builds (t101). const { byTab: unreadByTab, byPin: unreadByPin } = useMemo( () => aggregateUnread(notifications, tabs, pins, linkedTabByPin, teamGroupMap, muteOpts), [notifications, tabs, pins, linkedTabByPin, teamGroupMap, muteOpts], ) - // The bell/inbox/home-screen badge count — excludes this device's muted sources and goes - // to 0 when the device master is off (web only). Undefined on Electron, so the bell/inbox - // fall back to their own `notifications.filter(!read)` count (byte-unchanged). + // The bell/inbox/home-screen badge count — excludes muted sources and goes to 0 when the + // master is off. Both builds now (t101); the web build also mirrors it to the home-screen icon + // via setAppBadge, Electron mirrors the dock badge in main.js. const deviceUnread = useMemo( - () => (caps.web ? unreadExcluding(notifications, notifMutes, notificationsEnabled) : undefined), - [caps.web, notifications, notifMutes, notificationsEnabled], + () => unreadExcluding(notifications, notifMutes, notificationsEnabled), + [notifications, notifMutes, notificationsEnabled], ) const handleNotificationsEnabledChange = useCallback((enabled: boolean) => { @@ -849,9 +849,9 @@ export default function App() { window.cdp.setUiState({ notificationsEnabled: enabled }) }, []) - // Toggle a source's mute on this device (t093, web only). The muteKey is a Slack - // workspace's `slack:{groupId}` or an adapter name; persists to the device-keyed - // `notifMutes_` ui-state slot via the transport remap. + // Toggle a source's mute. The muteKey is a Slack workspace's groupKey or an adapter name; + // persists to the device-keyed `notifMutes_` slot on web (via the transport remap, + // t093) or the plain global `notifMutes` on Electron (t101). main.js refreshes the dock badge. const handleToggleMute = useCallback((key: string) => { setNotifMutes((prev) => { const next = toggleMute(prev, key) diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 1c480cc..8f4e723 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -28,6 +28,7 @@ import { Switch } from "@/components/ui/switch" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { isPointerFine, usePointerCoarse } from "@/hooks/use-pointer-coarse" import { getCaps } from "@/lib/caps" +import { type MuteEntry, slackMuteRows } from "@/lib/notif-mutes" import { createBrowserPushDeps, ensurePushSubscription, @@ -108,10 +109,14 @@ interface SettingsDialogProps { onSwitchEffectChange: (effect: SwitchEffect) => void notificationsEnabled: boolean onNotificationsEnabledChange: (enabled: boolean) => void - /** This device's muted sources (muteKeys) and the toggle (t093, web only — the - * per-device "Notifications (this device)" card drives them). Inert on Electron. */ + /** Muted sources (muteKeys) + the toggle. Per-device on web (t093), a single global list + * on Electron (t101) — the shared Notifications card drives both. */ notifMutes: string[] onToggleMute: (key: string) => void + /** Captured notifications — the source for Electron's per-workspace Slack mute rows (t101); + * the web build lists workspaces from the sweep's capture health instead. Typed to the + * minimal mute shape (only adapter/groupKey/source are read). */ + notifications: MuteEntry[] syncTheme: boolean onSyncThemeChange: (enabled: boolean) => void autoGrantLocalMedia: boolean @@ -206,6 +211,7 @@ export function SettingsDialog({ onNotificationsEnabledChange, notifMutes, onToggleMute, + notifications, syncTheme, onSyncThemeChange, autoGrantLocalMedia, @@ -695,30 +701,33 @@ export function SettingsDialog({ - {/* Notifications. Web (PWA across devices): a per-device card — master + - push + per-source mutes, all scoped to THIS device (t093). Electron is - single-device, so it keeps the one global toggle. */} - {caps.web ? ( - -
- {/* Master — the per-device softer mute; off silences everything on this - device without unsubscribing push (the push toggle owns the sub). */} -
-
- -

- Master switch for this device. Off silences pushes, toasts, and the - badge here — other devices are unaffected. -

-
- + {/* Notifications — one shared card for both builds (t101). Master + per-source + mutes are identical; the push row is web-only (Electron uses native OS + notifications), and the Slack mute rows come from the sweep's capture health + on web vs captured entries (slackMuteRows) on Electron — both render as the + same {key,label} rows. Mutes are per-device on web (t093), one global list on + Electron. */} + +
+ {/* Master — off silences everything (and zeroes the badge) on this device. */} +
+
+ +

+ {caps.web + ? "Master switch for this device. Off silences pushes, toasts, and the badge here — other devices are unaffected." + : "Master switch. Off silences system notifications and the dock badge."} +

+ +
- {/* Push — owns the subscription + permission grant. */} + {/* Push — web only (owns the subscription + permission grant). */} + {caps.web && (
+ )} - {/* Mute on this device — per-service (Teams/Outlook, always shown) + - per-Slack-workspace (one row per merged Grid org, from health). */} -
- -

- A muted source still appears in the Inbox (dimmed) — it just won't push, - toast, or bump the badge here. -

-
    - {ADAPTER_MUTE_ROWS.map((row) => ( - onToggleMute(row.key)} - /> - ))} - {slackHealth.map((w) => { - // A Slack source's muteKey is its merged-workspace groupKey - // (see notif-mutes.ts muteKey). - const key = `slack:${w.groupId}` - return ( - onToggleMute(key)} - /> - ) - })} -
-
-
- - ) : ( - -
-
- -

- Show a system notification for Teams messages when its tab isn't in view. -

-
- + {/* Mute — per-service (Teams/Outlook) + per-Slack-workspace rows. The Slack + rows' source differs by build; the render is shared. */} +
+ +

+ {caps.web + ? "A muted source still appears in the Inbox (dimmed) — it just won't push, toast, or bump the badge here." + : "A muted source still appears in the Inbox (dimmed) — it just won't notify or bump the badge."} +

+
    + {[ + ...ADAPTER_MUTE_ROWS, + ...(caps.web + ? slackHealth.map((w) => ({ key: `slack:${w.groupId}`, label: w.name })) + : slackMuteRows(notifications)), + ].map((row) => ( + onToggleMute(row.key)} + /> + ))} +
- - )} +
+
{caps.web && slackHealth.length > 0 && ( diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 8b8a86e..0ae68f7 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -447,6 +447,7 @@ export const Toolbar = forwardRef(function Toolbar( emulatedSize={emulatedSize} forceOnClient={forceOnClient} localExtensions={localExtensions} + notifications={notifications} notificationsEnabled={notificationsEnabled} notifMutes={notifMutes} onAdaptiveViewportChange={onAdaptiveViewportChange} diff --git a/src/lib/CLAUDE.md b/src/lib/CLAUDE.md index 97cb35a..74da2bd 100644 --- a/src/lib/CLAUDE.md +++ b/src/lib/CLAUDE.md @@ -58,11 +58,11 @@ Domain modules that form the renderer's logic layer, plus a React hook that wire **`find-bar.ts`** — Pure find-state machine behind the in-page find overlay (t001). `reduce(state, action)` over `{ open, query, currentIndex, total }` with `open`/`close`/`setQuery`/`setTotal`/`next`/`prev` actions — `setQuery` resets the count pending the remote search, `setTotal` clamps `currentIndex` into `[0, total)`, `next`/`prev` wrap at both ends and no-op when `total === 0`. `counterLabel(state)` derives the 1-based display ("3/12" | "0/0" no-match | "" empty). No I/O — no `document`, no `window.find`, no transport, no timers; the effectful remote search is enacted by `find-bar.tsx`, which feeds the reported `total` back via `setTotal`. Same pure-advisor / effectful-executor split as `tab-lifecycle.ts`. Tested by `find-bar.test.ts`. -**`notif-mutes.ts`** — Pure per-device notification mute logic (t093, web build). `muteKey(entry)` is the source-mute key: a Slack entry's merged `groupKey` (`slack:{groupId}`, t092; `"slack"` when absent), else the adapter name — so muting is per-workspace for Slack and per-service for Teams/Outlook. `isMuted(mutes, entry)` (mutes is an array or Set; `undefined` = nothing muted, the opt-out default), `toggleMute(mutes, key)` (add/remove, new array, never mutates), and `unreadExcluding(list, mutes, masterOn)` (count of unread entries whose muteKey isn't muted; `0` when the master is off) — the per-device badge number. Mirrors `core/notif-mutes.js` (the server uses the CJS copy for the push gate + per-device unread; the renderer can't import CJS, the server can't import this ESM — same duplication as `core/notifications.js` `slackGroupKey` ↔ this dir). `toggleMute` is renderer-only. Capture stays global; these only gate *delivery* on the device whose ui-state holds the mute. Consumed by `cdp-web-transport.ts` (toast gate), `app.tsx` (badges via `unreadExcluding` + `aggregateUnread`'s `muteOpts`), and the settings card / bell / inbox. Tested by `notif-mutes.test.ts`. +**`notif-mutes.ts`** — Pure notification mute logic (t093; Electron parity t101). `muteKey(entry)` is the source-mute key: a Slack entry's merged `groupKey` (`slack:{groupId}`, t092; `"slack"` when absent), else the adapter name — so muting is per-workspace for Slack and per-service for Teams/Outlook. `isMuted(mutes, entry)` (mutes is an array or Set; `undefined` = nothing muted, the opt-out default), `toggleMute(mutes, key)` (add/remove, new array, never mutates), and `unreadExcluding(list, mutes, masterOn)` (count of unread entries whose muteKey isn't muted; `0` when the master is off) — the per-device badge number. Mirrors `core/notif-mutes.js` (the server uses the CJS copy for the push gate + per-device unread; the renderer can't import CJS, the server can't import this ESM — same duplication as `core/notifications.js` `slackGroupKey` ↔ this dir). `toggleMute` is renderer-only. `slackMuteRows(list)` (t101) derives one `{ key, label }` mute row per distinct Slack workspace seen in a notification list — keyed by muteKey (`slack:{teamId}`), labelled by the entry's `source` (workspace name; first non-empty wins, else the key), first-seen order, non-Slack ignored — the source for the Electron mute list (which has no sweep/health; web lists workspaces from the sweep health instead). Capture stays global; these only gate *delivery*. Consumed by `cdp-web-transport.ts` (web toast gate), `app.tsx` (badges via `unreadExcluding` + `aggregateUnread`'s `muteOpts`, both builds since t101), the shared settings notification card, and — mirrored in `core/notif-mutes.js` — the web push gate + the Electron `shouldNotifyOs`/dock badge (t101). Tested by `notif-mutes.test.ts`. **`device-prefs.ts`** — Pure owner of the three durable per-device client prefs (t100, web build): `qualityTier` / `inputTransport` / `latencyHud`, persisted in server ui-state under `_` (like `notif-mutes`/`webPush`, t093/t095) so they survive an iPad-PWA localStorage wipe instead of resetting. `readDevicePrefs(ui, deviceId)` resolves each key device-slot → (qualityTier only) plain global shadow → default, parse-guarded (`parseTier`/transport-enum/strict-boolean); the qualityTier global fallback IS the migration path (a device with no slot inherits the pre-t100 global value, so nothing resets on first load). `writeDevicePrefs(partial, deviceId)` emits only the present keys' `_` slots, and a `qualityTier` write additionally emits the plain global `qualityTier` **shadow** — the shared-screencast connector (`core/remote-page-connector.js`) reads that global at connect, so mirroring this device's tier into it makes the connecting device's tier apply without any server change (`inputTransport`/`latencyHud` are client-only — the server never reads them, so they touch no global). `deviceKey(base, deviceId)` is the slot-name join. The CJS side (`core/settings-store.js` `DEVICE_KEY_PREFIXES`) keeps its own prefix list — same ESM↔CJS duplication as `notif-mutes.ts` ↔ `core/notif-mutes.js`. Effects live in `cdp-web-transport.ts` (`getUiState` resolves + reconfigures the transport + reconciles the shadow; `setUiState` remaps). Tested by `device-prefs.test.ts`. -**`unread-aggregator.ts`** — Pure unread-count aggregation. `aggregateUnread(notifications, tabs, pins, linkedTabByPin, teamGroupMap?, muteOpts?)` makes one pass over the notification list to build `{ byGroup, byTab, byPin }` count maps. The shared key derivation is `groupKeyForUrl(url)` = `slackGroupKey(url) ?? originOf(url)`: a notification keys by its stamped `groupKey` (else `groupKeyForUrl(targetUrl)`), and a Tab/Pin resolves through the **same** derivation so Slack tabs — which all share the `app.slack.com` origin — bucket by their per-workspace `slack:{teamId}` key (mirrors `core/notifications.js` `slackGroupKey`; one tab per workspace, so the URL is authoritative) instead of merging under one origin badge. Non-Slack apps fall back to origin (unchanged). A Pin resolves through its linked Tab's live URL (via `linkedTabByPin`) or its saved URL. The optional `teamGroupMap` (teamId → groupId, t092) merges Enterprise Grid Slack teams: a Tab/Pin URL's extracted teamId maps to `slack:{groupId}` (`map[teamId] || teamId`) so an org pseudo-team tab and its member workspace share one count; omitted/no-entry → today's per-team behavior. Notifications already carry the merged `groupKey` from the server sweep, so the map only affects Tab/Pin URL resolution. `app.tsx` fetches the map from `/api/notifications/health` (`.groups`, web only). The optional `muteOpts` (`{ mutes, master }`, t093, web only) applies this device's delivery prefs to the tally: a muted source's notifications are skipped in `byGroup` (so byTab/byPin inherit the exclusion via `isMuted` from `notif-mutes.ts`), and `master: false` zeroes every count — keeping the sidebar tab/pin badges honest per device. Omitted = no muting (byte-unchanged; the Electron path passes nothing). The Inbox/bell *lists* read the unfiltered notifications; only the badge counts honor the mute. Replaces the per-origin inline accounting that was scattered across `app.tsx`. +**`unread-aggregator.ts`** — Pure unread-count aggregation. `aggregateUnread(notifications, tabs, pins, linkedTabByPin, teamGroupMap?, muteOpts?)` makes one pass over the notification list to build `{ byGroup, byTab, byPin }` count maps. The shared key derivation is `groupKeyForUrl(url)` = `slackGroupKey(url) ?? originOf(url)`: a notification keys by its stamped `groupKey` (else `groupKeyForUrl(targetUrl)`), and a Tab/Pin resolves through the **same** derivation so Slack tabs — which all share the `app.slack.com` origin — bucket by their per-workspace `slack:{teamId}` key (mirrors `core/notifications.js` `slackGroupKey`; one tab per workspace, so the URL is authoritative) instead of merging under one origin badge. Non-Slack apps fall back to origin (unchanged). A Pin resolves through its linked Tab's live URL (via `linkedTabByPin`) or its saved URL. The optional `teamGroupMap` (teamId → groupId, t092) merges Enterprise Grid Slack teams: a Tab/Pin URL's extracted teamId maps to `slack:{groupId}` (`map[teamId] || teamId`) so an org pseudo-team tab and its member workspace share one count; omitted/no-entry → today's per-team behavior. Notifications already carry the merged `groupKey` from the server sweep, so the map only affects Tab/Pin URL resolution. `app.tsx` fetches the map from `/api/notifications/health` (`.groups`, web only). The optional `muteOpts` (`{ mutes, master }`, t093) applies this device's delivery prefs to the tally: a muted source's notifications are skipped in `byGroup` (so byTab/byPin inherit the exclusion via `isMuted` from `notif-mutes.ts`), and `master: false` zeroes every count — keeping the sidebar tab/pin badges honest per device. `app.tsx` passes it unconditionally on both builds since t101 (device-keyed mutes on web, the plain global `notifMutes` on Electron); omitted = no muting. The Inbox/bell *lists* read the unfiltered notifications; only the badge counts honor the mute. Replaces the per-origin inline accounting that was scattered across `app.tsx`. **`hotkey-registry.ts`** — the single source of truth shared by the `⌘K` command palette and the `⌘/` shortcut overlay (t058). An `Action` is plain data `{ id, name, group, hotkey?, run }`; `group` is one of the ux.md categories (`OVERLAY_GROUPS`: Global / Tab navigation / Sidebar / Address bar). `buildActions(input)` drops falsy entries (so callers splice conditionally — `caps.localTabs && action` — without pre-filtering) and returns a fresh array preserving registration order. `filterActions(actions, query)` is the palette's case-insensitive substring filter over name + group label (empty query → unchanged list; no match → []). `groupForOverlay(actions)` partitions into the ux.md categories for the overlay, keeping only actions that carry a hotkey (the overlay is a shortcut reference). `hotkeyHint(action)` reports the display string or undefined. Pure: no React, no IPC, no DOM, no `window` — `app.tsx` injects every `run` (each points at the *existing* keydown-switch / toolbar handler, so the palette is presentation, never a second copy of effect logic). Tested by `hotkey-registry.test.ts`. @@ -155,7 +155,7 @@ The transport is split into three named seams assembled by a thin shim: - Active Order (`active-order.ts`) is pure — returns new arrays, never mutates. Callers hold the array in state; `app.tsx` drives effects (which tab to activate on close). - Tab Lifecycle (`tab-lifecycle.ts`) is pure — returns directives, never executes them. `app.tsx` applies every effect (close the target, swap the active surface, push the Closed Tabs entry, revert the Pin, persist). - Unread Aggregator (`unread-aggregator.ts`) is pure — `aggregateUnread` is a plain function over data. `app.tsx` supplies `linkedTabByPin` and holds the result in state. The optional `muteOpts` arg is web-only (Electron passes nothing → byte-unchanged). -- Notif Mutes (`notif-mutes.ts`) is pure — no I/O, no React, no DOM. `muteKey`/`isMuted`/`unreadExcluding` mirror `core/notif-mutes.js` byte-for-byte (the server copy gates push + per-device unread; `toggleMute` is renderer-only). Capture is global; these gate delivery only. `app.tsx` owns the device-keyed ui-state writes (through the transport remap seam); the toast gate lives in `cdp-web-transport.ts`. +- Notif Mutes (`notif-mutes.ts`) is pure — no I/O, no React, no DOM. `muteKey`/`isMuted`/`unreadExcluding` mirror `core/notif-mutes.js` byte-for-byte (the server copy gates web push + per-device unread; the CJS copy also gates the Electron `shouldNotifyOs`/dock badge, t101); `toggleMute` and `slackMuteRows` are renderer-only. Capture is global; these gate delivery only. `app.tsx` owns the ui-state writes — device-keyed on web (transport remap seam), a plain global `notifMutes` on Electron; the web foreground toast gate lives in `cdp-web-transport.ts`. - Device Prefs (`device-prefs.ts`) is pure — no I/O, no React, no DOM. `readDevicePrefs`/`writeDevicePrefs`/`deviceKey` are plain functions over a ui-state snapshot. The qualityTier global shadow is the ONLY place that couples a per-device pref to a global key (so the shared-screencast connector applies the connecting device's tier); `inputTransport`/`latencyHud` never touch a global. Effects (the reconfigure on transport-mode change, the shadow reconcile) live in `cdp-web-transport.ts`. - Find Bar (`find-bar.ts`) is pure — `reduce`/`counterLabel` are a state machine over data, no `window.find`/DOM/transport. `find-bar.tsx` enacts the remote search (`page.find`/`findStep`/`clearFind`) and feeds `total` back via `setTotal`. - Notification Activation (`notification-activation.ts`) is pure — no Remote Page reference, no IPC. `app.tsx` calls `page[method](arg)` after resolving the intention. diff --git a/src/lib/notif-mutes.test.ts b/src/lib/notif-mutes.test.ts index c78c146..e68b28e 100644 --- a/src/lib/notif-mutes.test.ts +++ b/src/lib/notif-mutes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { isMuted, muteKey, toggleMute, unreadExcluding } from "./notif-mutes" +import { isMuted, muteKey, slackMuteRows, toggleMute, unreadExcluding } from "./notif-mutes" describe("muteKey", () => { it("uses the groupKey for a Slack entry (per merged workspace)", () => { @@ -103,3 +103,46 @@ describe("unreadExcluding", () => { expect(unreadExcluding(list(), undefined, true)).toBe(4) }) }) + +describe("slackMuteRows (t101 — Electron per-workspace mute list from captured entries)", () => { + it("returns one row per distinct Slack workspace, labelled by source", () => { + const rows = slackMuteRows([ + { adapter: "slack", groupKey: "slack:T1", source: "Acme" }, + { adapter: "slack", groupKey: "slack:T2", source: "Globex" }, + ]) + expect(rows).toEqual([ + { key: "slack:T1", label: "Acme" }, + { key: "slack:T2", label: "Globex" }, + ]) + }) + + it("de-dupes multiple entries of the same workspace, first-seen order", () => { + const rows = slackMuteRows([ + { adapter: "slack", groupKey: "slack:T2", source: "Globex" }, + { adapter: "slack", groupKey: "slack:T1", source: "Acme" }, + { adapter: "slack", groupKey: "slack:T1", source: "Acme" }, + ]) + expect(rows).toEqual([ + { key: "slack:T2", label: "Globex" }, + { key: "slack:T1", label: "Acme" }, + ]) + }) + + it("takes the first non-empty source as the label", () => { + const rows = slackMuteRows([ + { adapter: "slack", groupKey: "slack:T1", source: "" }, + { adapter: "slack", groupKey: "slack:T1", source: "Acme" }, + ]) + expect(rows).toEqual([{ key: "slack:T1", label: "Acme" }]) + }) + + it("falls back to the key as label when no source is present", () => { + const rows = slackMuteRows([{ adapter: "slack", groupKey: "slack:T9" }]) + expect(rows).toEqual([{ key: "slack:T9", label: "slack:T9" }]) + }) + + it("ignores non-Slack entries and returns [] for an empty list", () => { + expect(slackMuteRows([{ adapter: "teams" }, { adapter: "outlook" }])).toEqual([]) + expect(slackMuteRows([])).toEqual([]) + }) +}) diff --git a/src/lib/notif-mutes.ts b/src/lib/notif-mutes.ts index 8913d05..39d45ff 100644 --- a/src/lib/notif-mutes.ts +++ b/src/lib/notif-mutes.ts @@ -15,6 +15,15 @@ export interface MuteEntry { adapter?: string | null groupKey?: string read?: boolean + /** For a Slack entry, the workspace name the hijack ships (inject/slack-notify.js). Used + * only to label the Electron mute rows (t101) — see slackMuteRows. */ + source?: string +} + +/** A muteable Slack workspace row for the Electron mute list (t101). */ +export interface SlackMuteRow { + key: string + label: string } /** A collection of muted mute-keys — an array (the stored shape) or a Set. */ @@ -44,6 +53,27 @@ export function toggleMute(mutes: readonly string[], key: string): string[] { return mutes.includes(key) ? mutes.filter((k) => k !== key) : [...mutes, key] } +// Derive one mute row per distinct Slack workspace seen in the captured notification list — +// the source for the Electron mute UI, which has no sweep/health to enumerate workspaces +// (t101). Keyed by muteKey (slack:{teamId}); label is the workspace name the hijack entry +// carries in `source` (first non-empty wins), falling back to the key. First-seen order; +// non-Slack entries ignored; empty list → []. (Web uses the sweep's health rows instead.) +export function slackMuteRows(list: MuteEntry[]): SlackMuteRow[] { + const order: string[] = [] + const labels = new Map() + for (const e of list) { + if (e.adapter !== "slack") continue + const key = muteKey(e) + if (!labels.has(key)) { + labels.set(key, "") + order.push(key) + } + const src = (e.source ?? "").trim() + if (src && !labels.get(key)) labels.set(key, src) + } + return order.map((key) => ({ key, label: labels.get(key) || key })) +} + // Count of unread entries whose muteKey is NOT muted on this device — the per-device badge // number. 0 when the device master is off (a muted device shows a cleared badge). export function unreadExcluding(list: MuteEntry[], mutes: Mutes, masterOn: boolean): number {