feat: Add user statuses and cards
This commit is contained in:
@@ -1,35 +1,43 @@
|
||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
||||
<div class="flex-1"></div>
|
||||
<div class="w-full border-t border-border bg-card/50 px-1 py-2">
|
||||
@if (user()) {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<ng-icon
|
||||
name="lucideUser"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
<div class="flex flex-col items-center gap-1 text-xs">
|
||||
<button
|
||||
#avatarBtn
|
||||
type="button"
|
||||
class="relative flex items-center justify-center w-8 h-8 rounded-full bg-secondary text-foreground text-sm font-medium hover:bg-secondary/80 transition-colors"
|
||||
(click)="toggleProfileCard(avatarBtn)"
|
||||
>
|
||||
{{ user()!.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-card"
|
||||
[class]="currentStatusColor()"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('login')"
|
||||
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('register')"
|
||||
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Register
|
||||
</button>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('login')"
|
||||
class="w-full px-1 py-1 text-[10px] rounded bg-secondary hover:bg-secondary/80 flex items-center justify-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('register')"
|
||||
class="w-full px-1 py-1 text-[10px] rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center justify-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,16 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideUser,
|
||||
lucideLogIn,
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideUser,
|
||||
provideIcons({
|
||||
lucideLogIn,
|
||||
lucideUserPlus })
|
||||
],
|
||||
@@ -29,6 +26,29 @@ export class UserBarComponent {
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
private router = inject(Router);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
|
||||
currentStatusColor(): string {
|
||||
const status = this.user()?.status;
|
||||
|
||||
switch (status) {
|
||||
case 'online': return 'bg-green-500';
|
||||
case 'away': return 'bg-yellow-500';
|
||||
case 'busy': return 'bg-red-500';
|
||||
case 'offline': return 'bg-gray-500';
|
||||
case 'disconnected': return 'bg-gray-500';
|
||||
default: return 'bg-green-500';
|
||||
}
|
||||
}
|
||||
|
||||
toggleProfileCard(origin: HTMLElement): void {
|
||||
const user = this.user();
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
this.profileCard.open(origin, user, { placement: 'above', editable: true });
|
||||
}
|
||||
|
||||
/** Navigate to the specified authentication page. */
|
||||
goto(path: 'login' | 'register') {
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||
[class.opacity-50]="msg.isDeleted"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="msg.senderName"
|
||||
size="md"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
class="flex-shrink-0 cursor-pointer"
|
||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="msg.senderName"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
@if (msg.replyToId) {
|
||||
@@ -34,7 +38,11 @@
|
||||
}
|
||||
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
|
||||
<span
|
||||
class="font-semibold text-foreground cursor-pointer hover:underline"
|
||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||
>{{ msg.senderName }}</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
||||
@if (msg.editedAt && !msg.isDeleted) {
|
||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideCheck,
|
||||
@@ -30,10 +31,16 @@ import {
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../attachment';
|
||||
import { KlipyService } from '../../../../application/services/klipy.service';
|
||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
Message,
|
||||
User
|
||||
} from '../../../../../../shared-kernel';
|
||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
ProfileCardService,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../../shared';
|
||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||
@@ -114,6 +121,9 @@ export class ChatMessageItemComponent {
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
readonly message = input.required<Message>();
|
||||
@@ -139,6 +149,27 @@ export class ChatMessageItemComponent {
|
||||
|
||||
editContent = '';
|
||||
|
||||
openSenderProfileCard(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
const msg = this.message();
|
||||
// Look up full user from store
|
||||
const users = this.allUsers();
|
||||
const found = users.find((u) => u.id === msg.senderId || u.oderId === msg.senderId);
|
||||
const user: User = found ?? {
|
||||
id: msg.senderId,
|
||||
oderId: msg.senderId,
|
||||
username: msg.senderName,
|
||||
displayName: msg.senderName,
|
||||
status: 'disconnected',
|
||||
role: 'member',
|
||||
joinedAt: 0
|
||||
};
|
||||
const editable = user.id === this.currentUserId();
|
||||
|
||||
this.profileCard.open(el, user, { editable });
|
||||
}
|
||||
|
||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||
void this.attachmentVersion();
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
const YOUTUBE_URL_PATTERN = /(?:youtube\.com\/(?:watch\?.*v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/;
|
||||
|
||||
@@ -27,17 +27,14 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<!-- Avatar with status indicator -->
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
[status]="user.status"
|
||||
[showStatusBadge]="true"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
@@ -59,6 +56,16 @@
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (user.status && user.status !== 'online') {
|
||||
<span
|
||||
class="text-xs"
|
||||
[class.text-yellow-500]="user.status === 'away'"
|
||||
[class.text-red-500]="user.status === 'busy'"
|
||||
[class.text-muted-foreground]="user.status === 'offline'"
|
||||
>
|
||||
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Voice/Screen Status -->
|
||||
|
||||
@@ -83,7 +83,7 @@ export function shouldDeliverNotification(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
|
||||
if (context.currentUser?.status === 'busy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,6 @@ describe('room-signal-source helpers', () => {
|
||||
expect(areRoomSignalSourcesEqual(
|
||||
{ sourceUrl: 'https://signal.toju.app/' },
|
||||
{ signalingUrl: 'wss://signal.toju.app' }
|
||||
)).toBeTrue();
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,25 +15,33 @@
|
||||
}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
</p>
|
||||
@if (showConnectionError() || isConnected()) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (showConnectionError()) {
|
||||
<span class="text-destructive">Connection Error</span>
|
||||
} @else if (isConnected()) {
|
||||
<span class="text-green-500">Connected</span>
|
||||
}
|
||||
<div class="relative flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-3 flex-1 min-w-0 rounded-md px-1 py-0.5 hover:bg-secondary/60 transition-colors cursor-pointer"
|
||||
(click)="toggleProfileCard(); $event.stopPropagation()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
size="sm"
|
||||
[status]="currentUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate text-left">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@if (showConnectionError() || isConnected()) {
|
||||
<p class="text-xs text-muted-foreground text-left">
|
||||
@if (showConnectionError()) {
|
||||
<span class="text-destructive">Connection Error</span>
|
||||
} @else if (isConnected()) {
|
||||
<span class="text-green-500">Connected</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<app-debug-console
|
||||
launcherVariant="inline"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
@@ -34,7 +35,8 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
UserAvatarComponent
|
||||
UserAvatarComponent,
|
||||
ProfileCardService
|
||||
} from '../../../../shared';
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -75,6 +77,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private readonly hostEl = inject(ElementRef);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -88,6 +92,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
showSettings = signal(false);
|
||||
|
||||
toggleProfileCard(): void {
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!user)
|
||||
return;
|
||||
|
||||
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
|
||||
}
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
|
||||
Reference in New Issue
Block a user