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

@@ -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' },