# 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/ │ ├── notifications.facade.ts Stateful domain boundary: settings, unread counts, read markers, delivery decisions │ └── notifications.effects.ts NgRx glue reacting to room, user, and message actions │ ├── domain/ │ ├── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation │ └── notification.models.ts Settings, unread state, delivery context, and payload contracts │ ├── infrastructure/ │ ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention │ └── notification-settings.storage.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] 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 --> Logic Facade --> Storage Facade --> DB Facade --> Desktop Facade --> Audio click Facade "application/notifications.facade.ts" "Stateful domain boundary" _blank click Effects "application/notifications.effects.ts" "NgRx glue" _blank click Logic "domain/notification.logic.ts" "Pure notification rules" _blank click Storage "infrastructure/notification-settings.storage.ts" "localStorage persistence" _blank click Desktop "infrastructure/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. - `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 | | `refreshCurrentRoomUnread$` | `loadMessagesSuccess` and `syncMessages` | Recomputes unread counts for the active room from the latest message snapshot | ## 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.