refactor: true facades
This commit is contained in:
@@ -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<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';
|
|
||||||
}
|
|
||||||
@@ -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<Message, 'channelId'>): string {
|
|
||||||
return message.channelId || DEFAULT_TEXT_CHANNEL_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoomTextChannelIds(room: Room): string[] {
|
|
||||||
const textChannelIds = (room.channels ?? [])
|
|
||||||
.filter((channel) => channel.type === 'text')
|
|
||||||
.map((channel) => channel.id);
|
|
||||||
|
|
||||||
return textChannelIds.length > 0 ? textChannelIds : [DEFAULT_TEXT_CHANNEL_ID];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoomById(rooms: Room[], roomId: string): Room | null {
|
|
||||||
return rooms.find((room) => room.id === roomId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChannelLabel(room: Room | null, channelId: string): string {
|
|
||||||
const channelName = room?.channels?.find((channel) => channel.id === channelId)?.name;
|
|
||||||
|
|
||||||
return channelName || DEFAULT_TEXT_CHANNEL_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChannelLastReadAt(
|
|
||||||
settings: NotificationsSettings,
|
|
||||||
roomId: string,
|
|
||||||
channelId: string
|
|
||||||
): number {
|
|
||||||
return settings.lastReadByChannel[roomId]?.[channelId]
|
|
||||||
?? settings.roomBaselines[roomId]
|
|
||||||
?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoomTrackingBaseline(settings: NotificationsSettings, room: Room): number {
|
|
||||||
const trackedChannels = getRoomTextChannelIds(room).map((channelId) =>
|
|
||||||
getChannelLastReadAt(settings, room.id, channelId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return Math.min(...trackedChannels, settings.roomBaselines[room.id] ?? Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRoomMuted(settings: NotificationsSettings, roomId: string): boolean {
|
|
||||||
return settings.mutedRooms[roomId] === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isChannelMuted(
|
|
||||||
settings: NotificationsSettings,
|
|
||||||
roomId: string,
|
|
||||||
channelId: string
|
|
||||||
): boolean {
|
|
||||||
return settings.mutedChannels[roomId]?.[channelId] === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMessageVisibleInActiveView(
|
|
||||||
message: Pick<Message, 'channelId' | 'roomId'>,
|
|
||||||
context: NotificationDeliveryContext
|
|
||||||
): boolean {
|
|
||||||
return context.currentRoomId === message.roomId
|
|
||||||
&& context.activeChannelId === resolveMessageChannelId(message)
|
|
||||||
&& context.isWindowFocused
|
|
||||||
&& context.isDocumentVisible;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldDeliverNotification(
|
|
||||||
settings: NotificationsSettings,
|
|
||||||
message: Pick<Message, 'channelId' | 'roomId'>,
|
|
||||||
context: NotificationDeliveryContext
|
|
||||||
): boolean {
|
|
||||||
const channelId = resolveMessageChannelId(message);
|
|
||||||
|
|
||||||
if (!settings.enabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRoomMuted(settings, message.roomId) || isChannelMuted(settings, message.roomId, channelId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !isMessageVisibleInActiveView(message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildNotificationDisplayPayload(
|
|
||||||
message: Pick<Message, 'channelId' | 'content' | 'senderName'>,
|
|
||||||
room: Room | null,
|
|
||||||
settings: NotificationsSettings,
|
|
||||||
requestAttention: boolean
|
|
||||||
): NotificationDisplayPayload {
|
|
||||||
const channelId = resolveMessageChannelId(message);
|
|
||||||
const roomName = room?.name || 'Server';
|
|
||||||
const channelLabel = getChannelLabel(room, channelId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${roomName} · #${channelLabel}`,
|
|
||||||
body: settings.showPreview
|
|
||||||
? formatMessagePreview(message.senderName, message.content)
|
|
||||||
: `${message.senderName} sent a new message`,
|
|
||||||
requestAttention
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateUnreadForRoom(
|
|
||||||
room: Room,
|
|
||||||
messages: Message[],
|
|
||||||
settings: NotificationsSettings,
|
|
||||||
currentUserIds: Set<string>
|
|
||||||
): RoomUnreadCounts {
|
|
||||||
const channelCounts = Object.fromEntries(
|
|
||||||
getRoomTextChannelIds(room).map((channelId) => [channelId, 0])
|
|
||||||
) as Record<string, number>;
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message.isDeleted || currentUserIds.has(message.senderId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelId = resolveMessageChannelId(message);
|
|
||||||
|
|
||||||
if (message.timestamp <= getChannelLastReadAt(settings, room.id, channelId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
channelCounts[channelId] = (channelCounts[channelId] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
channelCounts,
|
|
||||||
roomCount: Object.values(channelCounts).reduce((total, count) => total + count, 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMessagePreview(senderName: string, content: string): string {
|
|
||||||
const normalisedContent = content.replace(/\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
if (!normalisedContent) {
|
|
||||||
return `${senderName} sent a new message`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT
|
|
||||||
? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}…`
|
|
||||||
: normalisedContent;
|
|
||||||
|
|
||||||
return `${senderName}: ${preview}`;
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,10 @@ Manages the list of server endpoints the client can connect to, health-checking
|
|||||||
```
|
```
|
||||||
server-directory/
|
server-directory/
|
||||||
├── application/
|
├── application/
|
||||||
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation
|
│ ├── 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
|
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
|
||||||
│
|
│
|
||||||
├── domain/
|
├── domain/
|
||||||
@@ -31,11 +34,12 @@ server-directory/
|
|||||||
|
|
||||||
## Layer composition
|
## 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
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Facade[ServerDirectoryFacade]
|
Facade[ServerDirectoryFacade]
|
||||||
|
Service[ServerDirectoryService]
|
||||||
State[ServerEndpointStateService]
|
State[ServerEndpointStateService]
|
||||||
API[ServerDirectoryApiService]
|
API[ServerDirectoryApiService]
|
||||||
Health[ServerEndpointHealthService]
|
Health[ServerEndpointHealthService]
|
||||||
@@ -44,17 +48,19 @@ graph TD
|
|||||||
Defaults[server-endpoint-defaults]
|
Defaults[server-endpoint-defaults]
|
||||||
Models[server-directory.models]
|
Models[server-directory.models]
|
||||||
|
|
||||||
Facade --> API
|
Facade --> Service
|
||||||
Facade --> State
|
Service --> API
|
||||||
Facade --> Health
|
Service --> State
|
||||||
Facade --> Compat
|
Service --> Health
|
||||||
|
Service --> Compat
|
||||||
API --> State
|
API --> State
|
||||||
State --> Storage
|
State --> Storage
|
||||||
State --> Defaults
|
State --> Defaults
|
||||||
Health --> Compat
|
Health --> Compat
|
||||||
|
|
||||||
click Facade "application/server-directory.facade.ts" "High-level API" _blank
|
click Facade "application/facades/server-directory.facade.ts" "Thin domain boundary" _blank
|
||||||
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state" _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 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 Health "infrastructure/server-endpoint-health.service.ts" "Health probe" _blank
|
||||||
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank
|
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank
|
||||||
@@ -87,7 +93,7 @@ stateDiagram-v2
|
|||||||
|
|
||||||
## Health probing
|
## 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
|
1. Sends `GET /api/health` with a 5-second timeout
|
||||||
2. Reads the response's `serverVersion` and stable `serverInstanceId`
|
2. Reads the response's `serverVersion` and stable `serverInstanceId`
|
||||||
|
|||||||
@@ -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<ServerDirectoryService['awaitInitialServerHealthCheck']>
|
||||||
|
): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> {
|
||||||
|
return this.service.awaitInitialServerHealthCheck(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
addServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['addServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['addServer']> {
|
||||||
|
return this.service.addServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureServerEndpoint(
|
||||||
|
...args: Parameters<ServerDirectoryService['ensureServerEndpoint']>
|
||||||
|
): ReturnType<ServerDirectoryService['ensureServerEndpoint']> {
|
||||||
|
return this.service.ensureServerEndpoint(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
findServerByUrl(
|
||||||
|
...args: Parameters<ServerDirectoryService['findServerByUrl']>
|
||||||
|
): ReturnType<ServerDirectoryService['findServerByUrl']> {
|
||||||
|
return this.service.findServerByUrl(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['removeServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['removeServer']> {
|
||||||
|
return this.service.removeServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDefaultServers(
|
||||||
|
...args: Parameters<ServerDirectoryService['restoreDefaultServers']>
|
||||||
|
): ReturnType<ServerDirectoryService['restoreDefaultServers']> {
|
||||||
|
return this.service.restoreDefaultServers(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['setActiveServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['setActiveServer']> {
|
||||||
|
return this.service.setActiveServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivateServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['deactivateServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['deactivateServer']> {
|
||||||
|
return this.service.deactivateServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServerStatus(
|
||||||
|
...args: Parameters<ServerDirectoryService['updateServerStatus']>
|
||||||
|
): ReturnType<ServerDirectoryService['updateServerStatus']> {
|
||||||
|
return this.service.updateServerStatus(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEndpointVersionCompatibility(
|
||||||
|
...args: Parameters<ServerDirectoryService['ensureEndpointVersionCompatibility']>
|
||||||
|
): ReturnType<ServerDirectoryService['ensureEndpointVersionCompatibility']> {
|
||||||
|
return this.service.ensureEndpointVersionCompatibility(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveRoomEndpoint(
|
||||||
|
...args: Parameters<ServerDirectoryService['resolveRoomEndpoint']>
|
||||||
|
): ReturnType<ServerDirectoryService['resolveRoomEndpoint']> {
|
||||||
|
return this.service.resolveRoomEndpoint(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
normaliseRoomSignalSource(
|
||||||
|
...args: Parameters<ServerDirectoryService['normaliseRoomSignalSource']>
|
||||||
|
): ReturnType<ServerDirectoryService['normaliseRoomSignalSource']> {
|
||||||
|
return this.service.normaliseRoomSignalSource(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildRoomSignalSelector(
|
||||||
|
...args: Parameters<ServerDirectoryService['buildRoomSignalSelector']>
|
||||||
|
): ReturnType<ServerDirectoryService['buildRoomSignalSelector']> {
|
||||||
|
return this.service.buildRoomSignalSelector(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFallbackRoomEndpoints(
|
||||||
|
...args: Parameters<ServerDirectoryService['getFallbackRoomEndpoints']>
|
||||||
|
): ReturnType<ServerDirectoryService['getFallbackRoomEndpoints']> {
|
||||||
|
return this.service.getFallbackRoomEndpoints(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchAllServers(
|
||||||
|
...args: Parameters<ServerDirectoryService['setSearchAllServers']>
|
||||||
|
): ReturnType<ServerDirectoryService['setSearchAllServers']> {
|
||||||
|
return this.service.setSearchAllServers(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
testServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['testServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['testServer']> {
|
||||||
|
return this.service.testServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
testAllServers(
|
||||||
|
...args: Parameters<ServerDirectoryService['testAllServers']>
|
||||||
|
): ReturnType<ServerDirectoryService['testAllServers']> {
|
||||||
|
return this.service.testAllServers(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiBaseUrl(
|
||||||
|
...args: Parameters<ServerDirectoryService['getApiBaseUrl']>
|
||||||
|
): ReturnType<ServerDirectoryService['getApiBaseUrl']> {
|
||||||
|
return this.service.getApiBaseUrl(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWebSocketUrl(
|
||||||
|
...args: Parameters<ServerDirectoryService['getWebSocketUrl']>
|
||||||
|
): ReturnType<ServerDirectoryService['getWebSocketUrl']> {
|
||||||
|
return this.service.getWebSocketUrl(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchServers(
|
||||||
|
...args: Parameters<ServerDirectoryService['searchServers']>
|
||||||
|
): ReturnType<ServerDirectoryService['searchServers']> {
|
||||||
|
return this.service.searchServers(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getServers(
|
||||||
|
...args: Parameters<ServerDirectoryService['getServers']>
|
||||||
|
): ReturnType<ServerDirectoryService['getServers']> {
|
||||||
|
return this.service.getServers(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['getServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['getServer']> {
|
||||||
|
return this.service.getServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
findServerAcrossActiveEndpoints(
|
||||||
|
...args: Parameters<ServerDirectoryService['findServerAcrossActiveEndpoints']>
|
||||||
|
): ReturnType<ServerDirectoryService['findServerAcrossActiveEndpoints']> {
|
||||||
|
return this.service.findServerAcrossActiveEndpoints(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['registerServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['registerServer']> {
|
||||||
|
return this.service.registerServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['updateServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['updateServer']> {
|
||||||
|
return this.service.updateServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterServer(
|
||||||
|
...args: Parameters<ServerDirectoryService['unregisterServer']>
|
||||||
|
): ReturnType<ServerDirectoryService['unregisterServer']> {
|
||||||
|
return this.service.unregisterServer(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerUsers(
|
||||||
|
...args: Parameters<ServerDirectoryService['getServerUsers']>
|
||||||
|
): ReturnType<ServerDirectoryService['getServerUsers']> {
|
||||||
|
return this.service.getServerUsers(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestJoin(
|
||||||
|
...args: Parameters<ServerDirectoryService['requestJoin']>
|
||||||
|
): ReturnType<ServerDirectoryService['requestJoin']> {
|
||||||
|
return this.service.requestJoin(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
createInvite(
|
||||||
|
...args: Parameters<ServerDirectoryService['createInvite']>
|
||||||
|
): ReturnType<ServerDirectoryService['createInvite']> {
|
||||||
|
return this.service.createInvite(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInvite(
|
||||||
|
...args: Parameters<ServerDirectoryService['getInvite']>
|
||||||
|
): ReturnType<ServerDirectoryService['getInvite']> {
|
||||||
|
return this.service.getInvite(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
kickServerMember(
|
||||||
|
...args: Parameters<ServerDirectoryService['kickServerMember']>
|
||||||
|
): ReturnType<ServerDirectoryService['kickServerMember']> {
|
||||||
|
return this.service.kickServerMember(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
banServerMember(
|
||||||
|
...args: Parameters<ServerDirectoryService['banServerMember']>
|
||||||
|
): ReturnType<ServerDirectoryService['banServerMember']> {
|
||||||
|
return this.service.banServerMember(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
unbanServerMember(
|
||||||
|
...args: Parameters<ServerDirectoryService['unbanServerMember']>
|
||||||
|
): ReturnType<ServerDirectoryService['unbanServerMember']> {
|
||||||
|
return this.service.unbanServerMember(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyLeave(
|
||||||
|
...args: Parameters<ServerDirectoryService['notifyLeave']>
|
||||||
|
): ReturnType<ServerDirectoryService['notifyLeave']> {
|
||||||
|
return this.service.notifyLeave(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserCount(
|
||||||
|
...args: Parameters<ServerDirectoryService['updateUserCount']>
|
||||||
|
): ReturnType<ServerDirectoryService['updateUserCount']> {
|
||||||
|
return this.service.updateUserCount(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendHeartbeat(
|
||||||
|
...args: Parameters<ServerDirectoryService['sendHeartbeat']>
|
||||||
|
): ReturnType<ServerDirectoryService['sendHeartbeat']> {
|
||||||
|
return this.service.sendHeartbeat(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,9 @@ import {
|
|||||||
type Signal
|
type Signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
|
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||||
import { User } from '../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
|
import { ServerDirectoryApiService } from '../../infrastructure/server-directory-api.service';
|
||||||
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
|
|
||||||
import type {
|
import type {
|
||||||
BanServerMemberRequest,
|
BanServerMemberRequest,
|
||||||
CreateServerInviteRequest,
|
CreateServerInviteRequest,
|
||||||
@@ -20,21 +19,19 @@ import type {
|
|||||||
ServerJoinAccessResponse,
|
ServerJoinAccessResponse,
|
||||||
ServerSourceSelector,
|
ServerSourceSelector,
|
||||||
UnbanServerMemberRequest
|
UnbanServerMemberRequest
|
||||||
} from '../domain/server-directory.models';
|
} from '../../domain/server-directory.models';
|
||||||
import {
|
import {
|
||||||
buildRoomSignalSelector,
|
buildRoomSignalSelector,
|
||||||
buildRoomSignalSource,
|
buildRoomSignalSource,
|
||||||
type RoomSignalSource,
|
type RoomSignalSource,
|
||||||
type RoomSignalSourceInput
|
type RoomSignalSourceInput
|
||||||
} from '../domain/room-signal-source';
|
} from '../../domain/room-signal-source';
|
||||||
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
|
import { ServerEndpointCompatibilityService } from '../../infrastructure/server-endpoint-compatibility.service';
|
||||||
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
|
import { ServerEndpointHealthService } from '../../infrastructure/server-endpoint-health.service';
|
||||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||||
|
|
||||||
export { CLIENT_UPDATE_REQUIRED_MESSAGE };
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ServerDirectoryFacade {
|
export class ServerDirectoryService {
|
||||||
readonly servers: Signal<ServerEndpoint[]>;
|
readonly servers: Signal<ServerEndpoint[]>;
|
||||||
readonly activeServers: Signal<ServerEndpoint[]>;
|
readonly activeServers: Signal<ServerEndpoint[]>;
|
||||||
readonly hasMissingDefaultServers: Signal<boolean>;
|
readonly hasMissingDefaultServers: Signal<boolean>;
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type Signal
|
type Signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../../environments/environment';
|
||||||
import {
|
import {
|
||||||
buildDefaultEndpointTemplates,
|
buildDefaultEndpointTemplates,
|
||||||
buildDefaultServerDefinitions,
|
buildDefaultServerDefinitions,
|
||||||
@@ -16,14 +16,14 @@ import {
|
|||||||
hasEndpointForDefault,
|
hasEndpointForDefault,
|
||||||
matchDefaultEndpointTemplate,
|
matchDefaultEndpointTemplate,
|
||||||
sanitiseServerBaseUrl
|
sanitiseServerBaseUrl
|
||||||
} from '../domain/server-endpoint-defaults';
|
} from '../../domain/server-endpoint-defaults';
|
||||||
import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service';
|
import { ServerEndpointStorageService } from '../../infrastructure/server-endpoint-storage.service';
|
||||||
import type {
|
import type {
|
||||||
ConfiguredDefaultServerDefinition,
|
ConfiguredDefaultServerDefinition,
|
||||||
DefaultEndpointTemplate,
|
DefaultEndpointTemplate,
|
||||||
ServerEndpoint,
|
ServerEndpoint,
|
||||||
ServerEndpointVersions
|
ServerEndpointVersions
|
||||||
} from '../domain/server-directory.models';
|
} from '../../domain/server-directory.models';
|
||||||
|
|
||||||
function resolveDefaultHttpProtocol(): 'http' | 'https' {
|
function resolveDefaultHttpProtocol(): 'http' | 'https' {
|
||||||
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
||||||
@@ -14,7 +14,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|||||||
import type { ServerInviteInfo } from '../../domain/server-directory.models';
|
import type { ServerInviteInfo } from '../../domain/server-directory.models';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
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';
|
import { User } from '../../../../shared-kernel';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { Room, User } from '../../../../shared-kernel';
|
|||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { type ServerInfo } from '../../domain/server-directory.models';
|
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 { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { ConfirmDialogComponent } from '../../../../shared';
|
import { ConfirmDialogComponent } from '../../../../shared';
|
||||||
import { hasRoomBanForUser } from '../../../access-control';
|
import { hasRoomBanForUser } from '../../../access-control';
|
||||||
|
|||||||
@@ -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.constants';
|
||||||
export * from './domain/server-directory.models';
|
export * from './domain/server-directory.models';
|
||||||
export * from './domain/room-signal-source';
|
export * from './domain/room-signal-source';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
RoomRoleAssignment,
|
RoomRoleAssignment,
|
||||||
User
|
User
|
||||||
} from '../../../shared-kernel';
|
} from '../../../shared-kernel';
|
||||||
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
|
import { ServerEndpointStateService } from '../application/services/server-endpoint-state.service';
|
||||||
import type {
|
import type {
|
||||||
BanServerMemberRequest,
|
BanServerMemberRequest,
|
||||||
CreateServerInviteRequest,
|
CreateServerInviteRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user