refactor: stricer domain: notifications
This commit is contained in:
@@ -7,7 +7,8 @@ Handles file sharing between peers over WebRTC data channels. Files are announce
|
|||||||
```
|
```
|
||||||
attachment/
|
attachment/
|
||||||
├── application/
|
├── application/
|
||||||
│ ├── attachment.facade.ts Thin entry point, delegates to manager
|
│ ├── facades/
|
||||||
|
│ │ └── attachment.facade.ts Thin entry point, delegates to manager
|
||||||
│ └── services/
|
│ └── services/
|
||||||
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
|
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
|
||||||
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
|
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
|
||||||
@@ -16,17 +17,20 @@ attachment/
|
|||||||
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
|
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
|
||||||
│
|
│
|
||||||
├── domain/
|
├── domain/
|
||||||
│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
|
│ ├── logic/
|
||||||
|
│ │ └── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state
|
│ │ ├── attachment.model.ts Attachment type extending AttachmentMeta with runtime state
|
||||||
│ │ └── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...)
|
│ │ └── attachment-transfer.model.ts Protocol event types (file-announce, file-chunk, file-request, ...)
|
||||||
│ └── constants/
|
│ └── constants/
|
||||||
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
|
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
|
||||||
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
|
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
|
||||||
│
|
│
|
||||||
├── infrastructure/
|
├── infrastructure/
|
||||||
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
│ ├── services/
|
||||||
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
│ │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||||
|
│ └── util/
|
||||||
|
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||||
│
|
│
|
||||||
└── index.ts Barrel exports
|
└── index.ts Barrel exports
|
||||||
```
|
```
|
||||||
@@ -57,15 +61,15 @@ graph TD
|
|||||||
Persistence --> Store
|
Persistence --> Store
|
||||||
Storage --> Helpers[attachment-storage.util]
|
Storage --> Helpers[attachment-storage.util]
|
||||||
|
|
||||||
click Facade "application/attachment.facade.ts" "Thin entry point" _blank
|
click Facade "application/facades/attachment.facade.ts" "Thin entry point" _blank
|
||||||
click Manager "application/services/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
|
click Manager "application/services/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
|
||||||
click Transfer "application/services/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
|
click Transfer "application/services/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
|
||||||
click Transport "application/services/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
|
click Transport "application/services/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
|
||||||
click Persistence "application/services/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
|
click Persistence "application/services/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
|
||||||
click Store "application/services/attachment-runtime.store.ts" "In-memory signal-based state" _blank
|
click Store "application/services/attachment-runtime.store.ts" "In-memory signal-based state" _blank
|
||||||
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
|
click Storage "infrastructure/services/attachment-storage.service.ts" "Electron filesystem access" _blank
|
||||||
click Helpers "infrastructure/attachment-storage.util.ts" "Path helpers" _blank
|
click Helpers "infrastructure/util/attachment-storage.util.ts" "Path helpers" _blank
|
||||||
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
|
click Logic "domain/logic/attachment.logic.ts" "Pure decision functions" _blank
|
||||||
```
|
```
|
||||||
|
|
||||||
## File transfer protocol
|
## File transfer protocol
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { AttachmentManagerService } from './services/attachment-manager.service';
|
import { AttachmentManagerService } from '../services/attachment-manager.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentFacade {
|
export class AttachmentFacade {
|
||||||
@@ -7,15 +7,15 @@ import { NavigationEnd, Router } from '@angular/router';
|
|||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { ROOM_URL_PATTERN } from '../../../../core/constants';
|
import { ROOM_URL_PATTERN } from '../../../../core/constants';
|
||||||
import { shouldAutoRequestWhenWatched } from '../../domain/attachment.logic';
|
import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.models';
|
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||||
import type {
|
import type {
|
||||||
FileAnnouncePayload,
|
FileAnnouncePayload,
|
||||||
FileCancelPayload,
|
FileCancelPayload,
|
||||||
FileChunkPayload,
|
FileChunkPayload,
|
||||||
FileNotFoundPayload,
|
FileNotFoundPayload,
|
||||||
FileRequestPayload
|
FileRequestPayload
|
||||||
} from '../../domain/models/attachment-transfer.models';
|
} from '../../domain/models/attachment-transfer.model';
|
||||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
import { AttachmentTransferService } from './attachment-transfer.service';
|
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.models';
|
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||||
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
|
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
|
||||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import type { Attachment } from '../../domain/models/attachment.models';
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentRuntimeStore {
|
export class AttachmentRuntimeStore {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
|
||||||
import { FileChunkEvent } from '../../domain/models/attachment-transfer.models';
|
import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentTransferTransportService {
|
export class AttachmentTransferTransportService {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime
|
|||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||||
import { shouldPersistDownloadedAttachment } from '../../domain/attachment.logic';
|
import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
|
||||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.models';
|
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||||
import {
|
import {
|
||||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type FileRequestEvent,
|
type FileRequestEvent,
|
||||||
type FileRequestPayload,
|
type FileRequestPayload,
|
||||||
type LocalFileWithPath
|
type LocalFileWithPath
|
||||||
} from '../../domain/models/attachment-transfer.models';
|
} from '../../domain/models/attachment-transfer.model';
|
||||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from './constants/attachment.constants';
|
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants';
|
||||||
import type { Attachment } from './models/attachment.models';
|
import type { Attachment } from '../models/attachment.model';
|
||||||
|
|
||||||
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||||
return attachment.mime.startsWith('image/') ||
|
return attachment.mime.startsWith('image/') ||
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './application/attachment.facade';
|
export * from './application/facades/attachment.facade';
|
||||||
export * from './domain/constants/attachment.constants';
|
export * from './domain/constants/attachment.constants';
|
||||||
export * from './domain/models/attachment.models';
|
export * from './domain/models/attachment.model';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import type { Attachment } from '../../domain/models/attachment.models';
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
import {
|
import {
|
||||||
resolveAttachmentStorageBucket,
|
resolveAttachmentStorageBucket,
|
||||||
resolveAttachmentStoredFilename,
|
resolveAttachmentStoredFilename,
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ Handles user authentication (login and registration) against the configured serv
|
|||||||
```
|
```
|
||||||
authentication/
|
authentication/
|
||||||
├── application/
|
├── application/
|
||||||
│ └── authentication.service.ts HTTP login/register against the active server endpoint
|
│ └── services/
|
||||||
|
│ └── authentication.service.ts HTTP login/register against the active server endpoint
|
||||||
│
|
│
|
||||||
├── domain/
|
├── domain/
|
||||||
│ └── authentication.model.ts LoginResponse interface
|
│ └── models/
|
||||||
|
│ └── authentication.model.ts LoginResponse interface
|
||||||
│
|
│
|
||||||
├── feature/
|
├── feature/
|
||||||
│ ├── login/ Login form component
|
│ ├── login/ Login form component
|
||||||
@@ -39,7 +41,7 @@ graph TD
|
|||||||
Auth --> SD
|
Auth --> SD
|
||||||
Login --> Store
|
Login --> Store
|
||||||
|
|
||||||
click Auth "application/authentication.service.ts" "HTTP login/register" _blank
|
click Auth "application/services/authentication.service.ts" "HTTP login/register" _blank
|
||||||
click Login "feature/login/" "Login form" _blank
|
click Login "feature/login/" "Login form" _blank
|
||||||
click Register "feature/register/" "Registration form" _blank
|
click Register "feature/register/" "Registration form" _blank
|
||||||
click UserBar "feature/user-bar/" "Current user display" _blank
|
click UserBar "feature/user-bar/" "Current user display" _blank
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory';
|
import { type ServerEndpoint, ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import type { LoginResponse } from '../domain/authentication.model';
|
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles user authentication (login and registration) against a
|
* Handles user authentication (login and registration) against a
|
||||||
@@ -11,7 +11,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideLogIn } from '@ng-icons/lucide';
|
import { lucideLogIn } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { AuthenticationService } from '../../application/authentication.service';
|
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { AuthenticationService } from '../../application/authentication.service';
|
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './application/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
export * from './domain/authentication.model';
|
export * from './domain/models/authentication.model';
|
||||||
|
|||||||
@@ -7,16 +7,23 @@ Owns desktop notification delivery, unread tracking, mute preferences, and the n
|
|||||||
```
|
```
|
||||||
notifications/
|
notifications/
|
||||||
├── application/
|
├── application/
|
||||||
│ ├── notifications.facade.ts Stateful domain boundary: settings, unread counts, read markers, delivery decisions
|
│ ├── facades/
|
||||||
│ └── notifications.effects.ts NgRx glue reacting to room, user, and message actions
|
│ │ └── 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/
|
├── domain/
|
||||||
│ ├── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation
|
│ ├── logic/
|
||||||
│ └── notification.models.ts Settings, unread state, delivery context, and payload contracts
|
│ │ └── 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/
|
├── infrastructure/
|
||||||
│ ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention
|
│ └── services/
|
||||||
│ └── notification-settings.storage.ts localStorage persistence with defensive deserialisation
|
│ ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention
|
||||||
|
│ └── notification-settings-storage.service.ts localStorage persistence with defensive deserialisation
|
||||||
│
|
│
|
||||||
├── feature/
|
├── feature/
|
||||||
│ └── settings/
|
│ └── settings/
|
||||||
@@ -36,6 +43,7 @@ graph TD
|
|||||||
Rail[ServersRailComponent]
|
Rail[ServersRailComponent]
|
||||||
Sidebar[RoomsSidePanelComponent]
|
Sidebar[RoomsSidePanelComponent]
|
||||||
Facade[NotificationsFacade]
|
Facade[NotificationsFacade]
|
||||||
|
Service[NotificationsService]
|
||||||
Logic[notification.logic]
|
Logic[notification.logic]
|
||||||
Storage[NotificationSettingsStorageService]
|
Storage[NotificationSettingsStorageService]
|
||||||
DB[DatabaseService]
|
DB[DatabaseService]
|
||||||
@@ -49,17 +57,19 @@ graph TD
|
|||||||
Settings --> Facade
|
Settings --> Facade
|
||||||
Rail --> Facade
|
Rail --> Facade
|
||||||
Sidebar --> Facade
|
Sidebar --> Facade
|
||||||
Facade --> Logic
|
Facade --> Service
|
||||||
Facade --> Storage
|
Service --> Logic
|
||||||
Facade --> DB
|
Service --> Storage
|
||||||
Facade --> Desktop
|
Service --> DB
|
||||||
Facade --> Audio
|
Service --> Desktop
|
||||||
|
Service --> Audio
|
||||||
|
|
||||||
click Facade "application/notifications.facade.ts" "Stateful domain boundary" _blank
|
click Facade "application/facades/notifications.facade.ts" "Thin domain boundary" _blank
|
||||||
click Effects "application/notifications.effects.ts" "NgRx glue" _blank
|
click Service "application/services/notifications.service.ts" "Stateful orchestrator" _blank
|
||||||
click Logic "domain/notification.logic.ts" "Pure notification rules" _blank
|
click Effects "application/effects/notifications.effects.ts" "NgRx glue" _blank
|
||||||
click Storage "infrastructure/notification-settings.storage.ts" "localStorage persistence" _blank
|
click Logic "domain/logic/notification.logic.ts" "Pure notification rules" _blank
|
||||||
click Desktop "infrastructure/desktop-notification.service.ts" "Desktop notification adapter" _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 Settings "feature/settings/notifications-settings.component.ts" "Notifications settings UI" _blank
|
||||||
click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank
|
click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank
|
||||||
```
|
```
|
||||||
@@ -68,7 +78,7 @@ graph TD
|
|||||||
|
|
||||||
The domain has two runtime entry points:
|
The domain has two runtime entry points:
|
||||||
|
|
||||||
- `NotificationsFacade` is injected directly by app bootstrapping and feature components.
|
- `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.
|
- `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`.
|
All effects in this domain are `dispatch: false`. The effect layer never owns notification business rules; it only connects NgRx actions to `NotificationsFacade`.
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
withLatestFrom
|
withLatestFrom
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom, selectSavedRooms } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { NotificationsFacade } from './notifications.facade';
|
import { NotificationsFacade } from '../facades/notifications.facade';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsEffects {
|
export class NotificationsEffects {
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { NotificationsService } from '../services/notifications.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NotificationsFacade {
|
||||||
|
private readonly service = inject(NotificationsService);
|
||||||
|
|
||||||
|
readonly settings = this.service.settings;
|
||||||
|
readonly unread = this.service.unread;
|
||||||
|
|
||||||
|
initialize(
|
||||||
|
...args: Parameters<NotificationsService['initialize']>
|
||||||
|
): ReturnType<NotificationsService['initialize']> {
|
||||||
|
return this.service.initialize(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRoomCatalog(
|
||||||
|
...args: Parameters<NotificationsService['syncRoomCatalog']>
|
||||||
|
): ReturnType<NotificationsService['syncRoomCatalog']> {
|
||||||
|
return this.service.syncRoomCatalog(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateUnreadCounts(
|
||||||
|
...args: Parameters<NotificationsService['hydrateUnreadCounts']>
|
||||||
|
): ReturnType<NotificationsService['hydrateUnreadCounts']> {
|
||||||
|
return this.service.hydrateUnreadCounts(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshRoomUnreadFromMessages(
|
||||||
|
...args: Parameters<NotificationsService['refreshRoomUnreadFromMessages']>
|
||||||
|
): ReturnType<NotificationsService['refreshRoomUnreadFromMessages']> {
|
||||||
|
return this.service.refreshRoomUnreadFromMessages(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIncomingMessage(
|
||||||
|
...args: Parameters<NotificationsService['handleIncomingMessage']>
|
||||||
|
): ReturnType<NotificationsService['handleIncomingMessage']> {
|
||||||
|
return this.service.handleIncomingMessage(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
markCurrentChannelReadIfActive(
|
||||||
|
...args: Parameters<NotificationsService['markCurrentChannelReadIfActive']>
|
||||||
|
): ReturnType<NotificationsService['markCurrentChannelReadIfActive']> {
|
||||||
|
return this.service.markCurrentChannelReadIfActive(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRoomMuted(
|
||||||
|
...args: Parameters<NotificationsService['isRoomMuted']>
|
||||||
|
): ReturnType<NotificationsService['isRoomMuted']> {
|
||||||
|
return this.service.isRoomMuted(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
isChannelMuted(
|
||||||
|
...args: Parameters<NotificationsService['isChannelMuted']>
|
||||||
|
): ReturnType<NotificationsService['isChannelMuted']> {
|
||||||
|
return this.service.isChannelMuted(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomUnreadCount(
|
||||||
|
...args: Parameters<NotificationsService['roomUnreadCount']>
|
||||||
|
): ReturnType<NotificationsService['roomUnreadCount']> {
|
||||||
|
return this.service.roomUnreadCount(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
channelUnreadCount(
|
||||||
|
...args: Parameters<NotificationsService['channelUnreadCount']>
|
||||||
|
): ReturnType<NotificationsService['channelUnreadCount']> {
|
||||||
|
return this.service.channelUnreadCount(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationsEnabled(
|
||||||
|
...args: Parameters<NotificationsService['setNotificationsEnabled']>
|
||||||
|
): ReturnType<NotificationsService['setNotificationsEnabled']> {
|
||||||
|
return this.service.setNotificationsEnabled(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPreview(
|
||||||
|
...args: Parameters<NotificationsService['setShowPreview']>
|
||||||
|
): ReturnType<NotificationsService['setShowPreview']> {
|
||||||
|
return this.service.setShowPreview(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRespectBusyStatus(
|
||||||
|
...args: Parameters<NotificationsService['setRespectBusyStatus']>
|
||||||
|
): ReturnType<NotificationsService['setRespectBusyStatus']> {
|
||||||
|
return this.service.setRespectBusyStatus(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoomMuted(
|
||||||
|
...args: Parameters<NotificationsService['setRoomMuted']>
|
||||||
|
): ReturnType<NotificationsService['setRoomMuted']> {
|
||||||
|
return this.service.setRoomMuted(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelMuted(
|
||||||
|
...args: Parameters<NotificationsService['setChannelMuted']>
|
||||||
|
): ReturnType<NotificationsService['setChannelMuted']> {
|
||||||
|
return this.service.setChannelMuted(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,9 +34,9 @@ import {
|
|||||||
type NotificationDeliveryContext,
|
type NotificationDeliveryContext,
|
||||||
type NotificationsSettings,
|
type NotificationsSettings,
|
||||||
type NotificationsUnreadState
|
type NotificationsUnreadState
|
||||||
} from '../domain/notification.models';
|
} from '../domain/notification.model';
|
||||||
import { DesktopNotificationService } from '../infrastructure/desktop-notification.service';
|
import { DesktopNotificationService } from '../infrastructure/services/desktop-notification.service';
|
||||||
import { NotificationSettingsStorageService } from '../infrastructure/notification-settings.storage';
|
import { NotificationSettingsStorageService } from '../infrastructure/services/notification-settings-storage.service';
|
||||||
|
|
||||||
type DesktopPlatform = 'linux' | 'mac' | 'unknown' | 'windows';
|
type DesktopPlatform = 'linux' | 'mac' | 'unknown' | 'windows';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import type { Message, Room } from '../../../../shared-kernel';
|
||||||
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||||
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
|
import {
|
||||||
|
selectActiveChannelId,
|
||||||
|
selectCurrentRoom,
|
||||||
|
selectSavedRooms
|
||||||
|
} from '../../../../store/rooms/rooms.selectors';
|
||||||
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
|
import {
|
||||||
|
buildNotificationDisplayPayload,
|
||||||
|
calculateUnreadForRoom,
|
||||||
|
DEFAULT_TEXT_CHANNEL_ID,
|
||||||
|
getRoomById,
|
||||||
|
getRoomTextChannelIds,
|
||||||
|
getRoomTrackingBaseline,
|
||||||
|
isChannelMuted,
|
||||||
|
isRoomMuted,
|
||||||
|
isMessageVisibleInActiveView,
|
||||||
|
resolveMessageChannelId,
|
||||||
|
shouldDeliverNotification
|
||||||
|
} from '../../domain/logic/notification.logic';
|
||||||
|
import {
|
||||||
|
createDefaultNotificationSettings,
|
||||||
|
createEmptyUnreadState,
|
||||||
|
type NotificationDeliveryContext,
|
||||||
|
type NotificationsSettings,
|
||||||
|
type NotificationsUnreadState
|
||||||
|
} from '../../domain/models/notification.model';
|
||||||
|
import { DesktopNotificationService } from '../../infrastructure/services/desktop-notification.service';
|
||||||
|
import { NotificationSettingsStorageService } from '../../infrastructure/services/notification-settings-storage.service';
|
||||||
|
|
||||||
|
type DesktopPlatform = 'linux' | 'mac' | 'unknown' | 'windows';
|
||||||
|
|
||||||
|
const MAX_NOTIFIED_MESSAGE_IDS = 500;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NotificationsService {
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
|
private readonly audio = inject(NotificationAudioService);
|
||||||
|
private readonly desktopNotifications = inject(DesktopNotificationService);
|
||||||
|
private readonly storage = inject(NotificationSettingsStorageService);
|
||||||
|
|
||||||
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
private readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private readonly _settings = signal<NotificationsSettings>(createDefaultNotificationSettings());
|
||||||
|
private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
|
||||||
|
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
|
||||||
|
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
||||||
|
private readonly _windowMinimized = signal<boolean>(false);
|
||||||
|
private readonly platformKind = detectPlatform();
|
||||||
|
private readonly notifiedMessageIds = new Set<string>();
|
||||||
|
private readonly notifiedMessageOrder: string[] = [];
|
||||||
|
private attentionActive = false;
|
||||||
|
private windowStateCleanup: (() => void) | null = null;
|
||||||
|
private initialised = false;
|
||||||
|
|
||||||
|
readonly settings = computed(() => this._settings());
|
||||||
|
readonly unread = computed(() => this._unread());
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._settings.set(this.storage.load());
|
||||||
|
this.initialised = true;
|
||||||
|
this.registerWindowListeners();
|
||||||
|
this.registerWindowStateListener();
|
||||||
|
this.syncRoomCatalog(this.savedRooms());
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRoomCatalog(rooms: Room[]): void {
|
||||||
|
if (!this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const currentSettings = this._settings();
|
||||||
|
const nextSettings: NotificationsSettings = {
|
||||||
|
...currentSettings,
|
||||||
|
mutedRooms: {},
|
||||||
|
mutedChannels: {},
|
||||||
|
roomBaselines: {},
|
||||||
|
lastReadByChannel: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
nextSettings.mutedRooms[room.id] = currentSettings.mutedRooms[room.id] === true;
|
||||||
|
nextSettings.roomBaselines[room.id] = currentSettings.roomBaselines[room.id] ?? now;
|
||||||
|
|
||||||
|
const textChannelIds = new Set(getRoomTextChannelIds(room));
|
||||||
|
const mutedChannels = currentSettings.mutedChannels[room.id] ?? {};
|
||||||
|
const lastReadByChannel = currentSettings.lastReadByChannel[room.id] ?? {};
|
||||||
|
|
||||||
|
nextSettings.mutedChannels[room.id] = Object.fromEntries(
|
||||||
|
Object.entries(mutedChannels)
|
||||||
|
.filter(([channelId, muted]) => textChannelIds.has(channelId) && muted === true)
|
||||||
|
);
|
||||||
|
|
||||||
|
nextSettings.lastReadByChannel[room.id] = Object.fromEntries(
|
||||||
|
Object.entries(lastReadByChannel)
|
||||||
|
.filter((entry): entry is [string, number] => textChannelIds.has(entry[0]) && typeof entry[1] === 'number')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSettings(nextSettings);
|
||||||
|
this.pruneUnreadState(rooms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hydrateUnreadCounts(rooms: Room[]): Promise<void> {
|
||||||
|
if (!this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserIds = this.getCurrentUserIds();
|
||||||
|
const roomCounts: Record<string, number> = {};
|
||||||
|
const channelCounts: Record<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
const trackedSince = getRoomTrackingBaseline(this._settings(), room);
|
||||||
|
const messages = await this.db.getMessagesSince(room.id, trackedSince);
|
||||||
|
const counts = calculateUnreadForRoom(room, messages, this._settings(), currentUserIds);
|
||||||
|
|
||||||
|
roomCounts[room.id] = counts.roomCount;
|
||||||
|
channelCounts[room.id] = counts.channelCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._unread.set({ roomCounts, channelCounts });
|
||||||
|
this.pruneUnreadState(rooms);
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshRoomUnreadFromMessages(roomId: string, messages: Message[]): void {
|
||||||
|
const room = getRoomById(this.savedRooms(), roomId);
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = calculateUnreadForRoom(room, messages, this._settings(), this.getCurrentUserIds());
|
||||||
|
|
||||||
|
this._unread.update((state) => ({
|
||||||
|
roomCounts: {
|
||||||
|
...state.roomCounts,
|
||||||
|
[roomId]: counts.roomCount
|
||||||
|
},
|
||||||
|
channelCounts: {
|
||||||
|
...state.channelCounts,
|
||||||
|
[roomId]: counts.channelCounts
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleIncomingMessage(message: Message): Promise<void> {
|
||||||
|
if (!this.initialised || this.isDuplicateMessage(message.id) || this.isOwnMessage(message.senderId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rememberMessageId(message.id);
|
||||||
|
|
||||||
|
const channelId = resolveMessageChannelId(message);
|
||||||
|
const baselineTimestamp = Math.max(message.timestamp - 1, 0);
|
||||||
|
|
||||||
|
this.ensureRoomTracking(message.roomId, channelId, baselineTimestamp);
|
||||||
|
|
||||||
|
if (message.isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMessageVisibleInActiveView(message, this.buildContext())) {
|
||||||
|
this.markChannelRead(message.roomId, channelId, message.timestamp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.timestamp > this.getChannelLastReadAt(message.roomId, channelId)) {
|
||||||
|
this.incrementUnread(message.roomId, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = this.buildContext();
|
||||||
|
|
||||||
|
if (!shouldDeliverNotification(this._settings(), message, context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = getRoomById(context.rooms, message.roomId);
|
||||||
|
const payload = buildNotificationDisplayPayload(
|
||||||
|
message,
|
||||||
|
room,
|
||||||
|
this._settings(),
|
||||||
|
!context.isWindowFocused || !context.isDocumentVisible
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.shouldPlayNotificationSound()) {
|
||||||
|
this.audio.play(AppSound.Notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.desktopNotifications.showNotification(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
markCurrentChannelReadIfActive(): void {
|
||||||
|
if (!this.initialised || !this._windowFocused() || !this._documentVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.currentRoom();
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
this.syncWindowAttention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = this.activeChannelId() || DEFAULT_TEXT_CHANNEL_ID;
|
||||||
|
|
||||||
|
this.markChannelRead(room.id, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRoomMuted(roomId: string): boolean {
|
||||||
|
return isRoomMuted(this._settings(), roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isChannelMuted(roomId: string, channelId: string): boolean {
|
||||||
|
return isChannelMuted(this._settings(), roomId, channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
roomUnreadCount(roomId: string): number {
|
||||||
|
return this._unread().roomCounts[roomId] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelUnreadCount(roomId: string, channelId: string): number {
|
||||||
|
return this._unread().channelCounts[roomId]?.[channelId] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationsEnabled(enabled: boolean): void {
|
||||||
|
this.setSettings({
|
||||||
|
...this._settings(),
|
||||||
|
enabled
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPreview(showPreview: boolean): void {
|
||||||
|
this.setSettings({
|
||||||
|
...this._settings(),
|
||||||
|
showPreview
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRespectBusyStatus(respectBusyStatus: boolean): void {
|
||||||
|
this.setSettings({
|
||||||
|
...this._settings(),
|
||||||
|
respectBusyStatus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoomMuted(roomId: string, muted: boolean): void {
|
||||||
|
this.setSettings({
|
||||||
|
...this._settings(),
|
||||||
|
mutedRooms: {
|
||||||
|
...this._settings().mutedRooms,
|
||||||
|
[roomId]: muted
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannelMuted(roomId: string, channelId: string, muted: boolean): void {
|
||||||
|
this.setSettings({
|
||||||
|
...this._settings(),
|
||||||
|
mutedChannels: {
|
||||||
|
...this._settings().mutedChannels,
|
||||||
|
[roomId]: {
|
||||||
|
...(this._settings().mutedChannels[roomId] ?? {}),
|
||||||
|
[channelId]: muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWindowListeners(): void {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('focus', this.handleWindowFocus);
|
||||||
|
window.addEventListener('blur', this.handleWindowBlur);
|
||||||
|
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWindowStateListener(): void {
|
||||||
|
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
||||||
|
this._windowFocused.set(state.isFocused);
|
||||||
|
this._windowMinimized.set(state.isMinimized);
|
||||||
|
|
||||||
|
if (state.isFocused && !state.isMinimized && this._documentVisible()) {
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleWindowFocus = (): void => {
|
||||||
|
this._windowFocused.set(true);
|
||||||
|
this._windowMinimized.set(false);
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleWindowBlur = (): void => {
|
||||||
|
this._windowFocused.set(false);
|
||||||
|
this.syncWindowAttention();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleVisibilityChange = (): void => {
|
||||||
|
const isVisible = document.visibilityState === 'visible';
|
||||||
|
|
||||||
|
this._documentVisible.set(isVisible);
|
||||||
|
|
||||||
|
if (isVisible && this._windowFocused()) {
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
};
|
||||||
|
|
||||||
|
private buildContext(): NotificationDeliveryContext {
|
||||||
|
return {
|
||||||
|
activeChannelId: this.activeChannelId(),
|
||||||
|
currentRoomId: this.currentRoom()?.id ?? null,
|
||||||
|
currentUser: this.currentUser() ?? null,
|
||||||
|
isDocumentVisible: this._documentVisible(),
|
||||||
|
isWindowFocused: this._windowFocused(),
|
||||||
|
rooms: this.savedRooms()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPlayNotificationSound(): boolean {
|
||||||
|
return this.platformKind === 'windows' && !this.isWindowActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isWindowActive(): boolean {
|
||||||
|
return this._windowFocused() && this._documentVisible() && !this._windowMinimized();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRoomTracking(roomId: string, channelId: string, baselineTimestamp: number): void {
|
||||||
|
const settings = this._settings();
|
||||||
|
|
||||||
|
if (settings.roomBaselines[roomId] !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setSettings({
|
||||||
|
...settings,
|
||||||
|
roomBaselines: {
|
||||||
|
...settings.roomBaselines,
|
||||||
|
[roomId]: baselineTimestamp
|
||||||
|
},
|
||||||
|
mutedChannels: {
|
||||||
|
...settings.mutedChannels,
|
||||||
|
[roomId]: {
|
||||||
|
...(settings.mutedChannels[roomId] ?? {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lastReadByChannel: {
|
||||||
|
...settings.lastReadByChannel,
|
||||||
|
[roomId]: {
|
||||||
|
...(settings.lastReadByChannel[roomId] ?? {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._unread.update((state) => ({
|
||||||
|
roomCounts: {
|
||||||
|
...state.roomCounts,
|
||||||
|
[roomId]: state.roomCounts[roomId] ?? 0
|
||||||
|
},
|
||||||
|
channelCounts: {
|
||||||
|
...state.channelCounts,
|
||||||
|
[roomId]: {
|
||||||
|
...(state.channelCounts[roomId] ?? {}),
|
||||||
|
[channelId]: state.channelCounts[roomId]?.[channelId] ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneUnreadState(rooms: Room[]): void {
|
||||||
|
const nextRoomCounts: Record<string, number> = {};
|
||||||
|
const nextChannelCounts: Record<string, Record<string, number>> = {};
|
||||||
|
const currentUnread = this._unread();
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
const validChannelIds = new Set(getRoomTextChannelIds(room));
|
||||||
|
const currentChannels = currentUnread.channelCounts[room.id] ?? {};
|
||||||
|
const roomChannelCounts = Object.fromEntries(
|
||||||
|
Object.entries(currentChannels)
|
||||||
|
.filter(([channelId]) => validChannelIds.has(channelId))
|
||||||
|
) as Record<string, number>;
|
||||||
|
|
||||||
|
for (const channelId of validChannelIds) {
|
||||||
|
roomChannelCounts[channelId] = roomChannelCounts[channelId] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextChannelCounts[room.id] = roomChannelCounts;
|
||||||
|
nextRoomCounts[room.id] = Object.values(roomChannelCounts).reduce((total, count) => total + count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._unread.set({
|
||||||
|
roomCounts: nextRoomCounts,
|
||||||
|
channelCounts: nextChannelCounts
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementUnread(roomId: string, channelId: string): void {
|
||||||
|
this._unread.update((state) => {
|
||||||
|
const roomChannelCounts = {
|
||||||
|
...(state.channelCounts[roomId] ?? {}),
|
||||||
|
[channelId]: (state.channelCounts[roomId]?.[channelId] ?? 0) + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomCounts: {
|
||||||
|
...state.roomCounts,
|
||||||
|
[roomId]: Object.values(roomChannelCounts).reduce((total, count) => total + count, 0)
|
||||||
|
},
|
||||||
|
channelCounts: {
|
||||||
|
...state.channelCounts,
|
||||||
|
[roomId]: roomChannelCounts
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
private markChannelRead(roomId: string, channelId: string, timestamp = Date.now()): void {
|
||||||
|
const nextReadAt = Math.max(timestamp, Date.now(), this.getChannelLastReadAt(roomId, channelId));
|
||||||
|
|
||||||
|
this.setSettings({
|
||||||
|
...this._settings(),
|
||||||
|
lastReadByChannel: {
|
||||||
|
...this._settings().lastReadByChannel,
|
||||||
|
[roomId]: {
|
||||||
|
...(this._settings().lastReadByChannel[roomId] ?? {}),
|
||||||
|
[channelId]: nextReadAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._unread.update((state) => {
|
||||||
|
const roomChannelCounts = {
|
||||||
|
...(state.channelCounts[roomId] ?? {}),
|
||||||
|
[channelId]: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomCounts: {
|
||||||
|
...state.roomCounts,
|
||||||
|
[roomId]: Object.values(roomChannelCounts).reduce((total, count) => total + count, 0)
|
||||||
|
},
|
||||||
|
channelCounts: {
|
||||||
|
...state.channelCounts,
|
||||||
|
[roomId]: roomChannelCounts
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChannelLastReadAt(roomId: string, channelId: string): number {
|
||||||
|
return this._settings().lastReadByChannel[roomId]?.[channelId]
|
||||||
|
?? this._settings().roomBaselines[roomId]
|
||||||
|
?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentUserIds(): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (user?.id) {
|
||||||
|
ids.add(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.oderId) {
|
||||||
|
ids.add(user.oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOwnMessage(senderId: string): boolean {
|
||||||
|
return this.getCurrentUserIds().has(senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSettings(settings: NotificationsSettings): void {
|
||||||
|
this._settings.set(settings);
|
||||||
|
this.storage.save(settings);
|
||||||
|
this.syncWindowAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasActionableUnread(): boolean {
|
||||||
|
const unread = this._unread();
|
||||||
|
const settings = this._settings();
|
||||||
|
|
||||||
|
for (const [roomId, roomCount] of Object.entries(unread.roomCounts)) {
|
||||||
|
if (roomCount <= 0 || isRoomMuted(settings, roomId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelCounts = unread.channelCounts[roomId] ?? {};
|
||||||
|
|
||||||
|
for (const [channelId, count] of Object.entries(channelCounts)) {
|
||||||
|
if (count > 0 && !isChannelMuted(settings, roomId, channelId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncWindowAttention(): void {
|
||||||
|
if (!this.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldAttention = this._settings().enabled
|
||||||
|
&& this.hasActionableUnread()
|
||||||
|
&& (this._windowMinimized() || !this._windowFocused() || !this._documentVisible());
|
||||||
|
|
||||||
|
if (shouldAttention === this.attentionActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.attentionActive = shouldAttention;
|
||||||
|
|
||||||
|
if (shouldAttention) {
|
||||||
|
void this.desktopNotifications.requestAttention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.desktopNotifications.clearAttention();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDuplicateMessage(messageId: string): boolean {
|
||||||
|
return this.notifiedMessageIds.has(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberMessageId(messageId: string): void {
|
||||||
|
if (this.notifiedMessageIds.has(messageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifiedMessageIds.add(messageId);
|
||||||
|
this.notifiedMessageOrder.push(messageId);
|
||||||
|
|
||||||
|
if (this.notifiedMessageOrder.length > MAX_NOTIFIED_MESSAGE_IDS) {
|
||||||
|
const removedId = this.notifiedMessageOrder.shift();
|
||||||
|
|
||||||
|
if (removedId) {
|
||||||
|
this.notifiedMessageIds.delete(removedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(): DesktopPlatform {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformInfo = `${navigator.userAgent} ${navigator.platform}`.toLowerCase();
|
||||||
|
|
||||||
|
if (platformInfo.includes('win')) {
|
||||||
|
return 'windows';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformInfo.includes('linux')) {
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformInfo.includes('mac')) {
|
||||||
|
return 'mac';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { Message, Room } from '../../../../shared-kernel';
|
||||||
|
import type {
|
||||||
|
NotificationDeliveryContext,
|
||||||
|
NotificationDisplayPayload,
|
||||||
|
NotificationsSettings,
|
||||||
|
RoomUnreadCounts
|
||||||
|
} from '../models/notification.model';
|
||||||
|
|
||||||
|
export const DEFAULT_TEXT_CHANNEL_ID = 'general';
|
||||||
|
|
||||||
|
const MESSAGE_PREVIEW_LIMIT = 140;
|
||||||
|
|
||||||
|
export function resolveMessageChannelId(message: Pick<Message, 'channelId'>): string {
|
||||||
|
return message.channelId || DEFAULT_TEXT_CHANNEL_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoomTextChannelIds(room: Room): string[] {
|
||||||
|
const textChannelIds = (room.channels ?? [])
|
||||||
|
.filter((channel) => channel.type === 'text')
|
||||||
|
.map((channel) => channel.id);
|
||||||
|
|
||||||
|
return textChannelIds.length > 0 ? textChannelIds : [DEFAULT_TEXT_CHANNEL_ID];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoomById(rooms: Room[], roomId: string): Room | null {
|
||||||
|
return rooms.find((room) => room.id === roomId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannelLabel(room: Room | null, channelId: string): string {
|
||||||
|
const channelName = room?.channels?.find((channel) => channel.id === channelId)?.name;
|
||||||
|
|
||||||
|
return channelName || DEFAULT_TEXT_CHANNEL_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannelLastReadAt(
|
||||||
|
settings: NotificationsSettings,
|
||||||
|
roomId: string,
|
||||||
|
channelId: string
|
||||||
|
): number {
|
||||||
|
return settings.lastReadByChannel[roomId]?.[channelId]
|
||||||
|
?? settings.roomBaselines[roomId]
|
||||||
|
?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoomTrackingBaseline(settings: NotificationsSettings, room: Room): number {
|
||||||
|
const trackedChannels = getRoomTextChannelIds(room).map((channelId) =>
|
||||||
|
getChannelLastReadAt(settings, room.id, channelId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.min(...trackedChannels, settings.roomBaselines[room.id] ?? Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRoomMuted(settings: NotificationsSettings, roomId: string): boolean {
|
||||||
|
return settings.mutedRooms[roomId] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChannelMuted(
|
||||||
|
settings: NotificationsSettings,
|
||||||
|
roomId: string,
|
||||||
|
channelId: string
|
||||||
|
): boolean {
|
||||||
|
return settings.mutedChannels[roomId]?.[channelId] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMessageVisibleInActiveView(
|
||||||
|
message: Pick<Message, 'channelId' | 'roomId'>,
|
||||||
|
context: NotificationDeliveryContext
|
||||||
|
): boolean {
|
||||||
|
return context.currentRoomId === message.roomId
|
||||||
|
&& context.activeChannelId === resolveMessageChannelId(message)
|
||||||
|
&& context.isWindowFocused
|
||||||
|
&& context.isDocumentVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDeliverNotification(
|
||||||
|
settings: NotificationsSettings,
|
||||||
|
message: Pick<Message, 'channelId' | 'roomId'>,
|
||||||
|
context: NotificationDeliveryContext
|
||||||
|
): boolean {
|
||||||
|
const channelId = resolveMessageChannelId(message);
|
||||||
|
|
||||||
|
if (!settings.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoomMuted(settings, message.roomId) || isChannelMuted(settings, message.roomId, channelId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isMessageVisibleInActiveView(message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNotificationDisplayPayload(
|
||||||
|
message: Pick<Message, 'channelId' | 'content' | 'senderName'>,
|
||||||
|
room: Room | null,
|
||||||
|
settings: NotificationsSettings,
|
||||||
|
requestAttention: boolean
|
||||||
|
): NotificationDisplayPayload {
|
||||||
|
const channelId = resolveMessageChannelId(message);
|
||||||
|
const roomName = room?.name || 'Server';
|
||||||
|
const channelLabel = getChannelLabel(room, channelId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${roomName} · #${channelLabel}`,
|
||||||
|
body: settings.showPreview
|
||||||
|
? formatMessagePreview(message.senderName, message.content)
|
||||||
|
: `${message.senderName} sent a new message`,
|
||||||
|
requestAttention
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateUnreadForRoom(
|
||||||
|
room: Room,
|
||||||
|
messages: Message[],
|
||||||
|
settings: NotificationsSettings,
|
||||||
|
currentUserIds: Set<string>
|
||||||
|
): RoomUnreadCounts {
|
||||||
|
const channelCounts = Object.fromEntries(
|
||||||
|
getRoomTextChannelIds(room).map((channelId) => [channelId, 0])
|
||||||
|
) as Record<string, number>;
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.isDeleted || currentUserIds.has(message.senderId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = resolveMessageChannelId(message);
|
||||||
|
|
||||||
|
if (message.timestamp <= getChannelLastReadAt(settings, room.id, channelId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelCounts[channelId] = (channelCounts[channelId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
channelCounts,
|
||||||
|
roomCount: Object.values(channelCounts).reduce((total, count) => total + count, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessagePreview(senderName: string, content: string): string {
|
||||||
|
const normalisedContent = content.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (!normalisedContent) {
|
||||||
|
return `${senderName} sent a new message`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT
|
||||||
|
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}…`
|
||||||
|
: normalisedContent;
|
||||||
|
|
||||||
|
return `${senderName}: ${preview}`;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
Message,
|
Message,
|
||||||
Room,
|
Room,
|
||||||
User
|
User
|
||||||
} from '../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
|
|
||||||
export interface NotificationsSettings {
|
export interface NotificationsSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
NotificationDisplayPayload,
|
NotificationDisplayPayload,
|
||||||
NotificationsSettings,
|
NotificationsSettings,
|
||||||
RoomUnreadCounts
|
RoomUnreadCounts
|
||||||
} from './notification.models';
|
} from './notification.model';
|
||||||
|
|
||||||
export const DEFAULT_TEXT_CHANNEL_ID = 'general';
|
export const DEFAULT_TEXT_CHANNEL_ID = 'general';
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||||
import type { Room } from '../../../../shared-kernel';
|
import type { Room } from '../../../../shared-kernel';
|
||||||
import { NotificationsFacade } from '../../application/notifications.facade';
|
import { NotificationsFacade } from '../../application/facades/notifications.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notifications-settings',
|
selector: 'app-notifications-settings',
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from './application/notifications.facade';
|
export * from './application/facades/notifications.facade';
|
||||||
export * from './application/notifications.effects';
|
export * from './application/effects/notifications.effects';
|
||||||
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
|
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import type { WindowStateSnapshot } from '../../../core/platform/electron/electron-api.models';
|
import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
||||||
import { PlatformService } from '../../../core/platform';
|
import { PlatformService } from '../../../../core/platform';
|
||||||
import type { NotificationDisplayPayload } from '../domain/notification.models';
|
import type { NotificationDisplayPayload } from '../../domain/models/notification.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DesktopNotificationService {
|
export class DesktopNotificationService {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../core/constants';
|
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants';
|
||||||
import { createDefaultNotificationSettings, type NotificationsSettings } from '../domain/notification.models';
|
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class NotificationSettingsStorageService {
|
export class NotificationSettingsStorageService {
|
||||||
Reference in New Issue
Block a user