# Notifications Domain

Owns desktop notification delivery, unread tracking, mute preferences, and the notifications settings UI. This domain exists separately from `store/messages` because the messages slice only keeps the currently viewed room in memory and clears when the user switches rooms, while notification state must survive navigation and be reconstructable across every saved server.

## Module map

```
notifications/
├── application/
│   ├── facades/
│   │   └── notifications.facade.ts             Thin domain boundary, delegates to NotificationsService
│   ├── services/
│   │   └── notifications.service.ts            Stateful orchestrator: settings, unread counts, read markers, delivery decisions
│   └── effects/
│       └── notifications.effects.ts            NgRx glue reacting to room, user, and message actions
│
├── domain/
│   ├── logic/
│   │   └── notification.logic.ts               Pure rules for mute checks, visibility, preview formatting, unread aggregation
│   └── models/
│       └── notification.model.ts               Settings, unread state, delivery context, and payload contracts
│
├── infrastructure/
│   └── services/
│       ├── desktop-notification.service.ts     Electron / browser adapter for desktop alerts and window attention
│       └── notification-settings-storage.service.ts    localStorage persistence with defensive deserialisation
│
├── feature/
│   └── settings/
│       ├── notifications-settings.component.ts
│       └── notifications-settings.component.html
│
└── index.ts                                Barrel exports
```

## Service relationships

```mermaid
graph TD
    App[App]
    Effects[NotificationsEffects]
    Settings[NotificationsSettingsComponent]
    Rail[ServersRailComponent]
    Sidebar[RoomsSidePanelComponent]
    Facade[NotificationsFacade]
    Service[NotificationsService]
    Logic[notification.logic]
    Storage[NotificationSettingsStorageService]
    DB[DatabaseService]
    Desktop[DesktopNotificationService]
    Audio[NotificationAudioService]
    Store[NgRx Store]

    App --> Facade
    Effects --> Facade
    Effects --> Store
    Settings --> Facade
    Rail --> Facade
    Sidebar --> Facade
    Facade --> Service
    Service --> Logic
    Service --> Storage
    Service --> DB
    Service --> Desktop
    Service --> Audio

    click Facade "application/facades/notifications.facade.ts" "Thin domain boundary" _blank
    click Service "application/services/notifications.service.ts" "Stateful orchestrator" _blank
    click Effects "application/effects/notifications.effects.ts" "NgRx glue" _blank
    click Logic "domain/logic/notification.logic.ts" "Pure notification rules" _blank
    click Storage "infrastructure/services/notification-settings-storage.service.ts" "localStorage persistence" _blank
    click Desktop "infrastructure/services/desktop-notification.service.ts" "Desktop notification adapter" _blank
    click Settings "feature/settings/notifications-settings.component.ts" "Notifications settings UI" _blank
    click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank
```

## Runtime wiring

The domain has two runtime entry points:

- `NotificationsFacade` is injected directly by app bootstrapping and feature components. It is a thin pass-through that delegates to `NotificationsService`.
- `NotificationsEffects` is registered globally in `provideEffects(...)` and forwards store actions into the facade.

All effects in this domain are `dispatch: false`. The effect layer never owns notification business rules; it only connects NgRx actions to `NotificationsFacade`.

| Entry point | Trigger | Responsibility |
|---|---|---|
| `NotificationsFacade.initialize()` | `App.ngOnInit()` | Loads persisted settings, installs focus/visibility listeners, syncs the room catalog, and marks the active channel read if it is visible |
| `syncRoomCatalog$` | Room lifecycle actions such as load, join, view, add/remove channel | Reconciles muted room/channel maps and prunes unread state for missing channels |
| `hydrateUnreadCounts$` | `loadRoomsSuccess` and `loadCurrentUserSuccess` | Rebuilds unread counts from persisted messages once both rooms and user identity exist |
| `markVisibleChannelRead$` | Room activation and channel selection | Clears unread for the current visible channel |
| `handleIncomingMessage$` | `MessagesActions.receiveMessage` | Updates unread counts and triggers desktop delivery for live messages |
| `refreshViewedRoomUnread$` | `loadMessagesSuccess` | Recomputes unread counts for the active room from the latest viewed-room snapshot |
| `refreshSyncedRoomUnread$` | `syncMessages` | Recomputes unread counts for every room represented in a sync batch, including background servers |

## Incoming message flow

```mermaid
sequenceDiagram
    participant Msg as MessagesActions.receiveMessage
    participant Fx as NotificationsEffects
    participant Facade as NotificationsFacade
    participant Logic as notification.logic
    participant Desktop as DesktopNotificationService
    participant Audio as NotificationAudioService

    Msg->>Fx: receiveMessage(message)
    Fx->>Facade: handleIncomingMessage(message)
    Facade->>Facade: ignore duplicate / own message
    Facade->>Facade: ensure room baseline exists

    alt deleted message
        Facade-->>Fx: stop
    else visible active channel
        Facade->>Facade: markChannelRead(roomId, channelId)
        Facade-->>Fx: stop
    else hidden or backgrounded
        Facade->>Facade: incrementUnread(roomId, channelId)
        Facade->>Logic: shouldDeliverNotification(...)

        alt delivery allowed
            Facade->>Logic: buildNotificationDisplayPayload(...)
            opt Windows
                Facade->>Audio: play(Notification)
            end
            Facade->>Desktop: showNotification(payload)
        else muted / busy / disabled
            Facade-->>Fx: unread only
        end
    end
```

## Read model and unread tracking

Unread state is modeled as a combination of persisted read markers plus in-memory derived counts.

| Field | Stored in | Meaning |
|---|---|---|
| `roomBaselines[roomId]` | localStorage | Lower bound for unread reconstruction in a room |
| `lastReadByChannel[roomId][channelId]` | localStorage | Latest timestamp the user has acknowledged for a text channel |
| `roomCounts[roomId]` | in memory | Sum of unread text messages across the room |
| `channelCounts[roomId][channelId]` | in memory | Per-text-channel unread count |

Important design constraint: unread counters are intentionally not persisted. The persisted state stores only the user-controlled settings and the read markers needed to derive unread counts again.

### Why baselines exist

The domain must avoid marking an entire historical backlog as unread the first time a room appears.

- When `syncRoomCatalog()` sees a room for the first time, its baseline is set to `Date.now()`. Old stored messages stay treated as historical backlog.
- When a live message arrives before the room has been catalogued, `ensureRoomTracking()` uses `message.timestamp - 1` so that the current live message still counts as unread.

### Channel scope

Only text channels participate in unread tracking. If a room has no text-channel metadata yet, the domain falls back to `DEFAULT_TEXT_CHANNEL_ID = "general"` so delivery and unread bookkeeping can still proceed.

### Rebuilding unread counts

`hydrateUnreadCounts()` reconstructs unread state from persistence rather than trusting stale counters.

1. Determine the room baseline with `getRoomTrackingBaseline(...)`.
2. Load messages newer than that point through `DatabaseService.getMessagesSince(roomId, sinceTimestamp)`.
3. Run `calculateUnreadForRoom(...)`.
4. Ignore deleted messages and messages sent by the current user.
5. Count only text-channel messages whose timestamp is newer than the channel's last-read marker.

The persistence backend differs by runtime, but the contract is the same:

- Browser: `BrowserDatabaseService.getMessagesSince(...)` filters IndexedDB room messages by timestamp and returns them oldest-first.
- Electron: `ElectronDatabaseService.getMessagesSince(...)` sends a `get-messages-since` CQRS query; the main process uses TypeORM to return rows ordered by ascending timestamp.

### Clearing unread state

Unread is cleared by channel, not globally.

- `markCurrentChannelReadIfActive()` only runs when the window is focused and the document is visible.
- If a live message arrives in the currently visible text channel, the domain immediately marks that channel read instead of incrementing unread.
- Window focus and document visibility changes both clear taskbar attention and mark the active channel read.
- `pruneUnreadState()` removes deleted or non-text channels from the unread maps and re-derives room totals from channel totals.

## Delivery rules

The pure rules live in `domain/notification.logic.ts`; the facade applies them to live message events.

| Rule | Effect |
|---|---|
| `enabled === false` | Suppresses desktop delivery everywhere, but unread badges still update |
| `respectBusyStatus === true` and `currentUser.status === "busy"` | Suppresses desktop delivery |
| Room muted | Suppresses desktop delivery for the whole server |
| Channel muted | Suppresses desktop delivery for that text channel |
| Message already visible in the active room + channel while focused | No desktop alert; mark read immediately |
| `showPreview === false` | Notification body becomes a generic "sender sent a new message" message |

Additional runtime guards:

- Deleted messages never notify.
- The current user's own messages never notify.
- Duplicate live events are suppressed with a rolling in-memory set of the last 500 notified message IDs.
- Unread badges are independent from mute state. Muting changes delivery only; it does not hide unread indicators.

## Desktop delivery and platform adapters

`DesktopNotificationService` is the only infrastructure service that actually shows a system notification.

### Renderer side

- Prefer the Electron bridge if `showDesktopNotification` is available.
- Fall back to the browser `Notification` API when running outside Electron.
- Request browser notification permission lazily on first use.
- `clearAttention()` delegates to the Electron bridge when available.

### Main-process behavior

The Electron main process handles the actual desktop notification and window-attention behavior.

- `show-desktop-notification` creates a system `Notification` with the window icon when supported.
- `show-desktop-notification` skips the OS toast while the main window is visible and maximized.
- Notification clicks restore, show, and focus the main window.
- `request-window-attention` flashes the taskbar entry directly when Electron is minimized or otherwise backgrounded and actionable unread exists.
- `show-desktop-notification` can still request attention for live toast delivery.
- `clear-window-attention` and the window `focus` event both call `flashFrame(false)`.

### Platform-specific policy

- Windows: the facade only plays `AppSound.Notification` when the app is not the active selected window.
- Linux: desktop alerts are expected to surface through the system notification center, with window attention requested when the app is backgrounded.
- macOS and browser-only builds use the same desktop notification adapter, but there is no extra renderer-side sound policy in this domain.

Taskbar attention is synchronized from the unread model, not only from live toast delivery. If the Electron app is minimized or otherwise backgrounded and there is at least one actionable unread message in an unmuted room or channel, the taskbar entry is asked to flash until focus is restored or the actionable unread state clears.

## Settings persistence

Notification settings are stored as a single JSON document under `metoyou_notification_settings`.

| Setting | Purpose |
|---|---|
| `enabled` | Master switch for desktop delivery |
| `showPreview` | Includes or suppresses message text previews |
| `respectBusyStatus` | Suppresses alerts while the current user is busy |
| `mutedRooms` | Per-room mute flags |
| `mutedChannels` | Per-room per-channel mute flags |
| `roomBaselines` | Per-room unread reconstruction lower bounds |
| `lastReadByChannel` | Per-channel read markers |

`NotificationSettingsStorageService` defensively normalises every nested boolean and number map on load. Invalid or partially corrupted JSON falls back to safe defaults instead of throwing.

## Feature surfaces

The domain owns the settings UI, but app-level features consume the facade for presentation.

- `NotificationsSettingsComponent` renders the dedicated Notifications tab in the settings modal.
- `ServersRailComponent` uses the facade for server unread badges and server-level mute / unmute context-menu actions.
- `RoomsSidePanelComponent` uses the facade for channel unread badges and channel-level mute / unmute context-menu actions.

Those surfaces all depend on the same facade state, so toggles in the settings panel and right-click menus stay in sync.

## Public boundary

`index.ts` exports:

- `NotificationsFacade`
- `NotificationsEffects`
- `NotificationsSettingsComponent`

Outside the domain, import from the domain barrel rather than internal files unless Angular static analysis requires a direct component import in standalone component metadata.

## Extension guidance

When extending this domain:

1. Put pure predicates and formatting rules in `domain/notification.logic.ts`.
2. Keep `NotificationsFacade` as the only stateful application boundary for unread counts and settings mutations.
3. Add new store-driven reactions in `notifications.effects.ts`, not in feature components.
4. Persist new user-controlled notification state in `notification-settings.storage.ts` and normalise it on load.
5. Do not persist unread counters. Persist the markers needed to derive them.
6. Keep Electron-specific APIs behind `DesktopNotificationService` and the existing bridge contract.
