Skip to content
Merged
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
6 changes: 3 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<deviceId>` (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_<deviceId>` 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**:
Expand Down
7 changes: 6 additions & 1 deletion core/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions core/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
4 changes: 4 additions & 0 deletions core/settings-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_<deviceId>` slot (t093) and this global stays [].
notifMutes: [],
syncTheme: true,
autoGrantLocalMedia: true,
restoreLocalPins: true,
Expand Down
7 changes: 7 additions & 0 deletions core/settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down
124 changes: 124 additions & 0 deletions docs/tasks/done/101-electron-notification-channel-mutes.md
Original file line number Diff line number Diff line change
@@ -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_<deviceId>`,
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_<id>` 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_<deviceId>` 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.
16 changes: 14 additions & 2 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -533,6 +544,7 @@ const notificationCenter = createNotificationCenter({
activeTabId,
enabled: settings.notificationsEnabled ?? true,
windowFocused,
mutes: settings.notifMutes ?? [],
}) &&
Notification.isSupported()
) {
Expand Down
28 changes: 14 additions & 14 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -820,38 +820,38 @@ 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) => {
setNotificationsEnabled(enabled)
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_<deviceId>` 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_<deviceId>` 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)
Expand Down
Loading