/* 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'; }