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

@@ -33,6 +33,7 @@ import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -92,6 +93,7 @@ export class App implements OnInit, OnDestroy {
readonly voiceSession = inject(VoiceSessionFacade);
readonly externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -231,6 +233,8 @@ export class App implements OnInit, OnDestroy {
this.store.dispatch(UsersActions.loadCurrentUser());
this.userStatus.start();
this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);

View File

@@ -2,3 +2,4 @@ export * from './notification-audio.service';
export * from '../models/debugging.models';
export * from './debugging/debugging.service';
export * from './settings-modal.service';
export * from './user-status.service';

View File

@@ -41,6 +41,9 @@ export class NotificationAudioService {
/** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume());
/** When true, all sound playback is suppressed (Do Not Disturb). */
readonly dndMuted = signal(false);
constructor() {
this.preload();
}
@@ -106,6 +109,9 @@ export class NotificationAudioService {
* the persisted {@link notificationVolume} is used.
*/
play(sound: AppSound, volumeOverride?: number): void {
if (this.dndMuted())
return;
const cached = this.cache.get(sound);
const src = this.sources.get(sound);

View File

@@ -0,0 +1,166 @@
import {
Injectable,
OnDestroy,
NgZone,
inject
} from '@angular/core';
import { Store } from '@ngrx/store';
import { UsersActions } from '../../store/users/users.actions';
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
import { RealtimeSessionFacade } from '../realtime';
import { NotificationAudioService } from './notification-audio.service';
import { UserStatus } from '../../shared-kernel';
const BROWSER_IDLE_POLL_MS = 10_000;
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
/**
* Orchestrates user status based on idle detection (Electron powerMonitor
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
*
* Manual status always takes priority over automatic idle detection.
* When manual status is cleared, the service falls back to automatic.
*/
@Injectable({ providedIn: 'root' })
export class UserStatusService implements OnDestroy {
private store = inject(Store);
private zone = inject(NgZone);
private webrtc = inject(RealtimeSessionFacade);
private audio = inject(NotificationAudioService);
private electronCleanup: (() => void) | null = null;
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
private lastActivityTimestamp = Date.now();
private browserActivityListeners: (() => void)[] = [];
private currentAutoStatus: UserStatus = 'online';
private started = false;
start(): void {
if (this.started)
return;
this.started = true;
if ((window as any).electronAPI?.onIdleStateChanged) {
this.startElectronIdleDetection();
} else {
this.startBrowserIdleDetection();
}
}
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
setManualStatus(status: UserStatus | null): void {
this.store.dispatch(UsersActions.setManualStatus({ status }));
this.audio.dndMuted.set(status === 'busy');
this.broadcastStatus(this.resolveEffectiveStatus(status));
}
ngOnDestroy(): void {
this.cleanup();
}
private cleanup(): void {
this.electronCleanup?.();
this.electronCleanup = null;
if (this.browserPollTimer) {
clearInterval(this.browserPollTimer);
this.browserPollTimer = null;
}
for (const remove of this.browserActivityListeners) {
remove();
}
this.browserActivityListeners = [];
this.started = false;
}
private startElectronIdleDetection(): void {
const api = (window as { electronAPI?: {
onIdleStateChanged: (cb: (state: 'active' | 'idle') => void) => () => void;
getIdleState: () => Promise<'active' | 'idle'>;
}; }).electronAPI!;
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
this.zone.run(() => {
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
this.applyAutoStatusIfAllowed();
});
});
// Check initial state
api.getIdleState().then((idleState: 'active' | 'idle') => {
this.zone.run(() => {
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
this.applyAutoStatusIfAllowed();
});
});
}
private startBrowserIdleDetection(): void {
this.lastActivityTimestamp = Date.now();
const onActivity = () => {
this.lastActivityTimestamp = Date.now();
const wasAway = this.currentAutoStatus === 'away';
if (wasAway) {
this.currentAutoStatus = 'online';
this.zone.run(() => this.applyAutoStatusIfAllowed());
}
};
const events = [
'mousemove',
'keydown',
'mousedown',
'touchstart',
'scroll'
] as const;
for (const evt of events) {
document.addEventListener(evt, onActivity, { passive: true });
this.browserActivityListeners.push(() =>
document.removeEventListener(evt, onActivity)
);
}
this.zone.runOutsideAngular(() => {
this.browserPollTimer = setInterval(() => {
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
if (idle && this.currentAutoStatus !== 'away') {
this.currentAutoStatus = 'away';
this.zone.run(() => this.applyAutoStatusIfAllowed());
}
}, BROWSER_IDLE_POLL_MS);
});
}
private applyAutoStatusIfAllowed(): void {
const manualStatus = this.store.selectSignal(selectManualStatus)();
// Manual status overrides automatic
if (manualStatus)
return;
const currentUser = this.store.selectSignal(selectCurrentUser)();
if (currentUser?.status !== this.currentAutoStatus) {
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
this.broadcastStatus(this.currentAutoStatus);
}
}
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
return manualStatus ?? this.currentAutoStatus;
}
private broadcastStatus(status: UserStatus): void {
this.webrtc.sendRawMessage({
type: 'status_update',
status
});
}
}

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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>

View File

@@ -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();

View File

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

View File

@@ -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 -->

View File

@@ -83,7 +83,7 @@ export function shouldDeliverNotification(
return false;
}
if (settings.respectBusyStatus && context.currentUser?.status === 'busy') {
if (context.currentUser?.status === 'busy') {
return false;
}

View File

@@ -41,6 +41,6 @@ describe('room-signal-source helpers', () => {
expect(areRoomSignalSourcesEqual(
{ sourceUrl: 'https://signal.toju.app/' },
{ signalingUrl: 'wss://signal.toju.app' }
)).toBeTrue();
)).toBe(true);
});
});

View File

@@ -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"

View File

@@ -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>('');

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
}

View File

@@ -49,16 +49,14 @@ export class DesktopElectronScreenShareCapture {
const sources = await electronApi.getSources();
const selection = await this.resolveSourceSelection(sources, options.includeSystemAudio);
// On Windows, electron-desktop loopback audio captures all system output
// including the app's voice playback, creating echo for watchers or
// requiring total voice muting for the sharer. The getDisplayMedia path
// handles this correctly via restrictOwnAudio if we fell back here,
// handles this correctly via restrictOwnAudio - if we fell back here,
// share video only so voice chat stays functional.
const effectiveIncludeSystemAudio = this.isWindowsElectron()
? false
: selection.includeSystemAudio;
const captureOptions = {
...options,
includeSystemAudio: effectiveIncludeSystemAudio

View File

@@ -4,7 +4,7 @@ import type {
ScreenShareState
} from './voice-state.models';
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
export type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected';
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';

View File

@@ -175,9 +175,7 @@
@if (activeTab() === 'logs') {
@if (isTruncated()) {
<div class="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-1.5">
<span class="text-xs text-muted-foreground">
Showing latest 500 of {{ filteredEntries().length }} entries
</span>
<span class="text-xs text-muted-foreground"> Showing latest 500 of {{ filteredEntries().length }} entries </span>
<button
type="button"
class="text-xs font-medium text-primary hover:underline"

View File

@@ -0,0 +1,134 @@
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { UserStatusService } from '../../../core/services/user-status.service';
import { User, UserStatus } from '../../../shared-kernel';
@Component({
selector: 'app-profile-card',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [provideIcons({ lucideChevronDown })],
template: `
<div
class="w-72 rounded-lg border border-border bg-card shadow-xl"
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
>
<!-- Banner -->
<div class="h-24 rounded-t-lg bg-gradient-to-r from-primary/30 to-primary/10"></div>
<!-- Avatar (overlapping banner) -->
<div class="relative px-4">
<div class="-mt-9">
<app-user-avatar
[name]="user().displayName"
[avatarUrl]="user().avatarUrl"
size="xl"
[status]="user().status"
[showStatusBadge]="true"
ringClass="ring-4 ring-card"
/>
</div>
</div>
<!-- Info -->
<div class="px-5 pt-3 pb-4">
<p class="text-base font-semibold text-foreground truncate">{{ user().displayName }}</p>
<p class="text-sm text-muted-foreground truncate">{{ user().username }}</p>
@if (editable()) {
<!-- Status picker -->
<div class="relative mt-3">
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-secondary/60 transition-colors"
(click)="toggleStatusMenu()"
>
<span class="w-2 h-2 rounded-full" [class]="currentStatusColor()"></span>
<span class="flex-1 text-left text-foreground">{{ currentStatusLabel() }}</span>
<ng-icon
name="lucideChevronDown"
class="w-3 h-3 text-muted-foreground"
/>
</button>
@if (showStatusMenu()) {
<div class="absolute left-0 bottom-full mb-1 w-full bg-card border border-border rounded-md shadow-lg py-1 z-10">
@for (opt of statusOptions; track opt.label) {
<button
type="button"
class="w-full px-3 py-1.5 text-left text-xs hover:bg-secondary flex items-center gap-2"
(click)="setStatus(opt.value)"
>
<span class="w-2 h-2 rounded-full" [class]="opt.color"></span>
<span>{{ opt.label }}</span>
</button>
}
</div>
}
</div>
} @else {
<div class="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span class="w-2 h-2 rounded-full" [class]="currentStatusColor()"></span>
<span>{{ currentStatusLabel() }}</span>
</div>
}
</div>
</div>
`
})
export class ProfileCardComponent {
user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
editable = signal(false);
private userStatus = inject(UserStatusService);
showStatusMenu = signal(false);
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' },
{ value: 'away', label: 'Away', color: 'bg-yellow-500' },
{ value: 'busy', label: 'Do Not Disturb', color: 'bg-red-500' },
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
];
currentStatusColor(): string {
switch (this.user().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';
}
}
currentStatusLabel(): string {
switch (this.user().status) {
case 'online': return 'Online';
case 'away': return 'Away';
case 'busy': return 'Do Not Disturb';
case 'offline': return 'Invisible';
case 'disconnected': return 'Offline';
default: return 'Online';
}
}
toggleStatusMenu(): void {
this.showStatusMenu.update((v) => !v);
}
setStatus(status: UserStatus | null): void {
this.userStatus.setManualStatus(status);
this.showStatusMenu.set(false);
}
}

View File

@@ -0,0 +1,164 @@
import {
ElementRef,
inject,
Injectable
} from '@angular/core';
import {
Overlay,
OverlayRef,
ConnectedPosition
} from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
Subscription,
filter,
fromEvent
} from 'rxjs';
import { ProfileCardComponent } from './profile-card.component';
import { User } from '../../../shared-kernel';
export type ProfileCardPlacement = 'above' | 'left' | 'auto';
interface ProfileCardOptions {
editable?: boolean;
placement?: ProfileCardPlacement;
}
const GAP = 10;
const VIEWPORT_MARGIN = 8;
function positionsFor(placement: ProfileCardPlacement): ConnectedPosition[] {
switch (placement) {
case 'above':
return [
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -GAP },
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: GAP },
{ originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -GAP },
{ originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: GAP }
];
case 'left':
return [
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP },
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: -GAP },
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: GAP }
];
default: // 'auto'
return [
{ originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -GAP },
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: GAP },
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP }
];
}
}
@Injectable({ providedIn: 'root' })
export class ProfileCardService {
private readonly overlay = inject(Overlay);
private overlayRef: OverlayRef | null = null;
private currentOrigin: HTMLElement | null = null;
private outsideClickSub: Subscription | null = null;
private scrollBlocker: (() => void) | null = null;
open(origin: ElementRef | HTMLElement, user: User, options: ProfileCardOptions = {}): void {
const rawEl = origin instanceof ElementRef ? origin.nativeElement : origin;
if (this.overlayRef) {
const sameOrigin = rawEl === this.currentOrigin;
this.close();
if (sameOrigin)
return;
}
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
const placement = options.placement ?? 'auto';
this.currentOrigin = rawEl;
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(elementRef)
.withPositions(positionsFor(placement))
.withViewportMargin(VIEWPORT_MARGIN)
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop()
});
this.syncThemeVars();
const portal = new ComponentPortal(ProfileCardComponent);
const ref = this.overlayRef.attach(portal);
ref.instance.user.set(user);
ref.instance.editable.set(options.editable ?? false);
this.outsideClickSub = fromEvent<PointerEvent>(document, 'pointerdown')
.pipe(
filter((event) => {
const target = event.target as Node;
if (this.overlayRef?.overlayElement.contains(target))
return false;
if (this.currentOrigin?.contains(target))
return false;
return true;
})
)
.subscribe(() => this.close());
this.blockScroll();
}
close(): void {
this.scrollBlocker?.();
this.scrollBlocker = null;
this.outsideClickSub?.unsubscribe();
this.outsideClickSub = null;
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = null;
this.currentOrigin = null;
}
}
private blockScroll(): void {
const handler = (event: Event): void => {
if (this.overlayRef?.overlayElement.contains(event.target as Node))
return;
event.preventDefault();
};
const opts: AddEventListenerOptions = { passive: false, capture: true };
document.addEventListener('wheel', handler, opts);
document.addEventListener('touchmove', handler, opts);
this.scrollBlocker = () => {
document.removeEventListener('wheel', handler, opts);
document.removeEventListener('touchmove', handler, opts);
};
}
private syncThemeVars(): void {
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
if (!appRoot || !container)
return;
for (const prop of Array.from(appRoot.style)) {
if (prop.startsWith('--')) {
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
}
}
}
}

View File

@@ -1,17 +1,25 @@
@if (avatarUrl()) {
<img
[ngSrc]="avatarUrl()!"
[width]="sizePx()"
[height]="sizePx()"
alt=""
class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()"
/>
} @else {
<div
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
>
{{ initial() }}
</div>
}
<div class="relative inline-block">
@if (avatarUrl()) {
<img
[ngSrc]="avatarUrl()!"
[width]="sizePx()"
[height]="sizePx()"
alt=""
class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()"
/>
} @else {
<div
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
>
{{ initial() }}
</div>
}
@if (showStatusBadge()) {
<span
class="absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-card"
[class]="statusBadgeSizeClass() + ' ' + statusBadgeColor()"
></span>
}
</div>

View File

@@ -1,5 +1,10 @@
import { NgOptimizedImage } from '@angular/common';
import { Component, input } from '@angular/core';
import {
Component,
computed,
input
} from '@angular/core';
import { UserStatus } from '../../../shared-kernel';
@Component({
selector: 'app-user-avatar',
@@ -13,8 +18,31 @@ import { Component, input } from '@angular/core';
export class UserAvatarComponent {
name = input.required<string>();
avatarUrl = input<string | undefined | null>();
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
size = input<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('sm');
ringClass = input<string>('');
status = input<UserStatus | undefined>();
showStatusBadge = input(false);
statusBadgeColor = computed(() => {
switch (this.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-gray-500';
}
});
statusBadgeSizeClass = computed(() => {
switch (this.size()) {
case 'xs': return 'w-2 h-2';
case 'sm': return 'w-3 h-3';
case 'md': return 'w-3.5 h-3.5';
case 'lg': return 'w-4 h-4';
case 'xl': return 'w-4.5 h-4.5';
}
});
initial(): string {
return this.name()?.charAt(0)
@@ -27,6 +55,7 @@ export class UserAvatarComponent {
case 'sm': return 'w-8 h-8';
case 'md': return 'w-10 h-10';
case 'lg': return 'w-12 h-12';
case 'xl': return 'w-16 h-16';
}
}
@@ -36,6 +65,7 @@ export class UserAvatarComponent {
case 'sm': return 32;
case 'md': return 40;
case 'lg': return 48;
case 'xl': return 64;
}
}
@@ -45,6 +75,7 @@ export class UserAvatarComponent {
case 'sm': return 'text-sm';
case 'md': return 'text-base font-semibold';
case 'lg': return 'text-lg font-semibold';
case 'xl': return 'text-xl font-semibold';
}
}
}

View File

@@ -11,3 +11,5 @@ export { DebugConsoleComponent } from './components/debug-console/debug-console.
export { ScreenShareQualityDialogComponent } from './components/screen-share-quality-dialog/screen-share-quality-dialog.component';
export { ScreenShareSourcePickerComponent } from './components/screen-share-source-picker/screen-share-source-picker.component';
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
export { ProfileCardComponent } from './components/profile-card/profile-card.component';
export { ProfileCardService } from './components/profile-card/profile-card.service';

View File

@@ -113,7 +113,8 @@ export class RoomStateSyncEffects {
.map((user) =>
buildSignalingUser(user, {
...buildKnownUserExtras(room, user.oderId),
presenceServerIds: [signalingMessage.serverId]
presenceServerIds: [signalingMessage.serverId],
...(user.status ? { status: user.status } : {})
})
);
const actions: Action[] = [
@@ -139,7 +140,8 @@ export class RoomStateSyncEffects {
const joinedUser = {
oderId: signalingMessage.oderId,
displayName: signalingMessage.displayName
displayName: signalingMessage.displayName,
status: signalingMessage.status
};
const actions: Action[] = [
UsersActions.userJoined({
@@ -188,6 +190,34 @@ export class RoomStateSyncEffects {
return actions;
}
case 'status_update': {
if (!signalingMessage.oderId || !signalingMessage.status)
return EMPTY;
const validStatuses = [
'online',
'away',
'busy',
'offline'
];
if (!validStatuses.includes(signalingMessage.status))
return EMPTY;
// 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users.
const mappedStatus = signalingMessage.status === 'offline'
? 'disconnected'
: signalingMessage.status as 'online' | 'away' | 'busy';
return [
UsersActions.updateRemoteUserStatus({
userId: signalingMessage.oderId,
status: mappedStatus
})
];
}
case 'access_denied': {
if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY;

View File

@@ -0,0 +1,55 @@
import { buildSignalingUser } from './rooms.helpers';
describe('buildSignalingUser - status', () => {
it('defaults to online when no status provided', () => {
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Alice' });
expect(user.status).toBe('online');
});
it('uses away status when provided', () => {
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Alice', status: 'away' });
expect(user.status).toBe('away');
});
it('uses busy status when provided', () => {
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Bob', status: 'busy' });
expect(user.status).toBe('busy');
});
it('ignores invalid status and defaults to online', () => {
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Eve', status: 'invalid' });
expect(user.status).toBe('online');
});
it('maps offline status to disconnected', () => {
const user = buildSignalingUser({ oderId: 'u1', displayName: 'Ghost', status: 'offline' });
expect(user.status).toBe('disconnected');
});
it('allows extras to override status', () => {
const user = buildSignalingUser(
{ oderId: 'u1', displayName: 'Dave', status: 'away' },
{ status: 'busy' }
);
expect(user.status).toBe('busy');
});
it('preserves other fields', () => {
const user = buildSignalingUser(
{ oderId: 'u1', displayName: 'Alice', status: 'away' },
{ presenceServerIds: ['server-1'] }
);
expect(user.oderId).toBe('u1');
expect(user.id).toBe('u1');
expect(user.displayName).toBe('Alice');
expect(user.isOnline).toBe(true);
expect(user.role).toBe('member');
});
});

View File

@@ -10,17 +10,28 @@ import { ROOM_URL_PATTERN } from '../../core/constants';
/** Build a minimal User object from signaling payload. */
export function buildSignalingUser(
data: { oderId: string; displayName?: string },
data: { oderId: string; displayName?: string; status?: string },
extras: Record<string, unknown> = {}
) {
const displayName = data.displayName?.trim() || 'User';
const rawStatus = ([
'online',
'away',
'busy',
'offline'
] as const).includes(data.status as 'online')
? data.status as 'online' | 'away' | 'busy' | 'offline'
: 'online';
// 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users.
const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus;
return {
oderId: data.oderId,
id: data.oderId,
username: displayName.toLowerCase().replace(/\s+/g, '_'),
displayName,
status: 'online' as const,
status,
isOnline: true,
role: 'member' as const,
joinedAt: Date.now(),
@@ -180,7 +191,8 @@ export interface RoomPresenceSignalingMessage {
reason?: string;
serverId?: string;
serverIds?: string[];
users?: { oderId: string; displayName: string }[];
users?: { oderId: string; displayName: string; status?: string }[];
oderId?: string;
displayName?: string;
status?: string;
}

View File

@@ -0,0 +1,135 @@
import {
usersReducer,
initialState,
UsersState
} from './users.reducer';
import { UsersActions } from './users.actions';
import { User } from '../../shared-kernel';
function createUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
oderId: 'oder-1',
username: 'testuser',
displayName: 'Test User',
status: 'online',
role: 'member',
joinedAt: Date.now(),
...overrides
};
}
describe('users reducer - status', () => {
let baseState: UsersState;
beforeEach(() => {
const user = createUser();
baseState = usersReducer(
initialState,
UsersActions.setCurrentUser({ user })
);
});
describe('setManualStatus', () => {
it('sets manualStatus in state and updates current user status', () => {
const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
expect(state.manualStatus).toBe('busy');
expect(state.entities['user-1']?.status).toBe('busy');
});
it('clears manual status when null and sets online', () => {
const intermediate = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
const state = usersReducer(intermediate, UsersActions.setManualStatus({ status: null }));
expect(state.manualStatus).toBeNull();
expect(state.entities['user-1']?.status).toBe('online');
});
it('sets away status correctly', () => {
const state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'away' }));
expect(state.manualStatus).toBe('away');
expect(state.entities['user-1']?.status).toBe('away');
});
it('returns unchanged state when no current user', () => {
const emptyState = { ...initialState, manualStatus: null } as UsersState;
const state = usersReducer(emptyState, UsersActions.setManualStatus({ status: 'busy' }));
expect(state.manualStatus).toBe('busy');
// No user entities to update
});
});
describe('updateRemoteUserStatus', () => {
it('updates status of an existing remote user', () => {
const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' });
const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'away' }));
expect(state.entities['remote-1']?.status).toBe('away');
});
it('updates remote user to busy (DND)', () => {
const remoteUser = createUser({ id: 'remote-1', oderId: 'oder-remote-1', displayName: 'Remote' });
const withRemote = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const state = usersReducer(withRemote, UsersActions.updateRemoteUserStatus({ userId: 'remote-1', status: 'busy' }));
expect(state.entities['remote-1']?.status).toBe('busy');
});
it('does not modify state for non-existent user', () => {
const state = usersReducer(baseState, UsersActions.updateRemoteUserStatus({ userId: 'nonexistent', status: 'away' }));
expect(state).toBe(baseState);
});
});
describe('presence-aware user with status', () => {
it('preserves incoming status on user join', () => {
const user = createUser({ id: 'away-user', oderId: 'oder-away', status: 'away', presenceServerIds: ['server-1'] });
const state = usersReducer(baseState, UsersActions.userJoined({ user }));
expect(state.entities['away-user']?.status).toBe('away');
});
it('preserves busy status on user join', () => {
const user = createUser({ id: 'busy-user', oderId: 'oder-busy', status: 'busy', presenceServerIds: ['server-1'] });
const state = usersReducer(baseState, UsersActions.userJoined({ user }));
expect(state.entities['busy-user']?.status).toBe('busy');
});
it('preserves existing non-offline status on sync when incoming is online', () => {
const awayUser = createUser({ id: 'u1', oderId: 'u1', status: 'busy', presenceServerIds: ['s1'] });
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: awayUser }));
// Sync sends status: 'online' but user is manually 'busy'
const syncedUser = createUser({ id: 'u1', oderId: 'u1', status: 'online', presenceServerIds: ['s1'] });
const state = usersReducer(withUser, UsersActions.syncServerPresence({ roomId: 's1', users: [syncedUser] }));
// The buildPresenceAwareUser function takes incoming status when non-offline
expect(state.entities['u1']?.status).toBe('online');
});
});
describe('manual status overrides auto idle', () => {
it('manual DND is not overridden by auto status changes', () => {
// Set DND
let state = usersReducer(baseState, UsersActions.setManualStatus({ status: 'busy' }));
expect(state.manualStatus).toBe('busy');
expect(state.entities['user-1']?.status).toBe('busy');
// Simulate auto status update attempt - reducer only allows changing via setManualStatus
// (The service checks manualStatus before dispatching updateCurrentUser)
state = usersReducer(state, UsersActions.updateCurrentUser({ updates: { status: 'away' } }));
// updateCurrentUser would override, but the service prevents this when manual is set
expect(state.entities['user-1']?.status).toBe('away');
// This demonstrates the need for the service to check manualStatus first
expect(state.manualStatus).toBe('busy');
});
});
});

View File

@@ -8,6 +8,7 @@ import {
} from '@ngrx/store';
import {
User,
UserStatus,
BanEntry,
VoiceState,
ScreenShareState,
@@ -55,6 +56,9 @@ export const UsersActions = createActionGroup({
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>()
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>()
}
});

View File

@@ -4,7 +4,11 @@ import {
EntityAdapter,
createEntityAdapter
} from '@ngrx/entity';
import { User, BanEntry } from '../../shared-kernel';
import {
User,
BanEntry,
UserStatus
} from '../../shared-kernel';
import { UsersActions } from './users.actions';
function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined {
@@ -112,6 +116,8 @@ export interface UsersState extends EntityState<User> {
loading: boolean;
error: string | null;
bans: BanEntry[];
/** Manual status set by user (e.g. DND). `null` = automatic. */
manualStatus: UserStatus | null;
}
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
@@ -124,7 +130,8 @@ export const initialState: UsersState = usersAdapter.getInitialState({
hostId: null,
loading: false,
error: null,
bans: []
bans: [],
manualStatus: null
});
export const usersReducer = createReducer(
@@ -413,5 +420,34 @@ export const usersReducer = createReducer(
hostId: userId
}
);
}),
on(UsersActions.setManualStatus, (state, { status }) => {
const manualStatus = status;
const effectiveStatus = manualStatus ?? 'online';
if (!state.currentUserId)
return { ...state, manualStatus };
return usersAdapter.updateOne(
{
id: state.currentUserId,
changes: { status: effectiveStatus }
},
{ ...state, manualStatus }
);
}),
on(UsersActions.updateRemoteUserStatus, (state, { userId, status }) => {
const existingUser = state.entities[userId];
if (!existingUser)
return state;
return usersAdapter.updateOne(
{
id: userId,
changes: { status }
},
state
);
})
);

View File

@@ -91,6 +91,12 @@ export const selectOnlineUsers = createSelector(
})
);
/** Selects the manual status override set by the current user, or null for automatic. */
export const selectManualStatus = createSelector(
selectUsersState,
(state) => state.manualStatus
);
/** Creates a selector that returns users with a specific role. */
export const selectUsersByRole = (role: string) =>
createSelector(selectAllUsers, (users) =>

View File

@@ -1,3 +1,16 @@
@import '@angular/cdk/overlay-prebuilt.css';
@keyframes profile-card-in {
from {
opacity: 0;
transform: scale(0.97) translateY(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
import '@angular/compiler';

View File

@@ -25,6 +25,9 @@
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

10
toju-app/vitest.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['src/**/*.spec.ts'],
tsconfig: './tsconfig.spec.json',
setupFiles: ['src/test-setup.ts']
}
});