608 lines
17 KiB
TypeScript
608 lines
17 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
Injectable,
|
|
computed,
|
|
inject,
|
|
signal
|
|
} from '@angular/core';
|
|
import { Store } from '@ngrx/store';
|
|
import type { Message, Room } from '../../../../shared-kernel';
|
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
|
import {
|
|
selectActiveChannelId,
|
|
selectCurrentRoom,
|
|
selectSavedRooms
|
|
} from '../../../../store/rooms/rooms.selectors';
|
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
import {
|
|
buildNotificationDisplayPayload,
|
|
calculateUnreadForRoom,
|
|
DEFAULT_TEXT_CHANNEL_ID,
|
|
getRoomById,
|
|
getRoomTextChannelIds,
|
|
getRoomTrackingBaseline,
|
|
isChannelMuted,
|
|
isRoomMuted,
|
|
isMessageVisibleInActiveView,
|
|
resolveMessageChannelId,
|
|
shouldDeliverNotification
|
|
} from '../../domain/logic/notification.logic';
|
|
import {
|
|
createDefaultNotificationSettings,
|
|
createEmptyUnreadState,
|
|
type NotificationDeliveryContext,
|
|
type NotificationsSettings,
|
|
type NotificationsUnreadState
|
|
} from '../../domain/models/notification.model';
|
|
import { DesktopNotificationService } from '../../infrastructure/services/desktop-notification.service';
|
|
import { NotificationSettingsStorageService } from '../../infrastructure/services/notification-settings-storage.service';
|
|
|
|
type DesktopPlatform = 'linux' | 'mac' | 'unknown' | 'windows';
|
|
|
|
const MAX_NOTIFIED_MESSAGE_IDS = 500;
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class NotificationsService {
|
|
private readonly store = inject(Store);
|
|
private readonly db = inject(DatabaseService);
|
|
private readonly audio = inject(NotificationAudioService);
|
|
private readonly desktopNotifications = inject(DesktopNotificationService);
|
|
private readonly storage = inject(NotificationSettingsStorageService);
|
|
|
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
private readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
|
|
private readonly _settings = signal<NotificationsSettings>(createDefaultNotificationSettings());
|
|
private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
|
|
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
|
|
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
|
private readonly _windowMinimized = signal<boolean>(false);
|
|
private readonly platformKind = detectPlatform();
|
|
private readonly notifiedMessageIds = new Set<string>();
|
|
private readonly notifiedMessageOrder: string[] = [];
|
|
private attentionActive = false;
|
|
private windowStateCleanup: (() => void) | null = null;
|
|
private initialised = false;
|
|
|
|
readonly settings = computed(() => this._settings());
|
|
readonly unread = computed(() => this._unread());
|
|
|
|
async initialize(): Promise<void> {
|
|
if (this.initialised) {
|
|
return;
|
|
}
|
|
|
|
this._settings.set(this.storage.load());
|
|
this.initialised = true;
|
|
this.registerWindowListeners();
|
|
this.registerWindowStateListener();
|
|
this.syncRoomCatalog(this.savedRooms());
|
|
this.markCurrentChannelReadIfActive();
|
|
}
|
|
|
|
syncRoomCatalog(rooms: Room[]): void {
|
|
if (!this.initialised) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const currentSettings = this._settings();
|
|
const nextSettings: NotificationsSettings = {
|
|
...currentSettings,
|
|
mutedRooms: {},
|
|
mutedChannels: {},
|
|
roomBaselines: {},
|
|
lastReadByChannel: {}
|
|
};
|
|
|
|
for (const room of rooms) {
|
|
nextSettings.mutedRooms[room.id] = currentSettings.mutedRooms[room.id] === true;
|
|
nextSettings.roomBaselines[room.id] = currentSettings.roomBaselines[room.id] ?? now;
|
|
|
|
const textChannelIds = new Set(getRoomTextChannelIds(room));
|
|
const mutedChannels = currentSettings.mutedChannels[room.id] ?? {};
|
|
const lastReadByChannel = currentSettings.lastReadByChannel[room.id] ?? {};
|
|
|
|
nextSettings.mutedChannels[room.id] = Object.fromEntries(
|
|
Object.entries(mutedChannels)
|
|
.filter(([channelId, muted]) => textChannelIds.has(channelId) && muted === true)
|
|
);
|
|
|
|
nextSettings.lastReadByChannel[room.id] = Object.fromEntries(
|
|
Object.entries(lastReadByChannel)
|
|
.filter((entry): entry is [string, number] => textChannelIds.has(entry[0]) && typeof entry[1] === 'number')
|
|
);
|
|
}
|
|
|
|
this.setSettings(nextSettings);
|
|
this.pruneUnreadState(rooms);
|
|
}
|
|
|
|
async hydrateUnreadCounts(rooms: Room[]): Promise<void> {
|
|
if (!this.initialised) {
|
|
return;
|
|
}
|
|
|
|
const currentUserIds = this.getCurrentUserIds();
|
|
const roomCounts: Record<string, number> = {};
|
|
const channelCounts: Record<string, Record<string, number>> = {};
|
|
|
|
for (const room of rooms) {
|
|
const trackedSince = getRoomTrackingBaseline(this._settings(), room);
|
|
const messages = await this.db.getMessagesSince(room.id, trackedSince);
|
|
const counts = calculateUnreadForRoom(room, messages, this._settings(), currentUserIds);
|
|
|
|
roomCounts[room.id] = counts.roomCount;
|
|
channelCounts[room.id] = counts.channelCounts;
|
|
}
|
|
|
|
this._unread.set({ roomCounts, channelCounts });
|
|
this.pruneUnreadState(rooms);
|
|
this.markCurrentChannelReadIfActive();
|
|
}
|
|
|
|
refreshRoomUnreadFromMessages(roomId: string, messages: Message[]): void {
|
|
const room = getRoomById(this.savedRooms(), roomId);
|
|
|
|
if (!room) {
|
|
return;
|
|
}
|
|
|
|
const counts = calculateUnreadForRoom(room, messages, this._settings(), this.getCurrentUserIds());
|
|
|
|
this._unread.update((state) => ({
|
|
roomCounts: {
|
|
...state.roomCounts,
|
|
[roomId]: counts.roomCount
|
|
},
|
|
channelCounts: {
|
|
...state.channelCounts,
|
|
[roomId]: counts.channelCounts
|
|
}
|
|
}));
|
|
|
|
this.syncWindowAttention();
|
|
}
|
|
|
|
async handleIncomingMessage(message: Message): Promise<void> {
|
|
if (!this.initialised || this.isDuplicateMessage(message.id) || this.isOwnMessage(message.senderId)) {
|
|
return;
|
|
}
|
|
|
|
this.rememberMessageId(message.id);
|
|
|
|
const channelId = resolveMessageChannelId(message);
|
|
const baselineTimestamp = Math.max(message.timestamp - 1, 0);
|
|
|
|
this.ensureRoomTracking(message.roomId, channelId, baselineTimestamp);
|
|
|
|
if (message.isDeleted) {
|
|
return;
|
|
}
|
|
|
|
if (isMessageVisibleInActiveView(message, this.buildContext())) {
|
|
this.markChannelRead(message.roomId, channelId, message.timestamp);
|
|
return;
|
|
}
|
|
|
|
if (message.timestamp > this.getChannelLastReadAt(message.roomId, channelId)) {
|
|
this.incrementUnread(message.roomId, channelId);
|
|
}
|
|
|
|
const context = this.buildContext();
|
|
|
|
if (!shouldDeliverNotification(this._settings(), message, context)) {
|
|
return;
|
|
}
|
|
|
|
const room = getRoomById(context.rooms, message.roomId);
|
|
const payload = buildNotificationDisplayPayload(
|
|
message,
|
|
room,
|
|
this._settings(),
|
|
!context.isWindowFocused || !context.isDocumentVisible
|
|
);
|
|
|
|
if (this.shouldPlayNotificationSound()) {
|
|
this.audio.play(AppSound.Notification);
|
|
}
|
|
|
|
await this.desktopNotifications.showNotification(payload);
|
|
}
|
|
|
|
markCurrentChannelReadIfActive(): void {
|
|
if (!this.initialised || !this._windowFocused() || !this._documentVisible()) {
|
|
return;
|
|
}
|
|
|
|
const room = this.currentRoom();
|
|
|
|
if (!room) {
|
|
this.syncWindowAttention();
|
|
return;
|
|
}
|
|
|
|
const channelId = this.activeChannelId() || DEFAULT_TEXT_CHANNEL_ID;
|
|
|
|
this.markChannelRead(room.id, channelId);
|
|
}
|
|
|
|
isRoomMuted(roomId: string): boolean {
|
|
return isRoomMuted(this._settings(), roomId);
|
|
}
|
|
|
|
isChannelMuted(roomId: string, channelId: string): boolean {
|
|
return isChannelMuted(this._settings(), roomId, channelId);
|
|
}
|
|
|
|
roomUnreadCount(roomId: string): number {
|
|
return this._unread().roomCounts[roomId] ?? 0;
|
|
}
|
|
|
|
channelUnreadCount(roomId: string, channelId: string): number {
|
|
return this._unread().channelCounts[roomId]?.[channelId] ?? 0;
|
|
}
|
|
|
|
setNotificationsEnabled(enabled: boolean): void {
|
|
this.setSettings({
|
|
...this._settings(),
|
|
enabled
|
|
});
|
|
}
|
|
|
|
setShowPreview(showPreview: boolean): void {
|
|
this.setSettings({
|
|
...this._settings(),
|
|
showPreview
|
|
});
|
|
}
|
|
|
|
setRespectBusyStatus(respectBusyStatus: boolean): void {
|
|
this.setSettings({
|
|
...this._settings(),
|
|
respectBusyStatus
|
|
});
|
|
}
|
|
|
|
setRoomMuted(roomId: string, muted: boolean): void {
|
|
this.setSettings({
|
|
...this._settings(),
|
|
mutedRooms: {
|
|
...this._settings().mutedRooms,
|
|
[roomId]: muted
|
|
}
|
|
});
|
|
}
|
|
|
|
setChannelMuted(roomId: string, channelId: string, muted: boolean): void {
|
|
this.setSettings({
|
|
...this._settings(),
|
|
mutedChannels: {
|
|
...this._settings().mutedChannels,
|
|
[roomId]: {
|
|
...(this._settings().mutedChannels[roomId] ?? {}),
|
|
[channelId]: muted
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private registerWindowListeners(): void {
|
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
window.addEventListener('focus', this.handleWindowFocus);
|
|
window.addEventListener('blur', this.handleWindowBlur);
|
|
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
}
|
|
|
|
private registerWindowStateListener(): void {
|
|
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
|
this._windowFocused.set(state.isFocused);
|
|
this._windowMinimized.set(state.isMinimized);
|
|
|
|
if (state.isFocused && !state.isMinimized && this._documentVisible()) {
|
|
this.markCurrentChannelReadIfActive();
|
|
return;
|
|
}
|
|
|
|
this.syncWindowAttention();
|
|
});
|
|
}
|
|
|
|
private readonly handleWindowFocus = (): void => {
|
|
this._windowFocused.set(true);
|
|
this._windowMinimized.set(false);
|
|
this.markCurrentChannelReadIfActive();
|
|
};
|
|
|
|
private readonly handleWindowBlur = (): void => {
|
|
this._windowFocused.set(false);
|
|
this.syncWindowAttention();
|
|
};
|
|
|
|
private readonly handleVisibilityChange = (): void => {
|
|
const isVisible = document.visibilityState === 'visible';
|
|
|
|
this._documentVisible.set(isVisible);
|
|
|
|
if (isVisible && this._windowFocused()) {
|
|
this.markCurrentChannelReadIfActive();
|
|
return;
|
|
}
|
|
|
|
this.syncWindowAttention();
|
|
};
|
|
|
|
private buildContext(): NotificationDeliveryContext {
|
|
return {
|
|
activeChannelId: this.activeChannelId(),
|
|
currentRoomId: this.currentRoom()?.id ?? null,
|
|
currentUser: this.currentUser() ?? null,
|
|
isDocumentVisible: this._documentVisible(),
|
|
isWindowFocused: this._windowFocused(),
|
|
rooms: this.savedRooms()
|
|
};
|
|
}
|
|
|
|
private shouldPlayNotificationSound(): boolean {
|
|
return this.platformKind === 'windows' && !this.isWindowActive();
|
|
}
|
|
|
|
private isWindowActive(): boolean {
|
|
return this._windowFocused() && this._documentVisible() && !this._windowMinimized();
|
|
}
|
|
|
|
private ensureRoomTracking(roomId: string, channelId: string, baselineTimestamp: number): void {
|
|
const settings = this._settings();
|
|
|
|
if (settings.roomBaselines[roomId] !== undefined) {
|
|
return;
|
|
}
|
|
|
|
this.setSettings({
|
|
...settings,
|
|
roomBaselines: {
|
|
...settings.roomBaselines,
|
|
[roomId]: baselineTimestamp
|
|
},
|
|
mutedChannels: {
|
|
...settings.mutedChannels,
|
|
[roomId]: {
|
|
...(settings.mutedChannels[roomId] ?? {})
|
|
}
|
|
},
|
|
lastReadByChannel: {
|
|
...settings.lastReadByChannel,
|
|
[roomId]: {
|
|
...(settings.lastReadByChannel[roomId] ?? {})
|
|
}
|
|
}
|
|
});
|
|
|
|
this._unread.update((state) => ({
|
|
roomCounts: {
|
|
...state.roomCounts,
|
|
[roomId]: state.roomCounts[roomId] ?? 0
|
|
},
|
|
channelCounts: {
|
|
...state.channelCounts,
|
|
[roomId]: {
|
|
...(state.channelCounts[roomId] ?? {}),
|
|
[channelId]: state.channelCounts[roomId]?.[channelId] ?? 0
|
|
}
|
|
}
|
|
}));
|
|
|
|
this.syncWindowAttention();
|
|
}
|
|
|
|
private pruneUnreadState(rooms: Room[]): void {
|
|
const nextRoomCounts: Record<string, number> = {};
|
|
const nextChannelCounts: Record<string, Record<string, number>> = {};
|
|
const currentUnread = this._unread();
|
|
|
|
for (const room of rooms) {
|
|
const validChannelIds = new Set(getRoomTextChannelIds(room));
|
|
const currentChannels = currentUnread.channelCounts[room.id] ?? {};
|
|
const roomChannelCounts = Object.fromEntries(
|
|
Object.entries(currentChannels)
|
|
.filter(([channelId]) => validChannelIds.has(channelId))
|
|
) as Record<string, number>;
|
|
|
|
for (const channelId of validChannelIds) {
|
|
roomChannelCounts[channelId] = roomChannelCounts[channelId] ?? 0;
|
|
}
|
|
|
|
nextChannelCounts[room.id] = roomChannelCounts;
|
|
nextRoomCounts[room.id] = Object.values(roomChannelCounts).reduce((total, count) => total + count, 0);
|
|
}
|
|
|
|
this._unread.set({
|
|
roomCounts: nextRoomCounts,
|
|
channelCounts: nextChannelCounts
|
|
});
|
|
|
|
this.syncWindowAttention();
|
|
}
|
|
|
|
private incrementUnread(roomId: string, channelId: string): void {
|
|
this._unread.update((state) => {
|
|
const roomChannelCounts = {
|
|
...(state.channelCounts[roomId] ?? {}),
|
|
[channelId]: (state.channelCounts[roomId]?.[channelId] ?? 0) + 1
|
|
};
|
|
|
|
return {
|
|
roomCounts: {
|
|
...state.roomCounts,
|
|
[roomId]: Object.values(roomChannelCounts).reduce((total, count) => total + count, 0)
|
|
},
|
|
channelCounts: {
|
|
...state.channelCounts,
|
|
[roomId]: roomChannelCounts
|
|
}
|
|
};
|
|
});
|
|
|
|
this.syncWindowAttention();
|
|
}
|
|
|
|
private markChannelRead(roomId: string, channelId: string, timestamp = Date.now()): void {
|
|
const nextReadAt = Math.max(timestamp, Date.now(), this.getChannelLastReadAt(roomId, channelId));
|
|
|
|
this.setSettings({
|
|
...this._settings(),
|
|
lastReadByChannel: {
|
|
...this._settings().lastReadByChannel,
|
|
[roomId]: {
|
|
...(this._settings().lastReadByChannel[roomId] ?? {}),
|
|
[channelId]: nextReadAt
|
|
}
|
|
}
|
|
});
|
|
|
|
this._unread.update((state) => {
|
|
const roomChannelCounts = {
|
|
...(state.channelCounts[roomId] ?? {}),
|
|
[channelId]: 0
|
|
};
|
|
|
|
return {
|
|
roomCounts: {
|
|
...state.roomCounts,
|
|
[roomId]: Object.values(roomChannelCounts).reduce((total, count) => total + count, 0)
|
|
},
|
|
channelCounts: {
|
|
...state.channelCounts,
|
|
[roomId]: roomChannelCounts
|
|
}
|
|
};
|
|
});
|
|
|
|
this.syncWindowAttention();
|
|
}
|
|
|
|
private getChannelLastReadAt(roomId: string, channelId: string): number {
|
|
return this._settings().lastReadByChannel[roomId]?.[channelId]
|
|
?? this._settings().roomBaselines[roomId]
|
|
?? 0;
|
|
}
|
|
|
|
private getCurrentUserIds(): Set<string> {
|
|
const ids = new Set<string>();
|
|
const user = this.currentUser();
|
|
|
|
if (user?.id) {
|
|
ids.add(user.id);
|
|
}
|
|
|
|
if (user?.oderId) {
|
|
ids.add(user.oderId);
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
private isOwnMessage(senderId: string): boolean {
|
|
return this.getCurrentUserIds().has(senderId);
|
|
}
|
|
|
|
private setSettings(settings: NotificationsSettings): void {
|
|
this._settings.set(settings);
|
|
this.storage.save(settings);
|
|
this.syncWindowAttention();
|
|
}
|
|
|
|
private hasActionableUnread(): boolean {
|
|
const unread = this._unread();
|
|
const settings = this._settings();
|
|
|
|
for (const [roomId, roomCount] of Object.entries(unread.roomCounts)) {
|
|
if (roomCount <= 0 || isRoomMuted(settings, roomId)) {
|
|
continue;
|
|
}
|
|
|
|
const channelCounts = unread.channelCounts[roomId] ?? {};
|
|
|
|
for (const [channelId, count] of Object.entries(channelCounts)) {
|
|
if (count > 0 && !isChannelMuted(settings, roomId, channelId)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private syncWindowAttention(): void {
|
|
if (!this.initialised) {
|
|
return;
|
|
}
|
|
|
|
const shouldAttention = this._settings().enabled
|
|
&& this.hasActionableUnread()
|
|
&& (this._windowMinimized() || !this._windowFocused() || !this._documentVisible());
|
|
|
|
if (shouldAttention === this.attentionActive) {
|
|
return;
|
|
}
|
|
|
|
this.attentionActive = shouldAttention;
|
|
|
|
if (shouldAttention) {
|
|
void this.desktopNotifications.requestAttention();
|
|
return;
|
|
}
|
|
|
|
void this.desktopNotifications.clearAttention();
|
|
}
|
|
|
|
private isDuplicateMessage(messageId: string): boolean {
|
|
return this.notifiedMessageIds.has(messageId);
|
|
}
|
|
|
|
private rememberMessageId(messageId: string): void {
|
|
if (this.notifiedMessageIds.has(messageId)) {
|
|
return;
|
|
}
|
|
|
|
this.notifiedMessageIds.add(messageId);
|
|
this.notifiedMessageOrder.push(messageId);
|
|
|
|
if (this.notifiedMessageOrder.length > MAX_NOTIFIED_MESSAGE_IDS) {
|
|
const removedId = this.notifiedMessageOrder.shift();
|
|
|
|
if (removedId) {
|
|
this.notifiedMessageIds.delete(removedId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function detectPlatform(): DesktopPlatform {
|
|
if (typeof navigator === 'undefined') {
|
|
return 'unknown';
|
|
}
|
|
|
|
const platformInfo = `${navigator.userAgent} ${navigator.platform}`.toLowerCase();
|
|
|
|
if (platformInfo.includes('win')) {
|
|
return 'windows';
|
|
}
|
|
|
|
if (platformInfo.includes('linux')) {
|
|
return 'linux';
|
|
}
|
|
|
|
if (platformInfo.includes('mac')) {
|
|
return 'mac';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|