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);
}