diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index a39811d..a7d7c87 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -7,7 +7,8 @@ Handles file sharing between peers over WebRTC data channels. Files are announce ``` attachment/ ├── application/ -│ ├── attachment.facade.ts Thin entry point, delegates to manager +│ ├── facades/ +│ │ └── attachment.facade.ts Thin entry point, delegates to manager │ └── services/ │ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners │ ├── 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) │ ├── domain/ -│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment +│ ├── logic/ +│ │ └── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment │ ├── models/ -│ │ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state -│ │ └── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...) +│ │ ├── attachment.model.ts Attachment type extending AttachmentMeta with runtime state +│ │ └── attachment-transfer.model.ts Protocol event types (file-announce, file-chunk, file-request, ...) │ └── constants/ │ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB │ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages │ ├── infrastructure/ -│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete) -│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket +│ ├── services/ +│ │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete) +│ └── util/ +│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket │ └── index.ts Barrel exports ``` @@ -57,15 +61,15 @@ graph TD Persistence --> Store 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 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 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 Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank - click Helpers "infrastructure/attachment-storage.util.ts" "Path helpers" _blank - click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank + click Storage "infrastructure/services/attachment-storage.service.ts" "Electron filesystem access" _blank + click Helpers "infrastructure/util/attachment-storage.util.ts" "Path helpers" _blank + click Logic "domain/logic/attachment.logic.ts" "Pure decision functions" _blank ``` ## File transfer protocol diff --git a/toju-app/src/app/domains/attachment/application/attachment.facade.ts b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts similarity index 98% rename from toju-app/src/app/domains/attachment/application/attachment.facade.ts rename to toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts index 19fd43b..f06afa2 100644 --- a/toju-app/src/app/domains/attachment/application/attachment.facade.ts +++ b/toju-app/src/app/domains/attachment/application/facades/attachment.facade.ts @@ -1,5 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { AttachmentManagerService } from './services/attachment-manager.service'; +import { AttachmentManagerService } from '../services/attachment-manager.service'; @Injectable({ providedIn: 'root' }) export class AttachmentFacade { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index 136e145..e3534f3 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -7,15 +7,15 @@ import { NavigationEnd, Router } from '@angular/router'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { ROOM_URL_PATTERN } from '../../../../core/constants'; -import { shouldAutoRequestWhenWatched } from '../../domain/attachment.logic'; -import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.models'; +import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic'; +import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import type { FileAnnouncePayload, FileCancelPayload, FileChunkPayload, FileNotFoundPayload, FileRequestPayload -} from '../../domain/models/attachment-transfer.models'; +} from '../../domain/models/attachment-transfer.model'; import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentTransferService } from './attachment-transfer.service'; diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts index 315f62b..ba81074 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts @@ -4,7 +4,7 @@ import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors'; import { DatabaseService } from '../../../../infrastructure/persistence'; 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 { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants'; import { AttachmentRuntimeStore } from './attachment-runtime.store'; diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts b/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts index 3c717b4..05bd041 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-runtime.store.ts @@ -1,5 +1,5 @@ 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' }) export class AttachmentRuntimeStore { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts index 33ec81b..dc5174f 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer-transport.service.ts @@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; 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' }) export class AttachmentTransferTransportService { diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index 435f0cb..6c7fe9f 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -3,8 +3,8 @@ import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime import { RealtimeSessionFacade } from '../../../../core/realtime'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants'; -import { shouldPersistDownloadedAttachment } from '../../domain/attachment.logic'; -import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.models'; +import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic'; +import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import { ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, @@ -23,7 +23,7 @@ import { type FileRequestEvent, type FileRequestPayload, type LocalFileWithPath -} from '../../domain/models/attachment-transfer.models'; +} from '../../domain/models/attachment-transfer.model'; import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; diff --git a/toju-app/src/app/domains/attachment/domain/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts similarity index 82% rename from toju-app/src/app/domains/attachment/domain/attachment.logic.ts rename to toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts index f29eec5..5ead998 100644 --- a/toju-app/src/app/domains/attachment/domain/attachment.logic.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts @@ -1,5 +1,5 @@ -import { MAX_AUTO_SAVE_SIZE_BYTES } from './constants/attachment.constants'; -import type { Attachment } from './models/attachment.models'; +import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants'; +import type { Attachment } from '../models/attachment.model'; export function isAttachmentMedia(attachment: Pick): boolean { return attachment.mime.startsWith('image/') || diff --git a/toju-app/src/app/domains/attachment/domain/models/attachment-transfer.models.ts b/toju-app/src/app/domains/attachment/domain/models/attachment-transfer.model.ts similarity index 100% rename from toju-app/src/app/domains/attachment/domain/models/attachment-transfer.models.ts rename to toju-app/src/app/domains/attachment/domain/models/attachment-transfer.model.ts diff --git a/toju-app/src/app/domains/attachment/domain/models/attachment.models.ts b/toju-app/src/app/domains/attachment/domain/models/attachment.model.ts similarity index 100% rename from toju-app/src/app/domains/attachment/domain/models/attachment.models.ts rename to toju-app/src/app/domains/attachment/domain/models/attachment.model.ts diff --git a/toju-app/src/app/domains/attachment/index.ts b/toju-app/src/app/domains/attachment/index.ts index 5d44444..432a4bb 100644 --- a/toju-app/src/app/domains/attachment/index.ts +++ b/toju-app/src/app/domains/attachment/index.ts @@ -1,3 +1,3 @@ -export * from './application/attachment.facade'; +export * from './application/facades/attachment.facade'; export * from './domain/constants/attachment.constants'; -export * from './domain/models/attachment.models'; +export * from './domain/models/attachment.model'; diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts b/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts index 26cd69a..a688cab 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/attachment-storage.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; 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 { resolveAttachmentStorageBucket, resolveAttachmentStoredFilename, diff --git a/toju-app/src/app/domains/authentication/README.md b/toju-app/src/app/domains/authentication/README.md index d65c5f5..be1ad1e 100644 --- a/toju-app/src/app/domains/authentication/README.md +++ b/toju-app/src/app/domains/authentication/README.md @@ -7,10 +7,12 @@ Handles user authentication (login and registration) against the configured serv ``` authentication/ ├── 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/ -│ └── authentication.model.ts LoginResponse interface +│ └── models/ +│ └── authentication.model.ts LoginResponse interface │ ├── feature/ │ ├── login/ Login form component @@ -39,7 +41,7 @@ graph TD Auth --> SD 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 Register "feature/register/" "Registration form" _blank click UserBar "feature/user-bar/" "Current user display" _blank diff --git a/toju-app/src/app/domains/authentication/application/authentication.service.ts b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts similarity index 96% rename from toju-app/src/app/domains/authentication/application/authentication.service.ts rename to toju-app/src/app/domains/authentication/application/services/authentication.service.ts index e930632..bd86151 100644 --- a/toju-app/src/app/domains/authentication/application/authentication.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts @@ -2,8 +2,8 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory'; -import type { LoginResponse } from '../domain/authentication.model'; +import { type ServerEndpoint, ServerDirectoryFacade } from '../../../server-directory'; +import type { LoginResponse } from '../../domain/models/authentication.model'; /** * Handles user authentication (login and registration) against a diff --git a/toju-app/src/app/domains/authentication/domain/authentication.model.ts b/toju-app/src/app/domains/authentication/domain/models/authentication.model.ts similarity index 100% rename from toju-app/src/app/domains/authentication/domain/authentication.model.ts rename to toju-app/src/app/domains/authentication/domain/models/authentication.model.ts diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.ts b/toju-app/src/app/domains/authentication/feature/login/login.component.ts index d99fa8f..e518830 100644 --- a/toju-app/src/app/domains/authentication/feature/login/login.component.ts +++ b/toju-app/src/app/domains/authentication/feature/login/login.component.ts @@ -11,7 +11,7 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; 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 { UsersActions } from '../../../../store/users/users.actions'; import { User } from '../../../../shared-kernel'; diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.ts b/toju-app/src/app/domains/authentication/feature/register/register.component.ts index 0514541..9239f25 100644 --- a/toju-app/src/app/domains/authentication/feature/register/register.component.ts +++ b/toju-app/src/app/domains/authentication/feature/register/register.component.ts @@ -11,7 +11,7 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; 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 { UsersActions } from '../../../../store/users/users.actions'; import { User } from '../../../../shared-kernel'; diff --git a/toju-app/src/app/domains/authentication/index.ts b/toju-app/src/app/domains/authentication/index.ts index 26a4a8a..2e0e636 100644 --- a/toju-app/src/app/domains/authentication/index.ts +++ b/toju-app/src/app/domains/authentication/index.ts @@ -1,2 +1,2 @@ -export * from './application/authentication.service'; -export * from './domain/authentication.model'; +export * from './application/services/authentication.service'; +export * from './domain/models/authentication.model'; diff --git a/toju-app/src/app/domains/notifications/README.md b/toju-app/src/app/domains/notifications/README.md index 0a7b6d1..d3775af 100644 --- a/toju-app/src/app/domains/notifications/README.md +++ b/toju-app/src/app/domains/notifications/README.md @@ -7,16 +7,23 @@ Owns desktop notification delivery, unread tracking, mute preferences, and the n ``` 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 +│ ├── facades/ +│ │ └── notifications.facade.ts Thin domain boundary, delegates to NotificationsService +│ ├── services/ +│ │ └── notifications.service.ts Stateful orchestrator: settings, unread counts, read markers, delivery decisions +│ └── effects/ +│ └── notifications.effects.ts NgRx glue reacting to room, user, and message actions │ ├── domain/ -│ ├── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation -│ └── notification.models.ts Settings, unread state, delivery context, and payload contracts +│ ├── logic/ +│ │ └── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation +│ └── models/ +│ └── notification.model.ts Settings, unread state, delivery context, and payload contracts │ ├── infrastructure/ -│ ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention -│ └── notification-settings.storage.ts localStorage persistence with defensive deserialisation +│ └── services/ +│ ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention +│ └── notification-settings-storage.service.ts localStorage persistence with defensive deserialisation │ ├── feature/ │ └── settings/ @@ -36,6 +43,7 @@ graph TD Rail[ServersRailComponent] Sidebar[RoomsSidePanelComponent] Facade[NotificationsFacade] + Service[NotificationsService] Logic[notification.logic] Storage[NotificationSettingsStorageService] DB[DatabaseService] @@ -49,17 +57,19 @@ graph TD Settings --> Facade Rail --> Facade Sidebar --> Facade - Facade --> Logic - Facade --> Storage - Facade --> DB - Facade --> Desktop - Facade --> Audio + Facade --> Service + Service --> Logic + Service --> Storage + Service --> DB + Service --> Desktop + Service --> 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 Facade "application/facades/notifications.facade.ts" "Thin domain boundary" _blank + click Service "application/services/notifications.service.ts" "Stateful orchestrator" _blank + click Effects "application/effects/notifications.effects.ts" "NgRx glue" _blank + click Logic "domain/logic/notification.logic.ts" "Pure notification rules" _blank + click Storage "infrastructure/services/notification-settings-storage.service.ts" "localStorage persistence" _blank + click Desktop "infrastructure/services/desktop-notification.service.ts" "Desktop notification adapter" _blank click Settings "feature/settings/notifications-settings.component.ts" "Notifications settings UI" _blank click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank ``` @@ -68,7 +78,7 @@ graph TD 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. All effects in this domain are `dispatch: false`. The effect layer never owns notification business rules; it only connects NgRx actions to `NotificationsFacade`. diff --git a/toju-app/src/app/domains/notifications/application/notifications.effects.ts b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts similarity index 85% rename from toju-app/src/app/domains/notifications/application/notifications.effects.ts rename to toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts index ff6a0d1..f259110 100644 --- a/toju-app/src/app/domains/notifications/application/notifications.effects.ts +++ b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts @@ -11,12 +11,12 @@ import { tap, withLatestFrom } from 'rxjs/operators'; -import { MessagesActions } from '../../../store/messages/messages.actions'; -import { RoomsActions } from '../../../store/rooms/rooms.actions'; -import { selectCurrentRoom, selectSavedRooms } from '../../../store/rooms/rooms.selectors'; -import { UsersActions } from '../../../store/users/users.actions'; -import { selectCurrentUser } from '../../../store/users/users.selectors'; -import { NotificationsFacade } from './notifications.facade'; +import { MessagesActions } from '../../../../store/messages/messages.actions'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; +import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; +import { UsersActions } from '../../../../store/users/users.actions'; +import { selectCurrentUser } from '../../../../store/users/users.selectors'; +import { NotificationsFacade } from '../facades/notifications.facade'; @Injectable() export class NotificationsEffects { diff --git a/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts b/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts new file mode 100644 index 0000000..7dac8ff --- /dev/null +++ b/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts @@ -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 + ): ReturnType { + return this.service.initialize(...args); + } + + syncRoomCatalog( + ...args: Parameters + ): ReturnType { + return this.service.syncRoomCatalog(...args); + } + + hydrateUnreadCounts( + ...args: Parameters + ): ReturnType { + return this.service.hydrateUnreadCounts(...args); + } + + refreshRoomUnreadFromMessages( + ...args: Parameters + ): ReturnType { + return this.service.refreshRoomUnreadFromMessages(...args); + } + + handleIncomingMessage( + ...args: Parameters + ): ReturnType { + return this.service.handleIncomingMessage(...args); + } + + markCurrentChannelReadIfActive( + ...args: Parameters + ): ReturnType { + return this.service.markCurrentChannelReadIfActive(...args); + } + + isRoomMuted( + ...args: Parameters + ): ReturnType { + return this.service.isRoomMuted(...args); + } + + isChannelMuted( + ...args: Parameters + ): ReturnType { + return this.service.isChannelMuted(...args); + } + + roomUnreadCount( + ...args: Parameters + ): ReturnType { + return this.service.roomUnreadCount(...args); + } + + channelUnreadCount( + ...args: Parameters + ): ReturnType { + return this.service.channelUnreadCount(...args); + } + + setNotificationsEnabled( + ...args: Parameters + ): ReturnType { + return this.service.setNotificationsEnabled(...args); + } + + setShowPreview( + ...args: Parameters + ): ReturnType { + return this.service.setShowPreview(...args); + } + + setRespectBusyStatus( + ...args: Parameters + ): ReturnType { + return this.service.setRespectBusyStatus(...args); + } + + setRoomMuted( + ...args: Parameters + ): ReturnType { + return this.service.setRoomMuted(...args); + } + + setChannelMuted( + ...args: Parameters + ): ReturnType { + return this.service.setChannelMuted(...args); + } +} diff --git a/toju-app/src/app/domains/notifications/application/notifications.facade.ts b/toju-app/src/app/domains/notifications/application/notifications.facade.ts index 46a8968..f15a4a1 100644 --- a/toju-app/src/app/domains/notifications/application/notifications.facade.ts +++ b/toju-app/src/app/domains/notifications/application/notifications.facade.ts @@ -34,9 +34,9 @@ import { type NotificationDeliveryContext, type NotificationsSettings, type NotificationsUnreadState -} from '../domain/notification.models'; -import { DesktopNotificationService } from '../infrastructure/desktop-notification.service'; -import { NotificationSettingsStorageService } from '../infrastructure/notification-settings.storage'; +} from '../domain/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'; diff --git a/toju-app/src/app/domains/notifications/application/services/notifications.service.ts b/toju-app/src/app/domains/notifications/application/services/notifications.service.ts new file mode 100644 index 0000000..bec87ab --- /dev/null +++ b/toju-app/src/app/domains/notifications/application/services/notifications.service.ts @@ -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(createDefaultNotificationSettings()); + private readonly _unread = signal(createEmptyUnreadState()); + private readonly _windowFocused = signal(typeof document === 'undefined' ? true : document.hasFocus()); + private readonly _documentVisible = signal(typeof document === 'undefined' ? true : document.visibilityState === 'visible'); + private readonly _windowMinimized = signal(false); + private readonly platformKind = detectPlatform(); + private readonly notifiedMessageIds = new Set(); + 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 { + 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 { + if (!this.initialised) { + return; + } + + const currentUserIds = this.getCurrentUserIds(); + const roomCounts: Record = {}; + const channelCounts: Record> = {}; + + 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 { + 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 = {}; + const nextChannelCounts: Record> = {}; + 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; + + 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 { + const ids = new Set(); + 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'; +} diff --git a/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts b/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts new file mode 100644 index 0000000..97683bc --- /dev/null +++ b/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts @@ -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): 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, + context: NotificationDeliveryContext +): boolean { + return context.currentRoomId === message.roomId + && context.activeChannelId === resolveMessageChannelId(message) + && context.isWindowFocused + && context.isDocumentVisible; +} + +export function shouldDeliverNotification( + settings: NotificationsSettings, + message: Pick, + 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, + 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 +): RoomUnreadCounts { + const channelCounts = Object.fromEntries( + getRoomTextChannelIds(room).map((channelId) => [channelId, 0]) + ) as Record; + + 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}`; +} diff --git a/toju-app/src/app/domains/notifications/domain/notification.models.ts b/toju-app/src/app/domains/notifications/domain/models/notification.model.ts similarity index 97% rename from toju-app/src/app/domains/notifications/domain/notification.models.ts rename to toju-app/src/app/domains/notifications/domain/models/notification.model.ts index 018a4fd..2fdd632 100644 --- a/toju-app/src/app/domains/notifications/domain/notification.models.ts +++ b/toju-app/src/app/domains/notifications/domain/models/notification.model.ts @@ -2,7 +2,7 @@ import type { Message, Room, User -} from '../../../shared-kernel'; +} from '../../../../shared-kernel'; export interface NotificationsSettings { enabled: boolean; diff --git a/toju-app/src/app/domains/notifications/domain/notification.logic.ts b/toju-app/src/app/domains/notifications/domain/notification.logic.ts index bf7a54a..b24d4f4 100644 --- a/toju-app/src/app/domains/notifications/domain/notification.logic.ts +++ b/toju-app/src/app/domains/notifications/domain/notification.logic.ts @@ -4,7 +4,7 @@ import type { NotificationDisplayPayload, NotificationsSettings, RoomUnreadCounts -} from './notification.models'; +} from './notification.model'; export const DEFAULT_TEXT_CHANNEL_ID = 'general'; diff --git a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts index 6c23f08..b25da4a 100644 --- a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts +++ b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings.component.ts @@ -15,7 +15,7 @@ import { } from '@ng-icons/lucide'; import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import type { Room } from '../../../../shared-kernel'; -import { NotificationsFacade } from '../../application/notifications.facade'; +import { NotificationsFacade } from '../../application/facades/notifications.facade'; @Component({ selector: 'app-notifications-settings', diff --git a/toju-app/src/app/domains/notifications/index.ts b/toju-app/src/app/domains/notifications/index.ts index 96e28f8..6f819a4 100644 --- a/toju-app/src/app/domains/notifications/index.ts +++ b/toju-app/src/app/domains/notifications/index.ts @@ -1,3 +1,3 @@ -export * from './application/notifications.facade'; -export * from './application/notifications.effects'; +export * from './application/facades/notifications.facade'; +export * from './application/effects/notifications.effects'; export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component'; diff --git a/toju-app/src/app/domains/notifications/infrastructure/desktop-notification.service.ts b/toju-app/src/app/domains/notifications/infrastructure/services/desktop-notification.service.ts similarity index 81% rename from toju-app/src/app/domains/notifications/infrastructure/desktop-notification.service.ts rename to toju-app/src/app/domains/notifications/infrastructure/services/desktop-notification.service.ts index 0e716ef..832d9da 100644 --- a/toju-app/src/app/domains/notifications/infrastructure/desktop-notification.service.ts +++ b/toju-app/src/app/domains/notifications/infrastructure/services/desktop-notification.service.ts @@ -1,8 +1,8 @@ import { Injectable, inject } from '@angular/core'; -import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; -import type { WindowStateSnapshot } from '../../../core/platform/electron/electron-api.models'; -import { PlatformService } from '../../../core/platform'; -import type { NotificationDisplayPayload } from '../domain/notification.models'; +import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models'; +import { PlatformService } from '../../../../core/platform'; +import type { NotificationDisplayPayload } from '../../domain/models/notification.model'; @Injectable({ providedIn: 'root' }) export class DesktopNotificationService { diff --git a/toju-app/src/app/domains/notifications/infrastructure/notification-settings.storage.ts b/toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts similarity index 94% rename from toju-app/src/app/domains/notifications/infrastructure/notification-settings.storage.ts rename to toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts index 0b8d97e..6b4b5f8 100644 --- a/toju-app/src/app/domains/notifications/infrastructure/notification-settings.storage.ts +++ b/toju-app/src/app/domains/notifications/infrastructure/services/notification-settings-storage.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../core/constants'; -import { createDefaultNotificationSettings, type NotificationsSettings } from '../domain/notification.models'; +import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../../core/constants'; +import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model'; @Injectable({ providedIn: 'root' }) export class NotificationSettingsStorageService {