13 KiB
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
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:
NotificationsFacadeis injected directly by app bootstrapping and feature components.NotificationsEffectsis registered globally inprovideEffects(...)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
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 toDate.now(). Old stored messages stay treated as historical backlog. - When a live message arrives before the room has been catalogued,
ensureRoomTracking()usesmessage.timestamp - 1so 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.
- Determine the room baseline with
getRoomTrackingBaseline(...). - Load messages newer than that point through
DatabaseService.getMessagesSince(roomId, sinceTimestamp). - Run
calculateUnreadForRoom(...). - Ignore deleted messages and messages sent by the current user.
- 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 aget-messages-sinceCQRS 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
showDesktopNotificationis available. - Fall back to the browser
NotificationAPI 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-notificationcreates a systemNotificationwith the window icon when supported.show-desktop-notificationskips the OS toast while the main window is visible and maximized.- Notification clicks restore, show, and focus the main window.
request-window-attentionflashes the taskbar entry directly when Electron is minimized or otherwise backgrounded and actionable unread exists.show-desktop-notificationcan still request attention for live toast delivery.clear-window-attentionand the windowfocusevent both callflashFrame(false).
Platform-specific policy
- Windows: the facade only plays
AppSound.Notificationwhen 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.
NotificationsSettingsComponentrenders the dedicated Notifications tab in the settings modal.ServersRailComponentuses the facade for server unread badges and server-level mute / unmute context-menu actions.RoomsSidePanelComponentuses 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:
NotificationsFacadeNotificationsEffectsNotificationsSettingsComponent
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:
- Put pure predicates and formatting rules in
domain/notification.logic.ts. - Keep
NotificationsFacadeas the only stateful application boundary for unread counts and settings mutations. - Add new store-driven reactions in
notifications.effects.ts, not in feature components. - Persist new user-controlled notification state in
notification-settings.storage.tsand normalise it on load. - Do not persist unread counters. Persist the markers needed to derive them.
- Keep Electron-specific APIs behind
DesktopNotificationServiceand the existing bridge contract.