feat: Add pm

This commit is contained in:
2026-04-27 00:45:16 +02:00
parent bc2fa7de22
commit 11c2588e45
65 changed files with 3653 additions and 214 deletions

View File

@@ -301,12 +301,14 @@
@for (user of onlineRoomUsers(); track user.id) {
<div
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50 cursor-pointer"
[attr.data-testid]="'room-user-card-' + (user.oderId || user.id)"
role="button"
tabindex="0"
(contextmenu)="openUserContextMenu($event, user)"
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
(keydown.enter)="openProfileCard($event, user, false); $event.stopPropagation()"
(keydown.space)="openProfileCard($event, user, false); $event.preventDefault(); $event.stopPropagation()"
(click)="openUserCard($event, user)"
(dblclick)="openDirectMessage($event, user)"
(keydown.enter)="openUserCard($event, user)"
(keydown.space)="openUserCard($event, user); $event.preventDefault()"
>
<app-user-avatar
[name]="user.displayName"
@@ -350,6 +352,19 @@
}
</div>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-card hover:text-foreground group-hover/user:opacity-100 focus:opacity-100"
title="Message"
[attr.aria-label]="'Message ' + user.displayName"
(click)="openDirectMessage($event, user)"
(dblclick)="$event.stopPropagation()"
>
<ng-icon
name="lucideMessageSquare"
class="h-4 w-4"
/>
</button>
</div>
}
</div>
@@ -363,12 +378,14 @@
<div class="space-y-1">
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
class="group/user flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
[attr.data-testid]="'room-user-card-' + (member.oderId || member.id)"
role="button"
tabindex="0"
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
(keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
(keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
(click)="openMemberCard($event, member)"
(dblclick)="openDirectMessageForMember($event, member)"
(keydown.enter)="openMemberCard($event, member)"
(keydown.space)="openMemberCard($event, member); $event.preventDefault()"
>
<app-user-avatar
[name]="member.displayName"
@@ -390,6 +407,19 @@
</div>
<p class="text-[10px] text-muted-foreground">Offline</p>
</div>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-card hover:text-foreground group-hover/user:opacity-100 focus:opacity-100"
title="Message"
[attr.aria-label]="'Message ' + member.displayName"
(click)="openDirectMessageForMember($event, member)"
(dblclick)="$event.stopPropagation()"
>
<ng-icon
name="lucideMessageSquare"
class="h-4 w-4"
/>
</button>
</div>
}
</div>

View File

@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -42,6 +43,7 @@ import {
VoiceConnectivityHealthService
} from '../../../domains/voice-connection';
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message';
import { VoicePlaybackService } from '../../../domains/voice-connection';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
@@ -101,6 +103,7 @@ type PanelMode = 'channels' | 'users';
})
export class RoomsSidePanelComponent {
private store = inject(Store);
private router = inject(Router);
private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
@@ -109,8 +112,10 @@ export class RoomsSidePanelComponent {
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true);
@@ -201,8 +206,41 @@ export class RoomsSidePanelComponent {
this.profileCard.open(el, user, { placement: 'left', editable });
}
openUserCard(event: Event, user: User): void {
event.stopPropagation();
this.queueProfileCardOpen(event.currentTarget as HTMLElement, user, false);
}
openProfileCardForMember(event: Event, member: RoomMember): void {
const user: User = {
const user = this.roomMemberToUser(member);
this.openProfileCard(event, user, false);
}
openMemberCard(event: Event, member: RoomMember): void {
event.stopPropagation();
this.queueProfileCardOpen(event.currentTarget as HTMLElement, this.roomMemberToUser(member), false);
}
async openDirectMessage(event: Event, user: User): Promise<void> {
event.stopPropagation();
this.cancelQueuedProfileCardOpen();
if (this.isCurrentUserIdentity(user)) {
return;
}
const conversation = await this.directMessages.createConversation(user);
await this.router.navigate(['/dm', conversation.id]);
}
async openDirectMessageForMember(event: Event, member: RoomMember): Promise<void> {
await this.openDirectMessage(event, this.roomMemberToUser(member));
}
private roomMemberToUser(member: RoomMember): User {
return {
id: member.id,
oderId: member.oderId || member.id,
username: member.username,
@@ -210,12 +248,13 @@ export class RoomsSidePanelComponent {
description: member.description,
profileUpdatedAt: member.profileUpdatedAt,
avatarUrl: member.avatarUrl,
avatarHash: member.avatarHash,
avatarMime: member.avatarMime,
avatarUpdatedAt: member.avatarUpdatedAt,
status: 'disconnected',
role: member.role,
joinedAt: member.joinedAt
};
this.openProfileCard(event, user, false);
}
private roomMemberKey(member: RoomMember): string {
@@ -256,6 +295,23 @@ export class RoomsSidePanelComponent {
);
}
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
this.cancelQueuedProfileCardOpen();
this.profileCardOpenTimer = setTimeout(() => {
this.profileCardOpenTimer = null;
this.profileCard.open(anchor, user, { placement: 'left', editable });
}, 180);
}
private cancelQueuedProfileCardOpen(): void {
if (!this.profileCardOpenTimer) {
return;
}
clearTimeout(this.profileCardOpenTimer);
this.profileCardOpenTimer = null;
}
hasConnectivityIssue(user: User): boolean {
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
}

View File

@@ -12,6 +12,10 @@
/>
</button>
@if (dmRailComponent()) {
<ng-container *ngComponentOutlet="dmRailComponent()" />
}
<!-- Saved servers icons -->
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5">
@for (room of visibleSavedRooms(); track room.id) {

View File

@@ -2,6 +2,7 @@
import {
Component,
DestroyRef,
Type,
computed,
effect,
inject,
@@ -72,6 +73,7 @@ export class ServersRailComponent {
currentRoom = this.store.selectSignal(selectCurrentRoom);
showMenu = signal(false);
dmRailComponent = signal<Type<unknown> | null>(null);
menuX = signal(72);
menuY = signal(100);
contextRoom = signal<Room | null>(null);
@@ -86,6 +88,13 @@ export class ServersRailComponent {
),
{ initialValue: this.router.url.startsWith('/search') }
);
isOnDirectMessage = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/'))
),
{ initialValue: this.router.url.startsWith('/dm/') }
);
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
@@ -135,6 +144,10 @@ export class ServersRailComponent {
});
constructor() {
void import('../../../domains/direct-message/feature/dm-rail/dm-rail.component').then((module) => {
this.dmRailComponent.set(module.DmRailComponent);
});
effect(() => {
const rooms = this.savedRooms();
const currentUser = this.currentUser();
@@ -296,6 +309,10 @@ export class ServersRailComponent {
}
isSelectedRoom(room: Room): boolean {
if (this.isOnDirectMessage()) {
return false;
}
return this.currentRoom()?.id === room.id;
}

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div
appThemeNode="titleBar"
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
@@ -55,7 +56,7 @@
{{ roomContextMeta() }}
</span>
}
} @else {
} @else if (!isInDirectMessage()) {
<div class="flex items-center gap-2 min-w-0">
<span
data-theme-slot="text"

View File

@@ -18,7 +18,9 @@ import {
lucideMenu,
lucideRefreshCw
} from '@ng-icons/lucide';
import { Router } from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map } from 'rxjs';
import {
selectCurrentRoom,
selectActiveChannelId,
@@ -93,7 +95,14 @@ export class TitleBarComponent {
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
inRoom = computed(() => !!this.currentRoom());
isInDirectMessage = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/'))
),
{ initialValue: this.router.url.startsWith('/dm/') }
);
inRoom = computed(() => !!this.currentRoom() && !this.isInDirectMessage());
roomName = computed(() => this.currentRoom()?.name || '');
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();