feat: Add notifications

This commit is contained in:
2026-03-30 04:32:24 +02:00
parent b7d4bf20e3
commit 42ac712571
32 changed files with 1974 additions and 14 deletions

View File

@@ -0,0 +1,18 @@
import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, sinceTimestamp } = query.payload;
const rows = await repo.find({
where: {
roomId,
timestamp: MoreThan(sinceTimestamp)
},
order: { timestamp: 'ASC' }
});
return rows.map(rowToMessage);
}

View File

@@ -4,6 +4,7 @@ import {
QueryTypeKey,
Query,
GetMessagesQuery,
GetMessagesSinceQuery,
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
@@ -13,6 +14,7 @@ import {
GetAttachmentsForMessageQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser';
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),

View File

@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
export const QueryType = {
GetMessages: 'get-messages',
GetMessagesSince: 'get-messages-since',
GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
@@ -160,6 +161,7 @@ export type Command =
| ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
@@ -174,6 +176,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
export type Query =
| GetMessagesQuery
| GetMessagesSinceQuery
| GetMessageByIdQuery
| GetReactionsForMessageQuery
| GetUserQuery

View File

@@ -4,6 +4,7 @@ import {
desktopCapturer,
dialog,
ipcMain,
Notification,
shell
} from 'electron';
import * as fs from 'fs';
@@ -33,6 +34,7 @@ import {
} from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
import { getMainWindow, getWindowIconPath } from '../window/create-window';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -86,6 +88,12 @@ interface ClipboardFilePayload {
path?: string;
}
interface DesktopNotificationPayload {
body: string;
requestAttention?: boolean;
title: string;
}
function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
@@ -316,6 +324,69 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
const body = typeof payload?.body === 'string' ? payload.body : '';
const mainWindow = getMainWindow();
if (!title) {
return false;
}
if (Notification.isSupported()) {
try {
const notification = new Notification({
title,
body,
icon: getWindowIconPath(),
silent: true
});
notification.on('click', () => {
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
notification.show();
} catch {
// Ignore notification center failures and still attempt taskbar attention.
}
}
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
mainWindow.flashFrame(true);
}
return true;
});
ipcMain.handle('request-window-attention', () => {
const mainWindow = getMainWindow();
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
return false;
}
mainWindow.flashFrame(true);
return true;
});
ipcMain.handle('clear-window-attention', () => {
getMainWindow()?.flashFrame(false);
return true;
});
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {

View File

@@ -5,6 +5,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monit
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
@@ -90,6 +91,17 @@ export interface DesktopUpdateState {
targetVersion: string | null;
}
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
@@ -132,6 +144,10 @@ export interface ElectronAPI {
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
@@ -213,6 +229,20 @@ const electronAPI: ElectronAPI = {
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
onWindowStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
listener(state);
};
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),

View File

@@ -10,6 +10,8 @@ import * as path from 'path';
let mainWindow: BrowserWindow | null = null;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged
? path.join(process.resourcesPath, 'images')
@@ -38,10 +40,23 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath();
@@ -109,6 +124,25 @@ export async function createWindow(): Promise<void> {
mainWindow = null;
});
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
emitWindowState();
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };

View File

@@ -97,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
"maximumError": "2.1MB"
},
{
"type": "anyComponentStyle",

View File

@@ -13,6 +13,7 @@ import { routes } from './app.routes';
import { messagesReducer } from './store/messages/messages.reducer';
import { usersReducer } from './store/users/users.reducer';
import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects';
@@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = {
rooms: roomsReducer
}),
provideEffects([
NotificationsEffects,
MessagesEffects,
MessagesSyncEffects,
UsersEffects,

View File

@@ -17,6 +17,7 @@ import { Store } from '@ngrx/store';
import { DatabaseService } from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform';
@@ -61,6 +62,7 @@ export class App implements OnInit, OnDestroy {
private databaseService = inject(DatabaseService);
private router = inject(Router);
private servers = inject(ServerDirectoryFacade);
private notifications = inject(NotificationsFacade);
private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionFacade);
@@ -84,6 +86,8 @@ export class App implements OnInit, OnDestroy {
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
await this.notifications.initialize();
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());

View File

@@ -1,6 +1,7 @@
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -105,6 +105,17 @@ export interface DesktopSettingsPatch {
vaapiVideoEncode?: boolean;
}
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
export interface ElectronCommand {
type: string;
payload: unknown;
@@ -132,6 +143,10 @@ export interface ElectronApi {
getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;

View File

@@ -1,5 +1,16 @@
import { Injectable, signal } from '@angular/core';
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
export type SettingsPage =
| 'general'
| 'network'
| 'notifications'
| 'voice'
| 'updates'
| 'debugging'
| 'server'
| 'members'
| 'bans'
| 'permissions';
@Injectable({ providedIn: 'root' })
export class SettingsModalService {

View File

@@ -11,11 +11,25 @@ infrastructure adapters and UI.
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Detailed docs
The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md)
- [auth/README.md](auth/README.md)
- [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md)
- [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md)
- [voice-session/README.md](voice-session/README.md)
## Folder convention
Every domain follows the same internal layout:

View File

@@ -0,0 +1,264 @@
# Notifications Domain
Owns desktop notification delivery, unread tracking, mute preferences, and the notifications settings UI. This domain exists separately from `store/messages` because the messages slice only keeps the currently viewed room in memory and clears when the user switches rooms, while notification state must survive navigation and be reconstructable across every saved server.
## Module map
```
notifications/
├── application/
│ ├── notifications.facade.ts Stateful domain boundary: settings, unread counts, read markers, delivery decisions
│ └── notifications.effects.ts NgRx glue reacting to room, user, and message actions
├── domain/
│ ├── notification.logic.ts Pure rules for mute checks, visibility, preview formatting, unread aggregation
│ └── notification.models.ts Settings, unread state, delivery context, and payload contracts
├── infrastructure/
│ ├── desktop-notification.service.ts Electron / browser adapter for desktop alerts and window attention
│ └── notification-settings.storage.ts localStorage persistence with defensive deserialisation
├── feature/
│ └── settings/
│ ├── notifications-settings.component.ts
│ └── notifications-settings.component.html
└── index.ts Barrel exports
```
## Service relationships
```mermaid
graph TD
App[App]
Effects[NotificationsEffects]
Settings[NotificationsSettingsComponent]
Rail[ServersRailComponent]
Sidebar[RoomsSidePanelComponent]
Facade[NotificationsFacade]
Logic[notification.logic]
Storage[NotificationSettingsStorageService]
DB[DatabaseService]
Desktop[DesktopNotificationService]
Audio[NotificationAudioService]
Store[NgRx Store]
App --> Facade
Effects --> Facade
Effects --> Store
Settings --> Facade
Rail --> Facade
Sidebar --> Facade
Facade --> Logic
Facade --> Storage
Facade --> DB
Facade --> Desktop
Facade --> Audio
click Facade "application/notifications.facade.ts" "Stateful domain boundary" _blank
click Effects "application/notifications.effects.ts" "NgRx glue" _blank
click Logic "domain/notification.logic.ts" "Pure notification rules" _blank
click Storage "infrastructure/notification-settings.storage.ts" "localStorage persistence" _blank
click Desktop "infrastructure/desktop-notification.service.ts" "Desktop notification adapter" _blank
click Settings "feature/settings/notifications-settings.component.ts" "Notifications settings UI" _blank
click DB "../../infrastructure/persistence/database.service.ts" "Persistence facade" _blank
```
## Runtime wiring
The domain has two runtime entry points:
- `NotificationsFacade` is injected directly by app bootstrapping and feature components.
- `NotificationsEffects` is registered globally in `provideEffects(...)` and forwards store actions into the facade.
All effects in this domain are `dispatch: false`. The effect layer never owns notification business rules; it only connects NgRx actions to `NotificationsFacade`.
| Entry point | Trigger | Responsibility |
|---|---|---|
| `NotificationsFacade.initialize()` | `App.ngOnInit()` | Loads persisted settings, installs focus/visibility listeners, syncs the room catalog, and marks the active channel read if it is visible |
| `syncRoomCatalog$` | Room lifecycle actions such as load, join, view, add/remove channel | Reconciles muted room/channel maps and prunes unread state for missing channels |
| `hydrateUnreadCounts$` | `loadRoomsSuccess` and `loadCurrentUserSuccess` | Rebuilds unread counts from persisted messages once both rooms and user identity exist |
| `markVisibleChannelRead$` | Room activation and channel selection | Clears unread for the current visible channel |
| `handleIncomingMessage$` | `MessagesActions.receiveMessage` | Updates unread counts and triggers desktop delivery for live messages |
| `refreshCurrentRoomUnread$` | `loadMessagesSuccess` and `syncMessages` | Recomputes unread counts for the active room from the latest message snapshot |
## Incoming message flow
```mermaid
sequenceDiagram
participant Msg as MessagesActions.receiveMessage
participant Fx as NotificationsEffects
participant Facade as NotificationsFacade
participant Logic as notification.logic
participant Desktop as DesktopNotificationService
participant Audio as NotificationAudioService
Msg->>Fx: receiveMessage(message)
Fx->>Facade: handleIncomingMessage(message)
Facade->>Facade: ignore duplicate / own message
Facade->>Facade: ensure room baseline exists
alt deleted message
Facade-->>Fx: stop
else visible active channel
Facade->>Facade: markChannelRead(roomId, channelId)
Facade-->>Fx: stop
else hidden or backgrounded
Facade->>Facade: incrementUnread(roomId, channelId)
Facade->>Logic: shouldDeliverNotification(...)
alt delivery allowed
Facade->>Logic: buildNotificationDisplayPayload(...)
opt Windows
Facade->>Audio: play(Notification)
end
Facade->>Desktop: showNotification(payload)
else muted / busy / disabled
Facade-->>Fx: unread only
end
end
```
## Read model and unread tracking
Unread state is modeled as a combination of persisted read markers plus in-memory derived counts.
| Field | Stored in | Meaning |
|---|---|---|
| `roomBaselines[roomId]` | localStorage | Lower bound for unread reconstruction in a room |
| `lastReadByChannel[roomId][channelId]` | localStorage | Latest timestamp the user has acknowledged for a text channel |
| `roomCounts[roomId]` | in memory | Sum of unread text messages across the room |
| `channelCounts[roomId][channelId]` | in memory | Per-text-channel unread count |
Important design constraint: unread counters are intentionally not persisted. The persisted state stores only the user-controlled settings and the read markers needed to derive unread counts again.
### Why baselines exist
The domain must avoid marking an entire historical backlog as unread the first time a room appears.
- When `syncRoomCatalog()` sees a room for the first time, its baseline is set to `Date.now()`. Old stored messages stay treated as historical backlog.
- When a live message arrives before the room has been catalogued, `ensureRoomTracking()` uses `message.timestamp - 1` so that the current live message still counts as unread.
### Channel scope
Only text channels participate in unread tracking. If a room has no text-channel metadata yet, the domain falls back to `DEFAULT_TEXT_CHANNEL_ID = "general"` so delivery and unread bookkeeping can still proceed.
### Rebuilding unread counts
`hydrateUnreadCounts()` reconstructs unread state from persistence rather than trusting stale counters.
1. Determine the room baseline with `getRoomTrackingBaseline(...)`.
2. Load messages newer than that point through `DatabaseService.getMessagesSince(roomId, sinceTimestamp)`.
3. Run `calculateUnreadForRoom(...)`.
4. Ignore deleted messages and messages sent by the current user.
5. Count only text-channel messages whose timestamp is newer than the channel's last-read marker.
The persistence backend differs by runtime, but the contract is the same:
- Browser: `BrowserDatabaseService.getMessagesSince(...)` filters IndexedDB room messages by timestamp and returns them oldest-first.
- Electron: `ElectronDatabaseService.getMessagesSince(...)` sends a `get-messages-since` CQRS query; the main process uses TypeORM to return rows ordered by ascending timestamp.
### Clearing unread state
Unread is cleared by channel, not globally.
- `markCurrentChannelReadIfActive()` only runs when the window is focused and the document is visible.
- If a live message arrives in the currently visible text channel, the domain immediately marks that channel read instead of incrementing unread.
- Window focus and document visibility changes both clear taskbar attention and mark the active channel read.
- `pruneUnreadState()` removes deleted or non-text channels from the unread maps and re-derives room totals from channel totals.
## Delivery rules
The pure rules live in `domain/notification.logic.ts`; the facade applies them to live message events.
| Rule | Effect |
|---|---|
| `enabled === false` | Suppresses desktop delivery everywhere, but unread badges still update |
| `respectBusyStatus === true` and `currentUser.status === "busy"` | Suppresses desktop delivery |
| Room muted | Suppresses desktop delivery for the whole server |
| Channel muted | Suppresses desktop delivery for that text channel |
| Message already visible in the active room + channel while focused | No desktop alert; mark read immediately |
| `showPreview === false` | Notification body becomes a generic "sender sent a new message" message |
Additional runtime guards:
- Deleted messages never notify.
- The current user's own messages never notify.
- Duplicate live events are suppressed with a rolling in-memory set of the last 500 notified message IDs.
- Unread badges are independent from mute state. Muting changes delivery only; it does not hide unread indicators.
## Desktop delivery and platform adapters
`DesktopNotificationService` is the only infrastructure service that actually shows a system notification.
### Renderer side
- Prefer the Electron bridge if `showDesktopNotification` is available.
- Fall back to the browser `Notification` API when running outside Electron.
- Request browser notification permission lazily on first use.
- `clearAttention()` delegates to the Electron bridge when available.
### Main-process behavior
The Electron main process handles the actual desktop notification and window-attention behavior.
- `show-desktop-notification` creates a system `Notification` with the window icon when supported.
- Notification clicks restore, show, and focus the main window.
- `request-window-attention` flashes the taskbar entry directly when Electron is minimized or otherwise backgrounded and actionable unread exists.
- `show-desktop-notification` can still request attention for live toast delivery.
- `clear-window-attention` and the window `focus` event both call `flashFrame(false)`.
### Platform-specific policy
- Windows: the facade also plays `AppSound.Notification` before showing the desktop notification.
- Linux: desktop alerts are expected to surface through the system notification center, with window attention requested when the app is backgrounded.
- macOS and browser-only builds use the same desktop notification adapter, but there is no extra renderer-side sound policy in this domain.
Taskbar attention is synchronized from the unread model, not only from live toast delivery. If the Electron app is minimized or otherwise backgrounded and there is at least one actionable unread message in an unmuted room or channel, the taskbar entry is asked to flash until focus is restored or the actionable unread state clears.
## Settings persistence
Notification settings are stored as a single JSON document under `metoyou_notification_settings`.
| Setting | Purpose |
|---|---|
| `enabled` | Master switch for desktop delivery |
| `showPreview` | Includes or suppresses message text previews |
| `respectBusyStatus` | Suppresses alerts while the current user is busy |
| `mutedRooms` | Per-room mute flags |
| `mutedChannels` | Per-room per-channel mute flags |
| `roomBaselines` | Per-room unread reconstruction lower bounds |
| `lastReadByChannel` | Per-channel read markers |
`NotificationSettingsStorageService` defensively normalises every nested boolean and number map on load. Invalid or partially corrupted JSON falls back to safe defaults instead of throwing.
## Feature surfaces
The domain owns the settings UI, but app-level features consume the facade for presentation.
- `NotificationsSettingsComponent` renders the dedicated Notifications tab in the settings modal.
- `ServersRailComponent` uses the facade for server unread badges and server-level mute / unmute context-menu actions.
- `RoomsSidePanelComponent` uses the facade for channel unread badges and channel-level mute / unmute context-menu actions.
Those surfaces all depend on the same facade state, so toggles in the settings panel and right-click menus stay in sync.
## Public boundary
`index.ts` exports:
- `NotificationsFacade`
- `NotificationsEffects`
- `NotificationsSettingsComponent`
Outside the domain, import from the domain barrel rather than internal files unless Angular static analysis requires a direct component import in standalone component metadata.
## Extension guidance
When extending this domain:
1. Put pure predicates and formatting rules in `domain/notification.logic.ts`.
2. Keep `NotificationsFacade` as the only stateful application boundary for unread counts and settings mutations.
3. Add new store-driven reactions in `notifications.effects.ts`, not in feature components.
4. Persist new user-controlled notification state in `notification-settings.storage.ts` and normalise it on load.
5. Do not persist unread counters. Persist the markers needed to derive them.
6. Keep Electron-specific APIs behind `DesktopNotificationService` and the existing bridge contract.

View File

@@ -0,0 +1,108 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
filter,
tap,
withLatestFrom
} from 'rxjs/operators';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { NotificationsFacade } from './notifications.facade';
@Injectable()
export class NotificationsEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly notifications = inject(NotificationsFacade);
syncRoomCatalog$ = createEffect(
() =>
this.actions$.pipe(
ofType(
RoomsActions.loadRoomsSuccess,
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess,
RoomsActions.updateRoom,
RoomsActions.addChannel,
RoomsActions.removeChannel,
RoomsActions.renameChannel,
RoomsActions.forgetRoomSuccess,
RoomsActions.deleteRoomSuccess
),
withLatestFrom(this.store.select(selectSavedRooms)),
tap(([, rooms]) => {
this.notifications.syncRoomCatalog(rooms);
})
),
{ dispatch: false }
);
hydrateUnreadCounts$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.loadRoomsSuccess, UsersActions.loadCurrentUserSuccess),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectSavedRooms)
),
filter(([, currentUser]) => !!currentUser),
tap(([
, , rooms
]) => {
void this.notifications.hydrateUnreadCounts(rooms);
})
),
{ dispatch: false }
);
markVisibleChannelRead$ = createEffect(
() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess,
RoomsActions.selectChannel
),
tap(() => {
this.notifications.markCurrentChannelReadIfActive();
})
),
{ dispatch: false }
);
handleIncomingMessage$ = createEffect(
() =>
this.actions$.pipe(
ofType(MessagesActions.receiveMessage),
tap(({ message }) => {
void this.notifications.handleIncomingMessage(message);
})
),
{ dispatch: false }
);
refreshCurrentRoomUnread$ = createEffect(
() =>
this.actions$.pipe(
ofType(MessagesActions.loadMessagesSuccess, MessagesActions.syncMessages),
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([{ messages }, room]) => {
if (room) {
this.notifications.refreshRoomUnreadFromMessages(room.id, messages);
}
})
),
{ dispatch: false }
);
}

View File

@@ -0,0 +1,599 @@
/* 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.models';
import { DesktopNotificationService } from '../infrastructure/desktop-notification.service';
import { NotificationSettingsStorageService } from '../infrastructure/notification-settings.storage';
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.platformKind === 'windows') {
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 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';
}

View File

@@ -0,0 +1,158 @@
import type { Message, Room } from '../../../shared-kernel';
import type {
NotificationDeliveryContext,
NotificationDisplayPayload,
NotificationsSettings,
RoomUnreadCounts
} from './notification.models';
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}`;
}

View File

@@ -0,0 +1,64 @@
import type {
Message,
Room,
User
} from '../../../shared-kernel';
export interface NotificationsSettings {
enabled: boolean;
showPreview: boolean;
respectBusyStatus: boolean;
mutedRooms: Record<string, boolean>;
mutedChannels: Record<string, Record<string, boolean>>;
roomBaselines: Record<string, number>;
lastReadByChannel: Record<string, Record<string, number>>;
}
export interface NotificationsUnreadState {
roomCounts: Record<string, number>;
channelCounts: Record<string, Record<string, number>>;
}
export interface NotificationDeliveryContext {
activeChannelId: string | null;
currentRoomId: string | null;
currentUser: User | null;
isDocumentVisible: boolean;
isWindowFocused: boolean;
rooms: Room[];
}
export interface NotificationDisplayPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface RoomUnreadCounts {
channelCounts: Record<string, number>;
roomCount: number;
}
export function createDefaultNotificationSettings(): NotificationsSettings {
return {
enabled: true,
showPreview: true,
respectBusyStatus: true,
mutedRooms: {},
mutedChannels: {},
roomBaselines: {},
lastReadByChannel: {}
};
}
export function createEmptyUnreadState(): NotificationsUnreadState {
return {
roomCounts: {},
channelCounts: {}
};
}
export type NotificationMessage = Pick<
Message,
'channelId' | 'content' | 'id' | 'isDeleted' | 'roomId' | 'senderId' | 'senderName' | 'timestamp'
>;

View File

@@ -0,0 +1,186 @@
<div class="space-y-6">
<section class="rounded-xl border border-border bg-secondary/20 p-5">
<div class="flex items-start gap-3">
<div class="rounded-xl bg-primary/10 p-2 text-primary">
<ng-icon
name="lucideBell"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<h4 class="text-base font-semibold text-foreground">Delivery</h4>
<p class="mt-1 text-sm text-muted-foreground">
Desktop alerts use the system notification center on Linux and request taskbar attention when the app is not focused. Windows also plays the
configured notification sound.
</p>
</div>
</div>
<div class="mt-5 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-medium text-foreground">Enable notifications</p>
<p class="text-sm text-muted-foreground">Mute every server and channel notification without affecting unread indicators.</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
[checked]="enabled()"
(change)="onNotificationsEnabledChange($event)"
/>
<div
class="h-6 w-11 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full"
></div>
</label>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-start gap-3">
<ng-icon
name="lucideMessageSquareText"
class="mt-0.5 h-4 w-4 text-muted-foreground"
/>
<div>
<p class="font-medium text-foreground">Show message preview</p>
<p class="text-sm text-muted-foreground">Include a short message preview in desktop notifications when content privacy allows it.</p>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
[checked]="showPreview()"
(change)="onShowPreviewChange($event)"
/>
<div
class="h-6 w-11 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full"
></div>
</label>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex items-start gap-3">
<ng-icon
name="lucideMoonStar"
class="mt-0.5 h-4 w-4 text-muted-foreground"
/>
<div>
<p class="font-medium text-foreground">Respect busy status</p>
<p class="text-sm text-muted-foreground">Suppress desktop alerts while your user presence is set to busy.</p>
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
[checked]="respectBusyStatus()"
(change)="onRespectBusyChange($event)"
/>
<div
class="h-6 w-11 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full"
></div>
</label>
</div>
</div>
</section>
<section class="rounded-xl border border-border bg-card p-5">
<div class="flex items-start gap-3">
<div class="rounded-xl bg-secondary p-2 text-muted-foreground">
<ng-icon
[name]="enabled() ? 'lucideBell' : 'lucideBellOff'"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<h4 class="text-base font-semibold text-foreground">Server Overrides</h4>
<p class="mt-1 text-sm text-muted-foreground">
Right-click actions mirror these switches. Muted servers and channels still collect unread badges so you can catch up later.
</p>
</div>
</div>
@if (rooms().length === 0) {
<div class="mt-5 rounded-lg border border-dashed border-border bg-secondary/20 p-4 text-sm text-muted-foreground">
Join a server to configure notification overrides.
</div>
} @else {
<div class="mt-5 space-y-4">
@for (room of rooms(); track trackRoom($index, room)) {
<article class="rounded-lg border border-border bg-secondary/15 p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-medium text-foreground">{{ room.name }}</p>
@if (roomUnreadCount(room.id) > 0) {
<span class="rounded-full bg-amber-400/20 px-2 py-0.5 text-[11px] font-semibold text-amber-300">
{{ formatUnreadCount(roomUnreadCount(room.id)) }} unread
</span>
}
</div>
<p class="mt-1 text-sm text-muted-foreground">{{ room.description || 'Notifications for every text channel in this server.' }}</p>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
[checked]="isRoomEnabled(room.id)"
(change)="onRoomMutedChange(room.id, $event)"
/>
<div
class="h-6 w-11 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full"
></div>
</label>
</div>
<div class="mt-4 rounded-lg border border-border/70 bg-background/50 p-3">
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Channels</p>
<p class="text-xs text-muted-foreground">Unread badges remain visible even if muted.</p>
</div>
<div class="space-y-2">
@for (channel of textChannels(room); track channel.id) {
<div class="flex items-center justify-between gap-3 rounded-lg px-2 py-2 hover:bg-secondary/40">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-muted-foreground">#</span>
<span class="truncate text-sm text-foreground">{{ channel.name }}</span>
@if (channelUnreadCount(room.id, channel.id) > 0) {
<span class="rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
{{ formatUnreadCount(channelUnreadCount(room.id, channel.id)) }}
</span>
}
</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
class="peer sr-only"
[checked]="isChannelEnabled(room.id, channel.id)"
(change)="onChannelMutedChange(room.id, channel.id, $event)"
/>
<div
class="h-5 w-9 rounded-full bg-secondary transition-colors peer-checked:bg-primary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full"
></div>
</label>
</div>
}
</div>
</div>
</article>
}
</div>
}
</section>
</div>

View File

@@ -0,0 +1,107 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideBell,
lucideBellOff,
lucideMessageSquareText,
lucideMoonStar
} from '@ng-icons/lucide';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { Room } from '../../../../shared-kernel';
import { NotificationsFacade } from '../../application/notifications.facade';
@Component({
selector: 'app-notifications-settings',
standalone: true,
imports: [
CommonModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideBell,
lucideBellOff,
lucideMessageSquareText,
lucideMoonStar
})
],
templateUrl: './notifications-settings.component.html'
})
export class NotificationsSettingsComponent {
private readonly store = inject(Store);
readonly notifications = inject(NotificationsFacade);
readonly rooms = this.store.selectSignal(selectSavedRooms);
readonly settings = this.notifications.settings;
readonly enabled = computed(() => this.settings().enabled);
readonly showPreview = computed(() => this.settings().showPreview);
readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus);
trackRoom = (_index: number, room: Room) => room.id;
textChannels(room: Room) {
const channels = (room.channels ?? [])
.filter((channel) => channel.type === 'text')
.sort((channelA, channelB) => channelA.position - channelB.position);
return channels.length > 0
? channels
: [{ id: 'general',
name: 'general',
type: 'text',
position: 0 }];
}
onNotificationsEnabledChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.notifications.setNotificationsEnabled(!!input.checked);
}
onShowPreviewChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.notifications.setShowPreview(!!input.checked);
}
onRespectBusyChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.notifications.setRespectBusyStatus(!!input.checked);
}
onRoomMutedChange(roomId: string, event: Event): void {
const input = event.target as HTMLInputElement;
this.notifications.setRoomMuted(roomId, !input.checked);
}
onChannelMutedChange(roomId: string, channelId: string, event: Event): void {
const input = event.target as HTMLInputElement;
this.notifications.setChannelMuted(roomId, channelId, !input.checked);
}
isRoomEnabled(roomId: string): boolean {
return !this.notifications.isRoomMuted(roomId);
}
isChannelEnabled(roomId: string, channelId: string): boolean {
return !this.notifications.isChannelMuted(roomId, channelId);
}
roomUnreadCount(roomId: string): number {
return this.notifications.roomUnreadCount(roomId);
}
channelUnreadCount(roomId: string, channelId: string): number {
return this.notifications.channelUnreadCount(roomId, channelId);
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
}

View File

@@ -0,0 +1,3 @@
export * from './application/notifications.facade';
export * from './application/notifications.effects';
export { NotificationsSettingsComponent } from './feature/settings/notifications-settings.component';

View File

@@ -0,0 +1,64 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { WindowStateSnapshot } from '../../../core/platform/electron/electron-api.models';
import { PlatformService } from '../../../core/platform';
import type { NotificationDisplayPayload } from '../domain/notification.models';
@Injectable({ providedIn: 'root' })
export class DesktopNotificationService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly platform = inject(PlatformService);
async showNotification(payload: NotificationDisplayPayload): Promise<void> {
const api = this.electronBridge.getApi();
if (api?.showDesktopNotification) {
await api.showDesktopNotification(payload);
return;
}
if (!this.platform.isBrowser || typeof Notification === 'undefined') {
return;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
return;
}
const notification = new Notification(payload.title, { body: payload.body });
notification.onclick = () => window.focus();
}
async requestAttention(): Promise<void> {
const api = this.electronBridge.getApi();
if (api?.requestWindowAttention) {
await api.requestWindowAttention();
}
}
async clearAttention(): Promise<void> {
const api = this.electronBridge.getApi();
if (api?.clearWindowAttention) {
await api.clearWindowAttention();
}
}
onWindowStateChanged(listener: (state: WindowStateSnapshot) => void): (() => void) | null {
const api = this.electronBridge.getApi();
if (!api?.onWindowStateChanged) {
return null;
}
return api.onWindowStateChanged(listener);
}
}

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
import { STORAGE_KEY_NOTIFICATION_SETTINGS } from '../../../core/constants';
import { createDefaultNotificationSettings, type NotificationsSettings } from '../domain/notification.models';
@Injectable({ providedIn: 'root' })
export class NotificationSettingsStorageService {
load(): NotificationsSettings {
const fallback = createDefaultNotificationSettings();
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_SETTINGS);
if (!raw) {
return fallback;
}
try {
const parsed = JSON.parse(raw) as Partial<NotificationsSettings>;
return {
enabled: parsed.enabled ?? fallback.enabled,
showPreview: parsed.showPreview ?? fallback.showPreview,
respectBusyStatus: parsed.respectBusyStatus ?? fallback.respectBusyStatus,
mutedRooms: normaliseBooleanRecord(parsed.mutedRooms),
mutedChannels: normaliseNestedBooleanRecord(parsed.mutedChannels),
roomBaselines: normaliseNumberRecord(parsed.roomBaselines),
lastReadByChannel: normaliseNestedNumberRecord(parsed.lastReadByChannel)
};
} catch {
return fallback;
}
}
save(settings: NotificationsSettings): void {
localStorage.setItem(STORAGE_KEY_NOTIFICATION_SETTINGS, JSON.stringify(settings));
}
}
function normaliseBooleanRecord(value: unknown): Record<string, boolean> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')
);
}
function normaliseNumberRecord(value: unknown): Record<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]))
);
}
function normaliseNestedBooleanRecord(value: unknown): Record<string, Record<string, boolean>> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.map(([roomId, channels]) => [roomId, normaliseBooleanRecord(channels)])
);
}
function normaliseNestedNumberRecord(value: unknown): Record<string, Record<string, number>> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.map(([roomId, channels]) => [roomId, normaliseNumberRecord(channels)])
);
}

View File

@@ -87,7 +87,13 @@
(click)="$event.stopPropagation()"
/>
} @else {
<span class="truncate">{{ ch.name }}</span>
<span class="flex-1 truncate">{{ ch.name }}</span>
}
@if (channelUnreadCount(ch.id) > 0) {
<span class="ml-auto rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
{{ formatUnreadCount(channelUnreadCount(ch.id)) }}
</span>
}
</button>
}
@@ -407,6 +413,14 @@
>
Resync Messages
</button>
@if (contextChannel()?.type === 'text') {
<button
(click)="toggleChannelNotifications()"
class="context-menu-item"
>
{{ isContextChannelMuted() ? 'Unmute Notifications' : 'Mute Notifications' }}
</button>
}
@if (canManageChannels()) {
<div class="context-menu-divider"></div>
<button

View File

@@ -37,6 +37,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { NotificationsFacade } from '../../../domains/notifications';
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { VoicePlaybackService } from '../../../domains/voice-connection/application/voice-playback.service';
@@ -93,6 +94,7 @@ export class RoomsSidePanelComponent {
private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
private notifications = inject(NotificationsFacade);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
@@ -303,6 +305,40 @@ export class RoomsSidePanelComponent {
}
}
toggleChannelNotifications(): void {
const channel = this.contextChannel();
const roomId = this.currentRoom()?.id;
this.closeChannelMenu();
if (!channel || channel.type !== 'text' || !roomId) {
return;
}
this.notifications.setChannelMuted(
roomId,
channel.id,
!this.notifications.isChannelMuted(roomId, channel.id)
);
}
isContextChannelMuted(): boolean {
const channel = this.contextChannel();
const roomId = this.currentRoom()?.id;
return !!channel && channel.type === 'text' && !!roomId && this.notifications.isChannelMuted(roomId, channel.id);
}
channelUnreadCount(channelId: string): number {
const roomId = this.currentRoom()?.id;
return roomId ? this.notifications.channelUnreadCount(roomId, channelId) : 0;
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
resyncMessages() {
this.closeChannelMenu();
const room = this.currentRoom();

View File

@@ -17,21 +17,29 @@
@for (room of visibleSavedRooms(); track room.id) {
<button
type="button"
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
class="relative w-10 h-10 flex-shrink-0 rounded-2xl border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[title]="room.name"
(click)="joinSavedRoom(room)"
(contextmenu)="openContextMenu($event, room)"
>
@if (room.icon) {
<img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
/>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
</div>
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (room.icon) {
<img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
/>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
</div>
}
</div>
@if (roomUnreadCount(room.id) > 0) {
<span class="absolute -right-1 -top-1 min-w-5 rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black shadow-sm">
{{ formatUnreadCount(roomUnreadCount(room.id)) }}
</span>
}
</button>
}
@@ -46,6 +54,14 @@
(closed)="closeMenu()"
[width]="'w-44'"
>
<button
type="button"
(click)="toggleRoomNotifications()"
class="context-menu-item"
>
{{ isRoomNotificationsMuted(contextRoom()?.id || '') ? 'Unmute Notifications' : 'Mute Notifications' }}
</button>
<div class="context-menu-divider"></div>
<button
type="button"
(click)="openLeaveConfirm()"

View File

@@ -21,6 +21,7 @@ import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.sel
import { selectCurrentUser } from '../../store/users/users.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../infrastructure/persistence';
import { NotificationsFacade } from '../../domains/notifications';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import {
@@ -50,6 +51,7 @@ export class ServersRailComponent {
private voiceSession = inject(VoiceSessionFacade);
private webrtc = inject(RealtimeSessionFacade);
private db = inject(DatabaseService);
private notifications = inject(NotificationsFacade);
private serverDirectory = inject(ServerDirectoryFacade);
private banLookupRequestVersion = 0;
savedRooms = this.store.selectSignal(selectSavedRooms);
@@ -208,6 +210,29 @@ export class ServersRailComponent {
this.showLeaveConfirm.set(false);
}
toggleRoomNotifications(): void {
const room = this.contextRoom();
if (!room) {
return;
}
this.notifications.setRoomMuted(room.id, !this.notifications.isRoomMuted(room.id));
this.closeMenu();
}
isRoomNotificationsMuted(roomId: string): boolean {
return this.notifications.isRoomMuted(roomId);
}
roomUnreadCount(roomId: string): number {
return this.notifications.roomUnreadCount(roomId);
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;

View File

@@ -122,6 +122,9 @@
@case ('network') {
Network
}
@case ('notifications') {
Notifications
}
@case ('voice') {
Voice & Audio
}
@@ -166,6 +169,9 @@
@case ('network') {
<app-network-settings />
}
@case ('notifications') {
<app-notifications-settings />
}
@case ('voice') {
<app-voice-settings />
}

View File

@@ -15,6 +15,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideX,
lucideBug,
lucideBell,
lucideDownload,
lucideGlobe,
lucideAudioLines,
@@ -30,6 +31,7 @@ import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room, UserRole } from '../../../shared-kernel';
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { NotificationsSettingsComponent } from '../../../domains/notifications/feature/settings/notifications-settings.component';
import { GeneralSettingsComponent } from './general-settings/general-settings.component';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
@@ -51,6 +53,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
NgIcon,
GeneralSettingsComponent,
NetworkSettingsComponent,
NotificationsSettingsComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
DebuggingSettingsComponent,
@@ -63,6 +66,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
provideIcons({
lucideX,
lucideBug,
lucideBell,
lucideDownload,
lucideGlobe,
lucideAudioLines,
@@ -97,6 +101,9 @@ export class SettingsModalComponent {
{ id: 'network',
label: 'Network',
icon: 'lucideGlobe' },
{ id: 'notifications',
label: 'Notifications',
icon: 'lucideBell' },
{ id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' },

View File

@@ -75,6 +75,17 @@ export class BrowserDatabaseService {
.map((message) => this.normaliseMessage(message));
}
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId
);
return allRoomMessages
.filter((message) => message.timestamp > sinceTimestamp)
.sort((first, second) => first.timestamp - second.timestamp)
.map((message) => this.normaliseMessage(message));
}
/** Delete a message by its ID. */
async deleteMessage(messageId: string): Promise<void> {
await this.deleteRecord(STORE_MESSAGES, messageId);

View File

@@ -52,6 +52,9 @@ export class DatabaseService {
/** Retrieve messages for a room with optional pagination. */
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
/** Retrieve messages newer than a given timestamp for a room. */
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); }
/** Permanently delete a message by ID. */
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }

View File

@@ -47,6 +47,10 @@ export class ElectronDatabaseService {
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } });
}
getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
return this.api.query<Message[]>({ type: 'get-messages-since', payload: { roomId, sinceTimestamp } });
}
/** Permanently delete a message by ID. */
deleteMessage(messageId: string): Promise<void> {
return this.api.command({ type: 'delete-message', payload: { messageId } });