Files
Toju/toju-app/src/app/domains/notifications/README.md
Myx 8b6578da3c
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m55s
Queue Release Build / build-linux (push) Successful in 30m56s
Queue Release Build / build-windows (push) Successful in 27m50s
Queue Release Build / finalize (push) Successful in 2m0s
fix: Notification audio
2026-03-30 21:14:26 +02:00

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:

  • 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

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.