diff --git a/toju-app/src/app/domains/notifications/application/notifications.facade.ts b/toju-app/src/app/domains/notifications/application/notifications.facade.ts deleted file mode 100644 index f15a4a1..0000000 --- a/toju-app/src/app/domains/notifications/application/notifications.facade.ts +++ /dev/null @@ -1,607 +0,0 @@ -/* 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/notification.logic'; -import { - createDefaultNotificationSettings, - createEmptyUnreadState, - type NotificationDeliveryContext, - type NotificationsSettings, - type NotificationsUnreadState -} 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'; - -const MAX_NOTIFIED_MESSAGE_IDS = 500; - -@Injectable({ providedIn: 'root' }) -export class NotificationsFacade { - 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/notification.logic.ts b/toju-app/src/app/domains/notifications/domain/notification.logic.ts deleted file mode 100644 index b24d4f4..0000000 --- a/toju-app/src/app/domains/notifications/domain/notification.logic.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { Message, Room } from '../../../shared-kernel'; -import type { - NotificationDeliveryContext, - NotificationDisplayPayload, - NotificationsSettings, - RoomUnreadCounts -} from './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/server-directory/README.md b/toju-app/src/app/domains/server-directory/README.md index 86a8b1d..81ae2fc 100644 --- a/toju-app/src/app/domains/server-directory/README.md +++ b/toju-app/src/app/domains/server-directory/README.md @@ -7,8 +7,11 @@ Manages the list of server endpoints the client can connect to, health-checking ``` server-directory/ ├── application/ -│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation -│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence +│ ├── facades/ +│ │ └── server-directory.facade.ts Thin domain boundary, delegates to ServerDirectoryService +│ └── services/ +│ ├── server-directory.service.ts Orchestrator: server CRUD, search, health, invites, moderation +│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence │ ├── domain/ │ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types @@ -31,11 +34,12 @@ server-directory/ ## Layer composition -The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service. +The facade is a thin pass-through that delegates to `ServerDirectoryService`. The service delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service. ```mermaid graph TD Facade[ServerDirectoryFacade] + Service[ServerDirectoryService] State[ServerEndpointStateService] API[ServerDirectoryApiService] Health[ServerEndpointHealthService] @@ -44,17 +48,19 @@ graph TD Defaults[server-endpoint-defaults] Models[server-directory.models] - Facade --> API - Facade --> State - Facade --> Health - Facade --> Compat + Facade --> Service + Service --> API + Service --> State + Service --> Health + Service --> Compat API --> State State --> Storage State --> Defaults Health --> Compat - click Facade "application/server-directory.facade.ts" "High-level API" _blank - click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank + click Facade "application/facades/server-directory.facade.ts" "Thin domain boundary" _blank + click Service "application/services/server-directory.service.ts" "Orchestrator" _blank + click State "application/services/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API" _blank click Health "infrastructure/server-endpoint-health.service.ts" "Health probe" _blank click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank @@ -87,7 +93,7 @@ stateDiagram-v2 ## Health probing -The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which: +The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate through the service to `ServerEndpointHealthService.probeEndpoint()`, which: 1. Sends `GET /api/health` with a 5-second timeout 2. Reads the response's `serverVersion` and stable `serverInstanceId` diff --git a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts new file mode 100644 index 0000000..d8c981f --- /dev/null +++ b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts @@ -0,0 +1,229 @@ +/* eslint-disable @typescript-eslint/member-ordering */ +import { Injectable, inject } from '@angular/core'; +import { ServerDirectoryService } from '../services/server-directory.service'; + +@Injectable({ providedIn: 'root' }) +export class ServerDirectoryFacade { + private readonly service = inject(ServerDirectoryService); + + readonly servers = this.service.servers; + readonly activeServers = this.service.activeServers; + readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers; + readonly activeServer = this.service.activeServer; + + awaitInitialServerHealthCheck( + ...args: Parameters + ): ReturnType { + return this.service.awaitInitialServerHealthCheck(...args); + } + + addServer( + ...args: Parameters + ): ReturnType { + return this.service.addServer(...args); + } + + ensureServerEndpoint( + ...args: Parameters + ): ReturnType { + return this.service.ensureServerEndpoint(...args); + } + + findServerByUrl( + ...args: Parameters + ): ReturnType { + return this.service.findServerByUrl(...args); + } + + removeServer( + ...args: Parameters + ): ReturnType { + return this.service.removeServer(...args); + } + + restoreDefaultServers( + ...args: Parameters + ): ReturnType { + return this.service.restoreDefaultServers(...args); + } + + setActiveServer( + ...args: Parameters + ): ReturnType { + return this.service.setActiveServer(...args); + } + + deactivateServer( + ...args: Parameters + ): ReturnType { + return this.service.deactivateServer(...args); + } + + updateServerStatus( + ...args: Parameters + ): ReturnType { + return this.service.updateServerStatus(...args); + } + + ensureEndpointVersionCompatibility( + ...args: Parameters + ): ReturnType { + return this.service.ensureEndpointVersionCompatibility(...args); + } + + resolveRoomEndpoint( + ...args: Parameters + ): ReturnType { + return this.service.resolveRoomEndpoint(...args); + } + + normaliseRoomSignalSource( + ...args: Parameters + ): ReturnType { + return this.service.normaliseRoomSignalSource(...args); + } + + buildRoomSignalSelector( + ...args: Parameters + ): ReturnType { + return this.service.buildRoomSignalSelector(...args); + } + + getFallbackRoomEndpoints( + ...args: Parameters + ): ReturnType { + return this.service.getFallbackRoomEndpoints(...args); + } + + setSearchAllServers( + ...args: Parameters + ): ReturnType { + return this.service.setSearchAllServers(...args); + } + + testServer( + ...args: Parameters + ): ReturnType { + return this.service.testServer(...args); + } + + testAllServers( + ...args: Parameters + ): ReturnType { + return this.service.testAllServers(...args); + } + + getApiBaseUrl( + ...args: Parameters + ): ReturnType { + return this.service.getApiBaseUrl(...args); + } + + getWebSocketUrl( + ...args: Parameters + ): ReturnType { + return this.service.getWebSocketUrl(...args); + } + + searchServers( + ...args: Parameters + ): ReturnType { + return this.service.searchServers(...args); + } + + getServers( + ...args: Parameters + ): ReturnType { + return this.service.getServers(...args); + } + + getServer( + ...args: Parameters + ): ReturnType { + return this.service.getServer(...args); + } + + findServerAcrossActiveEndpoints( + ...args: Parameters + ): ReturnType { + return this.service.findServerAcrossActiveEndpoints(...args); + } + + registerServer( + ...args: Parameters + ): ReturnType { + return this.service.registerServer(...args); + } + + updateServer( + ...args: Parameters + ): ReturnType { + return this.service.updateServer(...args); + } + + unregisterServer( + ...args: Parameters + ): ReturnType { + return this.service.unregisterServer(...args); + } + + getServerUsers( + ...args: Parameters + ): ReturnType { + return this.service.getServerUsers(...args); + } + + requestJoin( + ...args: Parameters + ): ReturnType { + return this.service.requestJoin(...args); + } + + createInvite( + ...args: Parameters + ): ReturnType { + return this.service.createInvite(...args); + } + + getInvite( + ...args: Parameters + ): ReturnType { + return this.service.getInvite(...args); + } + + kickServerMember( + ...args: Parameters + ): ReturnType { + return this.service.kickServerMember(...args); + } + + banServerMember( + ...args: Parameters + ): ReturnType { + return this.service.banServerMember(...args); + } + + unbanServerMember( + ...args: Parameters + ): ReturnType { + return this.service.unbanServerMember(...args); + } + + notifyLeave( + ...args: Parameters + ): ReturnType { + return this.service.notifyLeave(...args); + } + + updateUserCount( + ...args: Parameters + ): ReturnType { + return this.service.updateUserCount(...args); + } + + sendHeartbeat( + ...args: Parameters + ): ReturnType { + return this.service.sendHeartbeat(...args); + } +} diff --git a/toju-app/src/app/domains/server-directory/application/server-directory.facade.ts b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts similarity index 93% rename from toju-app/src/app/domains/server-directory/application/server-directory.facade.ts rename to toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts index b17037e..9655525 100644 --- a/toju-app/src/app/domains/server-directory/application/server-directory.facade.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-directory.service.ts @@ -4,10 +4,9 @@ import { type Signal } from '@angular/core'; import { Observable } from 'rxjs'; -import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants'; -import { User } from '../../../shared-kernel'; -import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants'; -import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service'; +import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants'; +import { User } from '../../../../shared-kernel'; +import { ServerDirectoryApiService } from '../../infrastructure/server-directory-api.service'; import type { BanServerMemberRequest, CreateServerInviteRequest, @@ -20,21 +19,19 @@ import type { ServerJoinAccessResponse, ServerSourceSelector, UnbanServerMemberRequest -} from '../domain/server-directory.models'; +} from '../../domain/server-directory.models'; import { buildRoomSignalSelector, buildRoomSignalSource, type RoomSignalSource, type RoomSignalSourceInput -} from '../domain/room-signal-source'; -import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service'; -import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service'; +} from '../../domain/room-signal-source'; +import { ServerEndpointCompatibilityService } from '../../infrastructure/server-endpoint-compatibility.service'; +import { ServerEndpointHealthService } from '../../infrastructure/server-endpoint-health.service'; import { ServerEndpointStateService } from './server-endpoint-state.service'; -export { CLIENT_UPDATE_REQUIRED_MESSAGE }; - @Injectable({ providedIn: 'root' }) -export class ServerDirectoryFacade { +export class ServerDirectoryService { readonly servers: Signal; readonly activeServers: Signal; readonly hasMissingDefaultServers: Signal; diff --git a/toju-app/src/app/domains/server-directory/application/server-endpoint-state.service.ts b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts similarity index 97% rename from toju-app/src/app/domains/server-directory/application/server-endpoint-state.service.ts rename to toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts index 3e0214c..625d188 100644 --- a/toju-app/src/app/domains/server-directory/application/server-endpoint-state.service.ts +++ b/toju-app/src/app/domains/server-directory/application/services/server-endpoint-state.service.ts @@ -6,7 +6,7 @@ import { type Signal } from '@angular/core'; import { v4 as uuidv4 } from 'uuid'; -import { environment } from '../../../../environments/environment'; +import { environment } from '../../../../../environments/environment'; import { buildDefaultEndpointTemplates, buildDefaultServerDefinitions, @@ -16,14 +16,14 @@ import { hasEndpointForDefault, matchDefaultEndpointTemplate, sanitiseServerBaseUrl -} from '../domain/server-endpoint-defaults'; -import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service'; +} from '../../domain/server-endpoint-defaults'; +import { ServerEndpointStorageService } from '../../infrastructure/server-endpoint-storage.service'; import type { ConfiguredDefaultServerDefinition, DefaultEndpointTemplate, ServerEndpoint, ServerEndpointVersions -} from '../domain/server-directory.models'; +} from '../../domain/server-directory.models'; function resolveDefaultHttpProtocol(): 'http' | 'https' { return typeof window !== 'undefined' && window.location?.protocol === 'https:' diff --git a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts index 98bf947..0c3f3e1 100644 --- a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts @@ -14,7 +14,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors'; import type { ServerInviteInfo } from '../../domain/server-directory.models'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants'; import { DatabaseService } from '../../../../infrastructure/persistence'; -import { ServerDirectoryFacade } from '../../application/server-directory.facade'; +import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { User } from '../../../../shared-kernel'; @Component({ diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts index 116f535..9d154a7 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts @@ -37,7 +37,7 @@ import { Room, User } from '../../../../shared-kernel'; import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { type ServerInfo } from '../../domain/server-directory.models'; -import { ServerDirectoryFacade } from '../../application/server-directory.facade'; +import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { ConfirmDialogComponent } from '../../../../shared'; import { hasRoomBanForUser } from '../../../access-control'; diff --git a/toju-app/src/app/domains/server-directory/index.ts b/toju-app/src/app/domains/server-directory/index.ts index 69c8b2c..39db73e 100644 --- a/toju-app/src/app/domains/server-directory/index.ts +++ b/toju-app/src/app/domains/server-directory/index.ts @@ -1,4 +1,4 @@ -export * from './application/server-directory.facade'; +export * from './application/facades/server-directory.facade'; export * from './domain/server-directory.constants'; export * from './domain/server-directory.models'; export * from './domain/room-signal-source'; diff --git a/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts b/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts index 4325b3e..7c82ed8 100644 --- a/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts +++ b/toju-app/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts @@ -16,7 +16,7 @@ import { RoomRoleAssignment, User } from '../../../shared-kernel'; -import { ServerEndpointStateService } from '../application/server-endpoint-state.service'; +import { ServerEndpointStateService } from '../application/services/server-endpoint-state.service'; import type { BanServerMemberRequest, CreateServerInviteRequest,