feat: Add user statuses and cards

This commit is contained in:
2026-04-16 22:52:45 +02:00
parent b4ac0cdc92
commit 2927a86fbb
57 changed files with 1964 additions and 185 deletions

View File

@@ -164,7 +164,10 @@
</button>
<!-- Voice users connected to this channel -->
@if (voiceUsersInRoom(ch.id).length > 0) {
<div class="ml-5 mt-1 space-y-1 border-l border-border pb-1 pl-2">
<div
class="mt-1 space-y-1 border-l border-border pb-1 pl-2"
style="margin-left: 0.91rem"
>
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
@@ -241,15 +244,17 @@
@if (currentUser()) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<div class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2">
<div class="relative">
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
>
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
[avatarUrl]="currentUser()?.avatarUrl"
size="sm"
[status]="currentUser()?.status"
[showStatusBadge]="true"
/>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
<div class="flex items-center gap-2">
@@ -287,17 +292,17 @@
<div class="space-y-1">
@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"
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50 cursor-pointer"
(contextmenu)="openUserContextMenu($event, user)"
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
>
<div class="relative">
<app-user-avatar
[name]="user.displayName"
[avatarUrl]="user.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<app-user-avatar
[name]="user.displayName"
[avatarUrl]="user.avatarUrl"
size="sm"
[status]="user.status"
[showStatusBadge]="true"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@@ -345,15 +350,17 @@
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
<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">
<div class="relative">
<app-user-avatar
[name]="member.displayName"
[avatarUrl]="member.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span>
</div>
<div
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
>
<app-user-avatar
[name]="member.displayName"
[avatarUrl]="member.avatarUrl"
size="sm"
status="disconnected"
[showStatusBadge]="true"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>

View File

@@ -50,7 +50,8 @@ import {
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent,
UserVolumeMenuComponent
UserVolumeMenuComponent,
ProfileCardService
} from '../../../shared';
import {
Channel,
@@ -101,6 +102,7 @@ export class RoomsSidePanelComponent {
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
private profileCard = inject(ProfileCardService);
voiceActivity = inject(VoiceActivityService);
readonly panelMode = input<PanelMode>('channels');
@@ -184,6 +186,28 @@ export class RoomsSidePanelComponent {
draggedVoiceUserId = signal<string | null>(null);
dragTargetVoiceChannelId = signal<string | null>(null);
openProfileCard(event: MouseEvent, user: User, editable: boolean): void {
event.stopPropagation();
const el = event.currentTarget as HTMLElement;
this.profileCard.open(el, user, { placement: 'left', editable });
}
openProfileCardForMember(event: MouseEvent, member: RoomMember): void {
const user: User = {
id: member.id,
oderId: member.oderId || member.id,
username: member.username,
displayName: member.displayName,
avatarUrl: member.avatarUrl,
status: 'disconnected',
role: member.role,
joinedAt: member.joinedAt
};
this.openProfileCard(event, user, false);
}
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}

View File

@@ -78,6 +78,20 @@
</div>
}
</div>
<div
class="grid w-full overflow-hidden duration-200 ease-out motion-reduce:transition-none"
style="transition-property: grid-template-rows, opacity"
[style.gridTemplateRows]="isOnSearch() ? '1fr' : '0fr'"
[style.opacity]="isOnSearch() ? '1' : '0'"
[style.visibility]="isOnSearch() ? 'visible' : 'hidden'"
[class.pointer-events-none]="!isOnSearch()"
[attr.aria-hidden]="isOnSearch() ? null : 'true'"
>
<div class="overflow-hidden">
<app-user-bar />
</div>
</div>
</nav>
<!-- Context menu -->

View File

@@ -7,24 +7,27 @@ import {
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
catchError,
filter,
firstValueFrom,
from,
map,
switchMap,
tap
} from 'rxjs';
import { Room, User } from '../../shared-kernel';
import { UserBarComponent } from '../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser, selectOnlineUsers } from '../../store/users/users.selectors';
@@ -49,7 +52,8 @@ import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent,
NgOptimizedImage
NgOptimizedImage,
UserBarComponent
],
viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html'
@@ -75,6 +79,13 @@ export class ServersRailComponent {
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({});
isOnSearch = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects.startsWith('/search'))
),
{ initialValue: this.router.url.startsWith('/search') }
);
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);

View File

@@ -9,9 +9,7 @@ export interface ThirdPartyLicense {
}
const toLicenseText = (lines: readonly string[]): string => lines.join('\n');
const GROUPED_LICENSE_NOTE = 'Grouped by the license declared in the installed package metadata for the packages below. Some upstream packages include their own copyright notices in addition to this standard license text.';
const MIT_LICENSE_TEXT = toLicenseText([
'MIT License',
'',
@@ -35,7 +33,6 @@ const MIT_LICENSE_TEXT = toLicenseText([
'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE',
'SOFTWARE.'
]);
const APACHE_LICENSE_TEXT = toLicenseText([
'Apache License',
'Version 2.0, January 2004',
@@ -191,7 +188,6 @@ const APACHE_LICENSE_TEXT = toLicenseText([
'',
'END OF TERMS AND CONDITIONS'
]);
const WAVESURFER_BSD_LICENSE_TEXT = toLicenseText([
'BSD 3-Clause License',
'',
@@ -220,7 +216,6 @@ const WAVESURFER_BSD_LICENSE_TEXT = toLicenseText([
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
]);
const ISC_LICENSE_TEXT = toLicenseText([
'ISC License',
'',
@@ -238,7 +233,6 @@ const ISC_LICENSE_TEXT = toLicenseText([
'ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS',
'SOFTWARE.'
]);
const ZERO_BSD_LICENSE_TEXT = toLicenseText([
'Zero-Clause BSD',
'',
@@ -316,9 +310,7 @@ export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
name: 'BSD-licensed packages',
licenseName: 'BSD 3-Clause License',
sourceUrl: 'https://opensource.org/licenses/BSD-3-Clause',
packages: [
'wavesurfer.js'
],
packages: ['wavesurfer.js'],
text: WAVESURFER_BSD_LICENSE_TEXT,
note: 'License text reproduced from the bundled wavesurfer.js package license.'
},
@@ -327,9 +319,7 @@ export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
name: 'ISC-licensed packages',
licenseName: 'ISC License',
sourceUrl: 'https://opensource.org/license/isc-license-txt',
packages: [
'@ng-icons/lucide'
],
packages: ['@ng-icons/lucide'],
text: ISC_LICENSE_TEXT,
note: GROUPED_LICENSE_NOTE
},
@@ -338,9 +328,7 @@ export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
name: '0BSD-licensed packages',
licenseName: '0BSD License',
sourceUrl: 'https://opensource.org/license/0bsd',
packages: [
'tslib'
],
packages: ['tslib'],
text: ZERO_BSD_LICENSE_TEXT,
note: GROUPED_LICENSE_NOTE
}