fix: Mobile style fixes and other small ui fixes
This commit is contained in:
@@ -19,11 +19,11 @@ Capabilities protect privileged app surfaces. A plugin must declare a capability
|
|||||||
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
|
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
|
||||||
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
|
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
|
||||||
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
|
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
|
||||||
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. |
|
| `messages.sync` | `messages.sync()`, `messages.import()`, `attachments.import()` | Syncs message arrays, imports historical messages locally, or imports files for those messages. |
|
||||||
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
|
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
|
||||||
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
|
| `channels.manage` | `channels.addTextChannel()`, `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
|
||||||
| `server.read` | `server.getCurrent()` | Reads active server. |
|
| `server.read` | `server.getCurrent()` | Reads active server. |
|
||||||
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. |
|
| `server.manage` | `server.updateIcon()`, `server.updatePermissions()`, `server.updateSettings()` | Updates server icon, permissions, or settings. `server.updateIcon()` resolves when the local icon update has been persisted or rejects if the current user is not allowed to manage the server icon. |
|
||||||
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
|
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
|
||||||
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
|
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
|
||||||
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
|
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
|
||||||
|
|||||||
Binary file not shown.
@@ -93,6 +93,16 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (isMobile() && directCalls.mobileOverlaySession(); as call) {
|
||||||
|
<div class="absolute inset-0 z-[70]">
|
||||||
|
<app-private-call
|
||||||
|
class="block h-full w-full"
|
||||||
|
[callIdInput]="call.callId"
|
||||||
|
[overlayMode]="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (isThemeStudioFullscreen()) {
|
@if (isThemeStudioFullscreen()) {
|
||||||
<div
|
<div
|
||||||
#themeStudioControlsRef
|
#themeStudioControlsRef
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { GameActivityService } from './domains/game-activity';
|
|||||||
import { PluginBootstrapService } from './domains/plugins';
|
import { PluginBootstrapService } from './domains/plugins';
|
||||||
import { DirectCallService } from './domains/direct-call';
|
import { DirectCallService } from './domains/direct-call';
|
||||||
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
|
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
|
||||||
|
import { PrivateCallComponent } from './features/direct-call/private-call.component';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
@@ -70,6 +71,7 @@ import {
|
|||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent,
|
ScreenShareSourcePickerComponent,
|
||||||
NativeContextMenuComponent,
|
NativeContextMenuComponent,
|
||||||
|
PrivateCallComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent
|
ThemePickerOverlayComponent
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||||
|
import { ViewportService } from '../../../../core/platform';
|
||||||
import {
|
import {
|
||||||
VoiceActivityService,
|
VoiceActivityService,
|
||||||
VoiceConnectionFacade,
|
VoiceConnectionFacade,
|
||||||
@@ -38,9 +39,11 @@ export class DirectCallService {
|
|||||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
private readonly playback = inject(VoicePlaybackService);
|
private readonly playback = inject(VoicePlaybackService);
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||||
|
private readonly mobileOverlayCallId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly sessions = computed(() => this.sessionsSignal());
|
readonly sessions = computed(() => this.sessionsSignal());
|
||||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||||
@@ -65,6 +68,15 @@ export class DirectCallService {
|
|||||||
});
|
});
|
||||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||||
|
readonly mobileOverlaySession = computed(() => {
|
||||||
|
const callId = this.mobileOverlayCallId();
|
||||||
|
|
||||||
|
if (!callId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.delivery.directCallEvents$.subscribe((event) => {
|
this.delivery.directCallEvents$.subscribe((event) => {
|
||||||
@@ -92,6 +104,12 @@ export class DirectCallService {
|
|||||||
|
|
||||||
this.audio.stop(AppSound.Call);
|
this.audio.stop(AppSound.Call);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
|
||||||
|
this.mobileOverlayCallId.set(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
||||||
@@ -155,7 +173,7 @@ export class DirectCallService {
|
|||||||
this.currentSession.set(session);
|
this.currentSession.set(session);
|
||||||
await this.joinCall(session.callId, false);
|
await this.joinCall(session.callId, false);
|
||||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||||
await this.router.navigate(['/call', session.callId]);
|
await this.openCallView(session.callId);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +204,24 @@ export class DirectCallService {
|
|||||||
this.currentSession.set(session);
|
this.currentSession.set(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openCallView(callId: string): Promise<void> {
|
||||||
|
if (this.viewport.isMobile()) {
|
||||||
|
await this.openMobileCallOverlay(callId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.openCallView(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openMobileCallOverlay(callId: string): Promise<void> {
|
||||||
|
await this.openCall(callId);
|
||||||
|
this.mobileOverlayCallId.set(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMobileCallOverlay(): void {
|
||||||
|
this.mobileOverlayCallId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
async answerIncomingCall(callId: string): Promise<void> {
|
async answerIncomingCall(callId: string): Promise<void> {
|
||||||
const session = this.sessionById(callId);
|
const session = this.sessionById(callId);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,27 @@
|
|||||||
appThemeNode="dmChatHeader"
|
appThemeNode="dmChatHeader"
|
||||||
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
|
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
|
||||||
>
|
>
|
||||||
|
@if (peerUser()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 pr-2 text-left transition-colors hover:bg-secondary/60 focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
[attr.aria-label]="'Open profile for ' + peerName()"
|
||||||
|
[title]="'Open profile for ' + peerName()"
|
||||||
|
(click)="openHeaderProfileCard($event)"
|
||||||
|
>
|
||||||
|
<app-user-avatar
|
||||||
|
[name]="peerName()"
|
||||||
|
[avatarUrl]="peerUser()?.avatarUrl"
|
||||||
|
[status]="peerUser()?.status"
|
||||||
|
[showStatusBadge]="true"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||||
|
<p class="text-xs text-muted-foreground">Direct Message</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="peerName()"
|
[name]="peerName()"
|
||||||
[avatarUrl]="peerUser()?.avatarUrl"
|
[avatarUrl]="peerUser()?.avatarUrl"
|
||||||
@@ -17,6 +38,7 @@
|
|||||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||||
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
|
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
@if (showCallButton() && conversation()) {
|
@if (showCallButton() && conversation()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
|||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
import { ViewportService } from '../../../../core/platform';
|
||||||
import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared';
|
import {
|
||||||
|
BottomSheetComponent,
|
||||||
|
ProfileCardService,
|
||||||
|
UserAvatarComponent
|
||||||
|
} from '../../../../shared';
|
||||||
import { DirectCallService } from '../../../direct-call';
|
import { DirectCallService } from '../../../direct-call';
|
||||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||||
import { ThemeNodeDirective } from '../../../theme';
|
import { ThemeNodeDirective } from '../../../theme';
|
||||||
@@ -82,6 +86,7 @@ export class DmChatComponent {
|
|||||||
private readonly attachments = inject(AttachmentFacade);
|
private readonly attachments = inject(AttachmentFacade);
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly linkMetadata = inject(LinkMetadataService);
|
private readonly linkMetadata = inject(LinkMetadataService);
|
||||||
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
private readonly metadataRequestKeys = new Set<string>();
|
private readonly metadataRequestKeys = new Set<string>();
|
||||||
private openedConversationId: string | null = null;
|
private openedConversationId: string | null = null;
|
||||||
@@ -309,6 +314,17 @@ export class DmChatComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openHeaderProfileCard(event: MouseEvent): void {
|
||||||
|
const user = this.peerUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
this.profileCard.open(event.currentTarget as HTMLElement, user, { editable: false });
|
||||||
|
}
|
||||||
|
|
||||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||||
this.replyTo.set(message);
|
this.replyTo.set(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="group/server relative flex w-full justify-center">
|
<div class="group/server relative flex w-full justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground"
|
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground md:h-10 md:w-10"
|
||||||
title="Direct Messages"
|
title="Direct Messages"
|
||||||
aria-label="Direct Messages"
|
aria-label="Direct Messages"
|
||||||
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
|
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideMessageCircle"
|
name="lucideMessageCircle"
|
||||||
class="h-4 w-4"
|
class="h-[18px] w-[18px] md:h-4 md:w-4"
|
||||||
/>
|
/>
|
||||||
@if (directMessages.totalUnreadCount() > 0) {
|
@if (directMessages.totalUnreadCount() > 0) {
|
||||||
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
|
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="group/server relative flex w-full justify-center">
|
<div class="group/server relative flex w-full justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
|
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
|
||||||
[class.dm-rail-slide-in]="!item.isExiting"
|
[class.dm-rail-slide-in]="!item.isExiting"
|
||||||
[class.dm-rail-slide-out]="item.isExiting"
|
[class.dm-rail-slide-out]="item.isExiting"
|
||||||
[class.pointer-events-none]="item.isExiting"
|
[class.pointer-events-none]="item.isExiting"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
appThemeNode="dmConversationItem"
|
appThemeNode="dmConversationItem"
|
||||||
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
class="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||||
[class.bg-primary/10]="isSelected()"
|
[class.bg-primary/10]="isSelected()"
|
||||||
[class.text-foreground]="isSelected()"
|
[class.text-foreground]="isSelected()"
|
||||||
[attr.aria-current]="isSelected() ? 'page' : null"
|
[attr.aria-current]="isSelected() ? 'page' : null"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
input
|
input,
|
||||||
|
output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -48,6 +49,7 @@ export class DmConversationItemComponent {
|
|||||||
private readonly directMessages = inject(DirectMessageService);
|
private readonly directMessages = inject(DirectMessageService);
|
||||||
private readonly directCalls = inject(DirectCallService);
|
private readonly directCalls = inject(DirectCallService);
|
||||||
readonly conversation = input.required<DirectMessageConversation>();
|
readonly conversation = input.required<DirectMessageConversation>();
|
||||||
|
readonly conversationOpened = output<string>();
|
||||||
readonly users = this.store.selectSignal(selectAllUsers);
|
readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||||
@@ -71,6 +73,7 @@ export class DmConversationItemComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openConversation(): void {
|
openConversation(): void {
|
||||||
|
this.conversationOpened.emit(this.conversation().id);
|
||||||
void this.router.navigate(['/dm', this.conversation().id]);
|
void this.router.navigate(['/dm', this.conversation().id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<aside
|
<aside
|
||||||
appThemeNode="dmConversationsPanel"
|
appThemeNode="dmConversationsPanel"
|
||||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
class="flex min-h-0 w-full min-w-0 overflow-hidden border-r border-border bg-card"
|
||||||
[ngStyle]="listPanelStyles()"
|
[ngStyle]="listPanelStyles()"
|
||||||
>
|
>
|
||||||
<section class="flex h-full w-full min-w-0 flex-col">
|
<section class="flex h-full w-full min-w-0 flex-col">
|
||||||
@@ -28,10 +28,12 @@
|
|||||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
|
||||||
<app-dm-conversation-item
|
<app-dm-conversation-item
|
||||||
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
|
|
||||||
[conversation]="conversation"
|
[conversation]="conversation"
|
||||||
></app-dm-conversation-item>
|
(conversationOpened)="conversationSelected.emit($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
inject
|
inject,
|
||||||
|
output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -31,6 +32,7 @@ export class DmConversationsPanelComponent {
|
|||||||
private readonly theme = inject(ThemeService);
|
private readonly theme = inject(ThemeService);
|
||||||
readonly directMessages = inject(DirectMessageService);
|
readonly directMessages = inject(DirectMessageService);
|
||||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||||
|
readonly conversationSelected = output<string>();
|
||||||
|
|
||||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||||
return conversation.id;
|
return conversation.id;
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
||||||
<app-servers-rail class="block h-full shrink-0" />
|
<app-servers-rail class="block h-full shrink-0" />
|
||||||
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
|
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
|
||||||
<app-dm-conversations-panel class="block h-full w-full" />
|
<app-dm-conversations-panel
|
||||||
|
(conversationSelected)="setMobilePage('chat')"
|
||||||
|
class="block h-full w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</swiper-slide>
|
</swiper-slide>
|
||||||
@@ -32,7 +35,21 @@
|
|||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<p class="truncate text-sm font-semibold text-foreground">Direct messages</p>
|
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">Direct messages</p>
|
||||||
|
@if (activeCall()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openActiveCall()"
|
||||||
|
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||||
|
aria-label="Return to call"
|
||||||
|
title="Return to call"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucidePhoneCall"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-hidden">
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
<app-dm-chat-panel class="block h-full w-full" />
|
<app-dm-chat-panel class="block h-full w-full" />
|
||||||
@@ -50,4 +67,3 @@
|
|||||||
<app-dm-chat-panel />
|
<app-dm-chat-panel />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideChevronLeft } from '@ng-icons/lucide';
|
import { lucideChevronLeft, lucidePhoneCall } from '@ng-icons/lucide';
|
||||||
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
|
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
import { ViewportService } from '../../../../core/platform';
|
||||||
import { ThemeService } from '../../../theme';
|
import { ThemeService } from '../../../theme';
|
||||||
|
import { DirectCallService } from '../../../direct-call';
|
||||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||||
import { DmChatPanelComponent } from './dm-chat-panel.component';
|
import { DmChatPanelComponent } from './dm-chat-panel.component';
|
||||||
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
|
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
|
||||||
@@ -47,7 +48,7 @@ interface SwiperElement extends HTMLElement {
|
|||||||
DmConversationsPanelComponent,
|
DmConversationsPanelComponent,
|
||||||
ServersRailComponent
|
ServersRailComponent
|
||||||
],
|
],
|
||||||
viewProviders: [provideIcons({ lucideChevronLeft })],
|
viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
templateUrl: './dm-workspace.component.html'
|
templateUrl: './dm-workspace.component.html'
|
||||||
})
|
})
|
||||||
@@ -57,6 +58,7 @@ export class DmWorkspaceComponent implements OnDestroy {
|
|||||||
private readonly theme = inject(ThemeService);
|
private readonly theme = inject(ThemeService);
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
private readonly zone = inject(NgZone);
|
private readonly zone = inject(NgZone);
|
||||||
|
private readonly directCalls = inject(DirectCallService);
|
||||||
private lastSeenConversationId: string | null = null;
|
private lastSeenConversationId: string | null = null;
|
||||||
private swiperListenerAttached: SwiperElement | null = null;
|
private swiperListenerAttached: SwiperElement | null = null;
|
||||||
readonly directMessages = inject(DirectMessageService);
|
readonly directMessages = inject(DirectMessageService);
|
||||||
@@ -66,6 +68,12 @@ export class DmWorkspaceComponent implements OnDestroy {
|
|||||||
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||||
|
readonly activeCall = computed(() => {
|
||||||
|
const currentSession = this.directCalls.currentSession();
|
||||||
|
const visibleSessions = this.directCalls.visibleActiveSessions();
|
||||||
|
|
||||||
|
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||||
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
|
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
|
||||||
@@ -150,6 +158,14 @@ export class DmWorkspaceComponent implements OnDestroy {
|
|||||||
this.mobilePage.set(page);
|
this.mobilePage.set(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openActiveCall(): void {
|
||||||
|
const call = this.activeCall();
|
||||||
|
|
||||||
|
if (call) {
|
||||||
|
void this.directCalls.openCallView(call.callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.directMessages.closeConversationView(this.routeConversationId());
|
this.directMessages.closeConversationView(this.routeConversationId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Store } from '@ngrx/store';
|
|||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
|
import { resolveRoomPermission } from '../../../access-control';
|
||||||
|
import { AttachmentFacade } from '../../../attachment';
|
||||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -28,6 +31,7 @@ import type {
|
|||||||
PluginApiAvatarUpdate,
|
PluginApiAvatarUpdate,
|
||||||
PluginApiActionContext,
|
PluginApiActionContext,
|
||||||
PluginApiActionSource,
|
PluginApiActionSource,
|
||||||
|
PluginApiAttachmentImportRequest,
|
||||||
PluginApiChannelRequest,
|
PluginApiChannelRequest,
|
||||||
PluginApiCustomStreamRequest,
|
PluginApiCustomStreamRequest,
|
||||||
PluginApiMessageAsPluginUserRequest,
|
PluginApiMessageAsPluginUserRequest,
|
||||||
@@ -44,11 +48,13 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginClientApiService {
|
export class PluginClientApiService {
|
||||||
|
private readonly attachments = inject(AttachmentFacade);
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
private readonly capabilities = inject(PluginCapabilityService);
|
||||||
private readonly db = inject(DatabaseService);
|
private readonly db = inject(DatabaseService);
|
||||||
private readonly logger = inject(PluginLoggerService);
|
private readonly logger = inject(PluginLoggerService);
|
||||||
private readonly messageBus = inject(PluginMessageBusService);
|
private readonly messageBus = inject(PluginMessageBusService);
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly storage = inject(PluginStorageService);
|
private readonly storage = inject(PluginStorageService);
|
||||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||||
@@ -73,6 +79,10 @@ export class PluginClientApiService {
|
|||||||
requireCapability('channels.manage');
|
requireCapability('channels.manage');
|
||||||
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
|
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
|
||||||
},
|
},
|
||||||
|
addTextChannel: (request) => {
|
||||||
|
requireCapability('channels.manage');
|
||||||
|
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'text') }));
|
||||||
|
},
|
||||||
addVideoChannel: (request) => {
|
addVideoChannel: (request) => {
|
||||||
requireCapability('channels.manage');
|
requireCapability('channels.manage');
|
||||||
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
|
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
|
||||||
@@ -143,6 +153,15 @@ export class PluginClientApiService {
|
|||||||
await this.storage.writeClientData(pluginId, key, value);
|
await this.storage.writeClientData(pluginId, key, value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
attachments: {
|
||||||
|
import: async (request: PluginApiAttachmentImportRequest) => {
|
||||||
|
requireCapability('messages.sync');
|
||||||
|
const roomId = this.requireRoomId();
|
||||||
|
|
||||||
|
this.attachments.rememberMessageRoom(request.messageId, roomId);
|
||||||
|
await this.attachments.publishAttachments(request.messageId, request.files, this.currentUser()?.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
media: {
|
media: {
|
||||||
addCustomAudioStream: async (request) => {
|
addCustomAudioStream: async (request) => {
|
||||||
requireCapability('media.addAudioStream');
|
requireCapability('media.addAudioStream');
|
||||||
@@ -190,6 +209,10 @@ export class PluginClientApiService {
|
|||||||
requireCapability('messages.send');
|
requireCapability('messages.send');
|
||||||
this.receivePluginUserMessage(pluginId, request);
|
this.receivePluginUserMessage(pluginId, request);
|
||||||
},
|
},
|
||||||
|
import: async (messages) => {
|
||||||
|
requireCapability('messages.sync');
|
||||||
|
await this.importPluginMessages(pluginId, messages);
|
||||||
|
},
|
||||||
setTyping: (isTyping, channelId) => {
|
setTyping: (isTyping, channelId) => {
|
||||||
requireCapability('messages.send');
|
requireCapability('messages.send');
|
||||||
this.setTyping(pluginId, isTyping, channelId);
|
this.setTyping(pluginId, isTyping, channelId);
|
||||||
@@ -301,6 +324,58 @@ export class PluginClientApiService {
|
|||||||
|
|
||||||
return userId;
|
return userId;
|
||||||
},
|
},
|
||||||
|
updateIcon: async (icon) => {
|
||||||
|
requireCapability('server.manage');
|
||||||
|
const room = this.currentRoom();
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
throw new Error('Room not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error('Not logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||||
|
const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host';
|
||||||
|
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
|
||||||
|
|
||||||
|
if (!isOwner && !isServerAdmin && !canByRole) {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconUpdatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.db.updateRoom(room.id, { icon, iconUpdatedAt });
|
||||||
|
|
||||||
|
this.store.dispatch(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt }));
|
||||||
|
|
||||||
|
this.realtime.broadcastMessage({
|
||||||
|
type: 'server-icon-update',
|
||||||
|
roomId: room.id,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
this.realtime.sendRawMessage({
|
||||||
|
type: 'server_icon_available',
|
||||||
|
serverId: room.id,
|
||||||
|
iconUpdatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
this.serverDirectory.updateServer(room.id, {
|
||||||
|
actingRole: isOwner ? 'host' : undefined,
|
||||||
|
currentOwnerId: currentUser.id,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt
|
||||||
|
}, {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
}).subscribe({
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
},
|
||||||
updatePermissions: (permissions) => {
|
updatePermissions: (permissions) => {
|
||||||
requireCapability('server.manage');
|
requireCapability('server.manage');
|
||||||
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
|
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
|
||||||
@@ -648,6 +723,29 @@ export class PluginClientApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async importPluginMessages(pluginId: string, messages: Message[]): Promise<void> {
|
||||||
|
const roomId = this.requireRoomId();
|
||||||
|
const normalizedMessages = messages
|
||||||
|
.filter((message) => message.roomId === roomId)
|
||||||
|
.map((message) => ({
|
||||||
|
...message,
|
||||||
|
channelId: message.channelId ?? this.activeChannelId() ?? 'general',
|
||||||
|
isDeleted: message.isDeleted === true,
|
||||||
|
reactions: message.reactions ?? []
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (normalizedMessages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of normalizedMessages) {
|
||||||
|
await this.db.saveMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.dispatch(MessagesActions.syncMessages({ messages: normalizedMessages }));
|
||||||
|
this.logger.info(pluginId, 'Historical messages imported', { count: normalizedMessages.length });
|
||||||
|
}
|
||||||
|
|
||||||
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
|
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
|
||||||
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
|
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
|
||||||
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);
|
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export interface PluginApiAudioClipRequest {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginApiAttachmentImportRequest {
|
||||||
|
files: File[];
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginApiCustomStreamRequest {
|
export interface PluginApiCustomStreamRequest {
|
||||||
label?: string;
|
label?: string;
|
||||||
stream: MediaStream;
|
stream: MediaStream;
|
||||||
@@ -195,6 +200,7 @@ export interface PluginApiUiContributionMap {
|
|||||||
export interface TojuClientPluginApi {
|
export interface TojuClientPluginApi {
|
||||||
readonly channels: {
|
readonly channels: {
|
||||||
addAudioChannel: (request: PluginApiChannelRequest) => void;
|
addAudioChannel: (request: PluginApiChannelRequest) => void;
|
||||||
|
addTextChannel: (request: PluginApiChannelRequest) => void;
|
||||||
addVideoChannel: (request: PluginApiChannelRequest) => void;
|
addVideoChannel: (request: PluginApiChannelRequest) => void;
|
||||||
list: () => Channel[];
|
list: () => Channel[];
|
||||||
remove: (channelId: string) => void;
|
remove: (channelId: string) => void;
|
||||||
@@ -221,6 +227,9 @@ export interface TojuClientPluginApi {
|
|||||||
remove: (key: string) => Promise<void>;
|
remove: (key: string) => Promise<void>;
|
||||||
write: (key: string, value: unknown) => Promise<void>;
|
write: (key: string, value: unknown) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
readonly attachments: {
|
||||||
|
import: (request: PluginApiAttachmentImportRequest) => Promise<void>;
|
||||||
|
};
|
||||||
readonly media: {
|
readonly media: {
|
||||||
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||||
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||||
@@ -235,6 +244,7 @@ export interface TojuClientPluginApi {
|
|||||||
readCurrent: () => Message[];
|
readCurrent: () => Message[];
|
||||||
send: (content: string, channelId?: string) => Message;
|
send: (content: string, channelId?: string) => Message;
|
||||||
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
||||||
|
import: (messages: Message[]) => Promise<void>;
|
||||||
setTyping: (isTyping: boolean, channelId?: string) => void;
|
setTyping: (isTyping: boolean, channelId?: string) => void;
|
||||||
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
|
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
|
||||||
sync: (messages: Message[]) => void;
|
sync: (messages: Message[]) => void;
|
||||||
@@ -261,6 +271,7 @@ export interface TojuClientPluginApi {
|
|||||||
readonly server: {
|
readonly server: {
|
||||||
getCurrent: () => Room | null;
|
getCurrent: () => Room | null;
|
||||||
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
|
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
|
||||||
|
updateIcon: (icon: string) => Promise<void>;
|
||||||
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
|
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
|
||||||
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
|
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||||
<section
|
<section
|
||||||
class="flex h-full min-h-0 flex-col bg-background text-foreground"
|
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
|
||||||
data-testid="plugin-manager"
|
data-testid="plugin-manager"
|
||||||
>
|
>
|
||||||
<header class="flex items-center justify-between border-b border-border px-4 py-3">
|
<header class="flex flex-col gap-3 border-b border-border px-3 py-3 md:flex-row md:items-center md:justify-between md:px-4">
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3 md:flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
class="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground md:h-8 md:w-8"
|
||||||
aria-label="Back to settings"
|
aria-label="Back to settings"
|
||||||
(click)="close()"
|
(click)="close()"
|
||||||
>
|
>
|
||||||
@@ -21,9 +21,10 @@
|
|||||||
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
|
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50"
|
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||||
[disabled]="busyAll()"
|
[disabled]="busyAll()"
|
||||||
(click)="activateAll()"
|
(click)="activateAll()"
|
||||||
>
|
>
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
|
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||||
(click)="openStore()"
|
(click)="openStore()"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -44,15 +45,16 @@
|
|||||||
/>
|
/>
|
||||||
Open Plugin Store
|
Open Plugin Store
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="flex gap-2 border-b border-border px-4 py-2"
|
class="no-scrollbar flex gap-2 overflow-x-auto border-b border-border px-3 py-2 md:px-4"
|
||||||
aria-label="Plugin manager sections"
|
aria-label="Plugin manager sections"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||||
[class.bg-muted]="activeTab() === 'installed'"
|
[class.bg-muted]="activeTab() === 'installed'"
|
||||||
(click)="setTab('installed')"
|
(click)="setTab('installed')"
|
||||||
>
|
>
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||||
[class.bg-muted]="activeTab() === 'extensions'"
|
[class.bg-muted]="activeTab() === 'extensions'"
|
||||||
(click)="setTab('extensions')"
|
(click)="setTab('extensions')"
|
||||||
>
|
>
|
||||||
@@ -76,7 +78,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||||
[class.bg-muted]="activeTab() === 'requirements'"
|
[class.bg-muted]="activeTab() === 'requirements'"
|
||||||
(click)="setTab('requirements')"
|
(click)="setTab('requirements')"
|
||||||
>
|
>
|
||||||
@@ -88,7 +90,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||||
[class.bg-muted]="activeTab() === 'settings'"
|
[class.bg-muted]="activeTab() === 'settings'"
|
||||||
(click)="setTab('settings')"
|
(click)="setTab('settings')"
|
||||||
>
|
>
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||||
[class.bg-muted]="activeTab() === 'docs'"
|
[class.bg-muted]="activeTab() === 'docs'"
|
||||||
(click)="setTab('docs')"
|
(click)="setTab('docs')"
|
||||||
>
|
>
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||||
[class.bg-muted]="activeTab() === 'logs'"
|
[class.bg-muted]="activeTab() === 'logs'"
|
||||||
(click)="setTab('logs')"
|
(click)="setTab('logs')"
|
||||||
>
|
>
|
||||||
@@ -124,7 +126,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
<div class="min-h-0 flex-1 overflow-auto p-3 md:p-4">
|
||||||
@switch (activeTab()) {
|
@switch (activeTab()) {
|
||||||
@case ('extensions') {
|
@case ('extensions') {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -216,7 +218,7 @@
|
|||||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
|
class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
|
||||||
[class.bg-muted]="isSelected(entry)"
|
[class.bg-muted]="isSelected(entry)"
|
||||||
(click)="selectPlugin(entry.manifest.id)"
|
(click)="selectPlugin(entry.manifest.id)"
|
||||||
>
|
>
|
||||||
@@ -224,7 +226,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<section class="rounded-lg border border-border bg-card p-4">
|
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
|
||||||
@if (selectedPlugin(); as plugin) {
|
@if (selectedPlugin(); as plugin) {
|
||||||
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
|
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
|
||||||
@if (selectedSettingsPages().length > 0) {
|
@if (selectedSettingsPages().length > 0) {
|
||||||
@@ -255,7 +257,7 @@
|
|||||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
|
class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
|
||||||
[class.bg-muted]="isSelected(entry)"
|
[class.bg-muted]="isSelected(entry)"
|
||||||
(click)="selectPlugin(entry.manifest.id)"
|
(click)="selectPlugin(entry.manifest.id)"
|
||||||
>
|
>
|
||||||
@@ -263,14 +265,14 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<section class="rounded-lg border border-border bg-card p-4">
|
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
|
||||||
@if (selectedPlugin(); as plugin) {
|
@if (selectedPlugin(); as plugin) {
|
||||||
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
|
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
|
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
@for (doc of selectedDocs(); track doc.label) {
|
@for (doc of selectedDocs(); track doc.label) {
|
||||||
<a
|
<a
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"
|
class="inline-flex min-h-11 items-center rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted md:min-h-0"
|
||||||
[href]="doc.url"
|
[href]="doc.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
@@ -292,7 +294,7 @@
|
|||||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted"
|
class="min-h-11 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted md:min-h-0"
|
||||||
[class.bg-muted]="isSelected(entry)"
|
[class.bg-muted]="isSelected(entry)"
|
||||||
(click)="selectPlugin(entry.manifest.id)"
|
(click)="selectPlugin(entry.manifest.id)"
|
||||||
>
|
>
|
||||||
@@ -323,7 +325,7 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@if (entries().length === 0) {
|
@if (entries().length === 0) {
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-dashed border-border p-8 text-center"
|
class="rounded-lg border border-dashed border-border p-5 text-center md:p-8"
|
||||||
data-testid="plugin-empty-state"
|
data-testid="plugin-empty-state"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -337,7 +339,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||||
<article
|
<article
|
||||||
class="rounded-lg border border-border bg-card p-4"
|
class="rounded-lg border border-border bg-card p-3 md:p-4"
|
||||||
[class.ring-2]="isSelected(entry)"
|
[class.ring-2]="isSelected(entry)"
|
||||||
[class.ring-primary]="isSelected(entry)"
|
[class.ring-primary]="isSelected(entry)"
|
||||||
>
|
>
|
||||||
@@ -351,17 +353,17 @@
|
|||||||
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
|
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
|
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
|
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||||
(click)="selectPlugin(entry.manifest.id)"
|
(click)="selectPlugin(entry.manifest.id)"
|
||||||
>
|
>
|
||||||
Select
|
Select
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
|
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||||
(click)="setEnabled(entry, !entry.enabled)"
|
(click)="setEnabled(entry, !entry.enabled)"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -372,7 +374,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||||
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
|
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
|
||||||
(click)="activate(entry)"
|
(click)="activate(entry)"
|
||||||
>
|
>
|
||||||
@@ -384,7 +386,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||||
[disabled]="busyPluginId() === entry.manifest.id"
|
[disabled]="busyPluginId() === entry.manifest.id"
|
||||||
(click)="reload(entry)"
|
(click)="reload(entry)"
|
||||||
>
|
>
|
||||||
@@ -396,7 +398,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||||
[disabled]="busyPluginId() === entry.manifest.id"
|
[disabled]="busyPluginId() === entry.manifest.id"
|
||||||
(click)="unload(entry)"
|
(click)="unload(entry)"
|
||||||
>
|
>
|
||||||
@@ -416,7 +418,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="rounded-lg border border-border bg-card p-4">
|
<aside class="rounded-lg border border-border bg-card p-3 md:p-4">
|
||||||
@if (selectedPlugin(); as plugin) {
|
@if (selectedPlugin(); as plugin) {
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -430,14 +432,14 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted"
|
class="mt-3 min-h-11 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||||
(click)="grantAll(plugin)"
|
(click)="grantAll(plugin)"
|
||||||
>
|
>
|
||||||
Grant all requested
|
Grant all requested
|
||||||
</button>
|
</button>
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
|
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
|
||||||
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
<label class="flex min-h-11 items-center gap-2 rounded-md border border-border px-3 py-2 text-sm md:min-h-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="flex w-full flex-wrap items-center justify-center gap-3 rounded-2xl bg-background/75 px-4 py-3 backdrop-blur">
|
<div class="flex w-full flex-wrap items-center justify-center gap-3 px-3 py-3 sm:px-4">
|
||||||
@if (!connected()) {
|
@if (!connected()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<article
|
<article
|
||||||
class="flex aspect-square min-w-0 flex-col items-center justify-center overflow-hidden rounded-2xl border border-border/80 bg-card/80 text-center shadow-sm backdrop-blur"
|
class="flex min-w-0 flex-col items-center justify-center overflow-hidden rounded-xl text-center"
|
||||||
[class.w-[11rem]]="compact()"
|
[ngClass]="compact() ? 'min-h-[9.5rem] w-[12rem] shrink-0 p-3 sm:w-[14rem] sm:p-4' : 'min-h-[14rem] w-full p-3 sm:min-h-[17rem] sm:p-[clamp(1.25rem,4vw,2rem)]'"
|
||||||
[class.shrink-0]="compact()"
|
|
||||||
[class.p-4]="compact()"
|
|
||||||
[class.sm:w-[12.5rem]]="compact()"
|
|
||||||
[class.w-full]="!compact()"
|
|
||||||
[class.p-[clamp(1rem,4vw,1.5rem)]]="!compact()"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
|
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
|
||||||
@@ -67,16 +62,9 @@
|
|||||||
@if (connected()) {
|
@if (connected()) {
|
||||||
<span
|
<span
|
||||||
class="absolute rounded-full border-card"
|
class="absolute rounded-full border-card"
|
||||||
[class.bottom-3]="compact()"
|
[ngClass]="
|
||||||
[class.right-3]="compact()"
|
compact() ? 'bottom-1 right-1 h-4 w-4 border-[3px] sm:bottom-3 sm:right-3' : 'bottom-1 right-1 h-5 w-5 border-4 sm:bottom-5 sm:right-5'
|
||||||
[class.h-4]="compact()"
|
"
|
||||||
[class.w-4]="compact()"
|
|
||||||
[class.border-[3px]]="compact()"
|
|
||||||
[class.bottom-5]="!compact()"
|
|
||||||
[class.right-5]="!compact()"
|
|
||||||
[class.h-5]="!compact()"
|
|
||||||
[class.w-5]="!compact()"
|
|
||||||
[class.border-4]="!compact()"
|
|
||||||
[class.bg-emerald-400]="speaking()"
|
[class.bg-emerald-400]="speaking()"
|
||||||
[class.bg-muted-foreground]="!speaking()"
|
[class.bg-muted-foreground]="!speaking()"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export class PrivateCallParticipantCardComponent {
|
|||||||
readonly compact = input(false);
|
readonly compact = input(false);
|
||||||
|
|
||||||
avatarSize(): string {
|
avatarSize(): string {
|
||||||
return this.compact() ? '5rem' : 'clamp(4.25rem, 22vw, 10rem)';
|
return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';
|
||||||
}
|
}
|
||||||
|
|
||||||
avatarSizeSm(): string {
|
avatarSizeSm(): string {
|
||||||
return this.compact() ? '6rem' : this.avatarSize();
|
return this.compact() ? '6rem' : 'clamp(4.25rem, 22vw, 10rem)';
|
||||||
}
|
}
|
||||||
|
|
||||||
participantInitial(): string {
|
participantInitial(): string {
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
|
@if (isMobile()) {
|
||||||
|
<swiper-container
|
||||||
|
class="block h-full min-h-0 w-full"
|
||||||
|
direction="vertical"
|
||||||
|
slides-per-view="1"
|
||||||
|
space-between="0"
|
||||||
|
initial-slide="1"
|
||||||
|
threshold="10"
|
||||||
|
resistance-ratio="0"
|
||||||
|
(swiperslidechange)="onMobileCallSlideChange($event)"
|
||||||
|
>
|
||||||
|
<swiper-slide class="block h-full w-full" />
|
||||||
|
<swiper-slide class="block h-full w-full">
|
||||||
|
<ng-container *ngTemplateOutlet="privateCallSurface" />
|
||||||
|
</swiper-slide>
|
||||||
|
</swiper-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container *ngTemplateOutlet="privateCallSurface" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #privateCallSurface>
|
||||||
<section
|
<section
|
||||||
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
|
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
|
||||||
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
|
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
|
||||||
>
|
>
|
||||||
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
|
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
|
||||||
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-5 backdrop-blur">
|
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 backdrop-blur sm:px-5">
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
|
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -26,8 +47,22 @@
|
|||||||
|
|
||||||
@if (session()) {
|
@if (session()) {
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
@if (isMobile()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
(click)="minimizeCall()"
|
||||||
|
aria-label="Minimize call"
|
||||||
|
title="Minimize call"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<select
|
<select
|
||||||
class="h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground"
|
class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
|
||||||
[ngModel]="inviteUserId()"
|
[ngModel]="inviteUserId()"
|
||||||
(ngModelChange)="inviteUserId.set($event)"
|
(ngModelChange)="inviteUserId.set($event)"
|
||||||
aria-label="Add user to call"
|
aria-label="Add user to call"
|
||||||
@@ -39,7 +74,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50"
|
class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
|
||||||
[disabled]="!inviteUserId()"
|
[disabled]="!inviteUserId()"
|
||||||
(click)="inviteSelectedUser()"
|
(click)="inviteSelectedUser()"
|
||||||
aria-label="Add user"
|
aria-label="Add user"
|
||||||
@@ -55,8 +90,8 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (session()) {
|
@if (session()) {
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-4 sm:px-5">
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-3 py-3 sm:px-5 sm:py-4">
|
||||||
<div class="relative min-h-0 flex-1 overflow-hidden rounded-2xl border border-border/80 bg-card/45 shadow-sm">
|
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||||
@if (activeShares().length > 0) {
|
@if (activeShares().length > 0) {
|
||||||
@if (focusedShare()) {
|
@if (focusedShare()) {
|
||||||
@if (hasMultipleShares()) {
|
@if (hasMultipleShares()) {
|
||||||
@@ -103,17 +138,18 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex h-full min-h-0 items-center justify-center p-4 sm:p-6">
|
<div class="flex h-full min-h-0 items-center justify-center p-1 sm:p-5">
|
||||||
<div
|
<div
|
||||||
class="grid w-full max-w-5xl grid-cols-[repeat(auto-fit,minmax(min(10rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(13rem,100%),1fr))] sm:gap-5 lg:gap-7"
|
class="grid w-full max-w-7xl grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))] sm:gap-5 lg:gap-7"
|
||||||
>
|
>
|
||||||
|
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||||
<app-private-call-participant-card
|
<app-private-call-participant-card
|
||||||
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
|
|
||||||
[user]="user"
|
[user]="user"
|
||||||
[connected]="isParticipantConnected(user)"
|
[connected]="isParticipantConnected(user)"
|
||||||
[speaking]="isSpeaking(user)"
|
[speaking]="isSpeaking(user)"
|
||||||
[issueLabel]="participantIssueLabel(user)"
|
[issueLabel]="participantIssueLabel(user)"
|
||||||
></app-private-call-participant-card>
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -122,14 +158,15 @@
|
|||||||
@if (activeShares().length > 0) {
|
@if (activeShares().length > 0) {
|
||||||
<div class="shrink-0 pt-4">
|
<div class="shrink-0 pt-4">
|
||||||
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
|
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
|
||||||
|
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||||
<app-private-call-participant-card
|
<app-private-call-participant-card
|
||||||
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
|
|
||||||
[user]="user"
|
[user]="user"
|
||||||
[connected]="isParticipantConnected(user)"
|
[connected]="isParticipantConnected(user)"
|
||||||
[speaking]="isSpeaking(user)"
|
[speaking]="isSpeaking(user)"
|
||||||
[issueLabel]="participantIssueLabel(user)"
|
[issueLabel]="participantIssueLabel(user)"
|
||||||
[compact]="true"
|
[compact]="true"
|
||||||
></app-private-call-participant-card>
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
@if (hasMultipleShares()) {
|
@if (hasMultipleShares()) {
|
||||||
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
|
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
|
||||||
@@ -166,7 +203,7 @@
|
|||||||
(cameraToggled)="toggleCamera()"
|
(cameraToggled)="toggleCamera()"
|
||||||
(screenShareToggled)="toggleScreenShare()"
|
(screenShareToggled)="toggleScreenShare()"
|
||||||
(leaveRequested)="leave()"
|
(leaveRequested)="leave()"
|
||||||
></app-private-call-controls>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -191,6 +228,7 @@
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
@if (showScreenShareQualityDialog()) {
|
@if (showScreenShareQualityDialog()) {
|
||||||
<app-screen-share-quality-dialog
|
<app-screen-share-quality-dialog
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
HostListener,
|
HostListener,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
signal,
|
signal,
|
||||||
untracked
|
untracked
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -17,6 +19,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucidePhone,
|
lucidePhone,
|
||||||
|
lucideX,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideUserPlus
|
lucideUserPlus
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
@@ -39,6 +42,7 @@ import {
|
|||||||
} from '../../domains/screen-share';
|
} from '../../domains/screen-share';
|
||||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
|
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
|
||||||
import { ScreenShareQualityDialogComponent } from '../../shared';
|
import { ScreenShareQualityDialogComponent } from '../../shared';
|
||||||
|
import { ViewportService } from '../../core/platform';
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { UsersActions } from '../../store/users/users.actions';
|
import { UsersActions } from '../../store/users/users.actions';
|
||||||
import { User } from '../../shared-kernel';
|
import { User } from '../../shared-kernel';
|
||||||
@@ -60,9 +64,12 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
|
|||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
VoiceWorkspaceStreamTileComponent
|
VoiceWorkspaceStreamTileComponent
|
||||||
],
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
host: { class: 'block h-full w-full' },
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucidePhone,
|
lucidePhone,
|
||||||
|
lucideX,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideUserPlus
|
lucideUserPlus
|
||||||
})
|
})
|
||||||
@@ -79,13 +86,18 @@ export class PrivateCallComponent {
|
|||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
private readonly playback = inject(VoicePlaybackService);
|
private readonly playback = inject(VoicePlaybackService);
|
||||||
private readonly screenShare = inject(ScreenShareFacade);
|
private readonly screenShare = inject(ScreenShareFacade);
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
private chatResizing = false;
|
private chatResizing = false;
|
||||||
|
|
||||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
readonly callId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
readonly callIdInput = input<string | null>(null);
|
||||||
|
readonly overlayMode = input(false);
|
||||||
|
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('callId')
|
initialValue: this.route.snapshot.paramMap.get('callId')
|
||||||
});
|
});
|
||||||
|
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
||||||
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
||||||
readonly participantUsers = computed(() => {
|
readonly participantUsers = computed(() => {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
@@ -146,12 +158,10 @@ export class PrivateCallComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const user of this.participantUsers()) {
|
for (const user of this.participantUsers()) {
|
||||||
const peerKey = this.getPeerKeyCandidates(user).find(
|
const peerKey =
|
||||||
(candidate) => candidate !== localPeerKey
|
this.getPeerKeyCandidates(user).find(
|
||||||
&& (
|
(candidate) =>
|
||||||
!!this.screenShare.getRemoteScreenShareStream(candidate)
|
candidate !== localPeerKey && (!!this.screenShare.getRemoteScreenShareStream(candidate) || !!this.voice.getRemoteCameraStream(candidate))
|
||||||
|| !!this.voice.getRemoteCameraStream(candidate)
|
|
||||||
)
|
|
||||||
) ?? this.userKey(user);
|
) ?? this.userKey(user);
|
||||||
|
|
||||||
if (peerKey === localPeerKey) {
|
if (peerKey === localPeerKey) {
|
||||||
@@ -192,9 +202,7 @@ export class PrivateCallComponent {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
readonly focusedShare = computed(
|
readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null);
|
||||||
() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null
|
|
||||||
);
|
|
||||||
readonly thumbnailShares = computed(() => {
|
readonly thumbnailShares = computed(() => {
|
||||||
const focusedShareId = this.focusedShareId();
|
const focusedShareId = this.focusedShareId();
|
||||||
|
|
||||||
@@ -217,14 +225,31 @@ export class PrivateCallComponent {
|
|||||||
const session = this.session();
|
const session = this.session();
|
||||||
|
|
||||||
if (session && !this.calls.hasOngoingActivity(session)) {
|
if (session && !this.calls.hasOngoingActivity(session)) {
|
||||||
|
if (this.overlayMode()) {
|
||||||
|
untracked(() => this.calls.closeMobileCallOverlay());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
|
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const callId = this.callId();
|
||||||
|
const session = this.session();
|
||||||
|
|
||||||
|
if (callId && session?.conversationId && this.isMobile() && !this.overlayMode()) {
|
||||||
|
untracked(() => {
|
||||||
|
void this.calls.openMobileCallOverlay(callId);
|
||||||
|
void this.router.navigate(['/pm', session.conversationId], { replaceUrl: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
const currentUserId = this.currentUserKey();
|
const currentUserId = this.currentUserKey();
|
||||||
const peerIds = (session ? this.remoteParticipantPeerIds(session, currentUserId) : []);
|
const peerIds = session ? this.remoteParticipantPeerIds(session, currentUserId) : [];
|
||||||
|
|
||||||
this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected');
|
this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected');
|
||||||
});
|
});
|
||||||
@@ -240,13 +265,9 @@ export class PrivateCallComponent {
|
|||||||
this.untrackLocalMic();
|
this.untrackLocalMic();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.screenShare.onRemoteStream
|
this.screenShare.onRemoteStream.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision());
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
|
||||||
|
|
||||||
this.screenShare.onPeerDisconnected
|
this.screenShare.onPeerDisconnected.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision());
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
|
||||||
|
|
||||||
this.destroyRef.onDestroy(() => {
|
this.destroyRef.onDestroy(() => {
|
||||||
this.screenShare.syncRemoteScreenShareRequests([], false);
|
this.screenShare.syncRemoteScreenShareRequests([], false);
|
||||||
@@ -284,8 +305,36 @@ export class PrivateCallComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.calls.leaveCall(session.callId);
|
this.calls.leaveCall(session.callId);
|
||||||
|
this.calls.closeMobileCallOverlay();
|
||||||
this.untrackLocalMic();
|
this.untrackLocalMic();
|
||||||
void this.router.navigate(['/dm', session.conversationId]);
|
|
||||||
|
if (!this.overlayMode()) {
|
||||||
|
void this.router.navigate(['/pm', session.conversationId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minimizeCall(): void {
|
||||||
|
const session = this.session();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayMode()) {
|
||||||
|
this.calls.closeMobileCallOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.router.navigate(['/pm', session.conversationId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMobileCallSlideChange(event: Event): void {
|
||||||
|
const detail = (event as CustomEvent).detail;
|
||||||
|
const swiper = Array.isArray(detail) ? detail[0] : detail;
|
||||||
|
|
||||||
|
if (this.isMobile() && swiper?.activeIndex === 0) {
|
||||||
|
this.minimizeCall();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMute(): void {
|
toggleMute(): void {
|
||||||
@@ -378,11 +427,9 @@ export class PrivateCallComponent {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !!session.participants[userId]?.joined
|
return (
|
||||||
|| !!(
|
!!session.participants[userId]?.joined ||
|
||||||
user.voiceState?.isConnected
|
!!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId)
|
||||||
&& user.voiceState.roomId === session.callId
|
|
||||||
&& user.voiceState.serverId === session.callId
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +484,8 @@ export class PrivateCallComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.updateVoiceState({
|
this.store.dispatch(
|
||||||
|
UsersActions.updateVoiceState({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
voiceState: {
|
voiceState: {
|
||||||
isConnected: this.isConnected(),
|
isConnected: this.isConnected(),
|
||||||
@@ -446,7 +494,8 @@ export class PrivateCallComponent {
|
|||||||
roomId: session.callId,
|
roomId: session.callId,
|
||||||
serverId: session.callId
|
serverId: session.callId
|
||||||
}
|
}
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
|
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
|
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
|
||||||
<app-rooms-side-panel
|
<app-rooms-side-panel
|
||||||
panelMode="channels"
|
panelMode="channels"
|
||||||
|
(textChannelSelected)="setMobilePage('main')"
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +53,20 @@
|
|||||||
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name }}</p>
|
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (activeCall()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openActiveCall()"
|
||||||
|
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||||
|
aria-label="Return to call"
|
||||||
|
title="Return to call"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucidePhoneCall"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="setMobilePage('members')"
|
(click)="setMobilePage('members')"
|
||||||
@@ -208,4 +223,3 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideMenu,
|
lucideMenu,
|
||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft
|
lucideChevronLeft,
|
||||||
|
lucidePhoneCall
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
|
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
|
||||||
@@ -38,6 +39,7 @@ import { ViewportService } from '../../../core/platform';
|
|||||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||||
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||||
|
import { DirectCallService } from '../../../domains/direct-call';
|
||||||
|
|
||||||
/** Mobile-only page identifier within the chat-room view. */
|
/** Mobile-only page identifier within the chat-room view. */
|
||||||
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
|
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
|
||||||
@@ -77,7 +79,8 @@ interface SwiperElement extends HTMLElement {
|
|||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideMenu,
|
lucideMenu,
|
||||||
lucideX,
|
lucideX,
|
||||||
lucideChevronLeft
|
lucideChevronLeft,
|
||||||
|
lucidePhoneCall
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
@@ -96,6 +99,7 @@ export class ChatRoomComponent {
|
|||||||
private readonly settingsModal = inject(SettingsModalService);
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
private readonly theme = inject(ThemeService);
|
private readonly theme = inject(ThemeService);
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
|
private readonly directCalls = inject(DirectCallService);
|
||||||
private readonly zone = inject(NgZone);
|
private readonly zone = inject(NgZone);
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
private lastSeenChannelId: string | null = null;
|
private lastSeenChannelId: string | null = null;
|
||||||
@@ -128,6 +132,12 @@ export class ChatRoomComponent {
|
|||||||
});
|
});
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||||
hasTextChannels = computed(() => this.textChannels().length > 0);
|
hasTextChannels = computed(() => this.textChannels().length > 0);
|
||||||
|
activeCall = computed(() => {
|
||||||
|
const currentSession = this.directCalls.currentSession();
|
||||||
|
const visibleSessions = this.directCalls.visibleActiveSessions();
|
||||||
|
|
||||||
|
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
|
||||||
|
});
|
||||||
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||||
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
||||||
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||||
@@ -209,6 +219,14 @@ export class ChatRoomComponent {
|
|||||||
this.mobilePage.set(page);
|
this.mobilePage.set(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openActiveCall(): void {
|
||||||
|
const call = this.activeCall();
|
||||||
|
|
||||||
|
if (call) {
|
||||||
|
void this.directCalls.openCallView(call.callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Open the settings modal to the Server admin page for the current room. */
|
/** Open the settings modal to the Server admin page for the current room. */
|
||||||
toggleAdminPanel() {
|
toggleAdminPanel() {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
output,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
@@ -138,6 +139,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
|
|
||||||
readonly panelMode = input<PanelMode>('channels');
|
readonly panelMode = input<PanelMode>('channels');
|
||||||
readonly showVoiceControls = input(true);
|
readonly showVoiceControls = input(true);
|
||||||
|
readonly textChannelSelected = output<string>();
|
||||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
@@ -379,6 +381,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.voiceWorkspace.showChat();
|
this.voiceWorkspace.showChat();
|
||||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||||
|
this.textChannelSelected.emit(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||||
|
|||||||
@@ -63,10 +63,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!item().isLocal && item().hasAudio) {
|
@if (canControlStreamAudio()) {
|
||||||
|
<div class="flex min-w-32 items-center gap-2 rounded-full border border-white/10 bg-black/35 px-2.5 py-1.5 text-white/75">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
|
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white"
|
||||||
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
||||||
(click)="toggleMuted(); $event.stopPropagation()"
|
(click)="toggleMuted(); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@@ -75,6 +76,35 @@
|
|||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
[value]="volume()"
|
||||||
|
class="w-20 accent-primary sm:w-28"
|
||||||
|
aria-label="Stream volume"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(input)="updateVolume($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="w-9 text-right text-xs tabular-nums">{{ muted() ? 'Off' : volume() + '%' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isMobile() && item().kind === 'screen') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
|
||||||
|
title="Rotate to landscape"
|
||||||
|
aria-label="Rotate to landscape"
|
||||||
|
(click)="enterLandscapeFullscreen($event)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideRotateCw"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -92,6 +122,72 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (immersive() && item().kind === 'screen' && !isFullscreen()) {
|
||||||
|
<div class="absolute inset-x-3 bottom-3 z-20 sm:inset-x-5 sm:bottom-5">
|
||||||
|
<div class="mx-auto flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 rounded-2xl border border-white/10 bg-black/55 px-3 py-3 text-white/80 shadow-2xl backdrop-blur-lg sm:gap-3 sm:px-4">
|
||||||
|
@if (canControlStreamAudio()) {
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-white/10 px-2.5 py-2 sm:max-w-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-white/85 transition hover:bg-white/10 hover:text-white"
|
||||||
|
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
||||||
|
[attr.aria-label]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
||||||
|
(click)="toggleMuted(); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
[value]="volume()"
|
||||||
|
class="min-w-0 flex-1 accent-primary"
|
||||||
|
aria-label="Screen share volume"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(input)="updateVolume($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="w-10 text-right text-xs font-semibold tabular-nums text-white/70">{{ muted() ? 'Off' : volume() + '%' }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="min-w-0 flex-1 px-2 text-center text-xs font-medium text-white/65 sm:text-left">No screen audio</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/15"
|
||||||
|
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||||
|
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||||
|
(click)="toggleFullscreen($event)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMaximize"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isMobile()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/15"
|
||||||
|
title="Rotate to landscape"
|
||||||
|
aria-label="Rotate to landscape"
|
||||||
|
(click)="enterLandscapeFullscreen($event)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideRotateCw"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (mini()) {
|
@if (mini()) {
|
||||||
<div class="absolute inset-x-0 bottom-0 p-2">
|
<div class="absolute inset-x-0 bottom-0 p-2">
|
||||||
<div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md">
|
<div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md">
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
lucideMaximize,
|
lucideMaximize,
|
||||||
lucideMinimize,
|
lucideMinimize,
|
||||||
lucideMonitor,
|
lucideMonitor,
|
||||||
|
lucideRotateCw,
|
||||||
lucideVideo,
|
lucideVideo,
|
||||||
lucideVolume2,
|
lucideVolume2,
|
||||||
lucideVolumeX
|
lucideVolumeX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { UserAvatarComponent } from '../../../../shared';
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
|
import { ViewportService } from '../../../../core/platform';
|
||||||
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
|||||||
lucideMaximize,
|
lucideMaximize,
|
||||||
lucideMinimize,
|
lucideMinimize,
|
||||||
lucideMonitor,
|
lucideMonitor,
|
||||||
|
lucideRotateCw,
|
||||||
lucideVideo,
|
lucideVideo,
|
||||||
lucideVolume2,
|
lucideVolume2,
|
||||||
lucideVolumeX
|
lucideVolumeX
|
||||||
@@ -51,6 +54,7 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
|||||||
})
|
})
|
||||||
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
||||||
@@ -64,6 +68,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
||||||
|
|
||||||
readonly isFullscreen = signal(false);
|
readonly isFullscreen = signal(false);
|
||||||
|
readonly isMobile = this.viewport.isMobile;
|
||||||
readonly showFullscreenHeader = signal(true);
|
readonly showFullscreenHeader = signal(true);
|
||||||
readonly volume = signal(100);
|
readonly volume = signal(100);
|
||||||
readonly muted = signal(false);
|
readonly muted = signal(false);
|
||||||
@@ -138,6 +143,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.unlockOrientation();
|
||||||
this.clearFullscreenHeaderHideTimeout();
|
this.clearFullscreenHeaderHideTimeout();
|
||||||
this.showFullscreenHeader.set(true);
|
this.showFullscreenHeader.set(true);
|
||||||
}
|
}
|
||||||
@@ -150,6 +156,8 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
if (tile && document.fullscreenElement === tile) {
|
if (tile && document.fullscreenElement === tile) {
|
||||||
void document.exitFullscreen().catch(() => {});
|
void document.exitFullscreen().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.unlockOrientation();
|
||||||
}
|
}
|
||||||
|
|
||||||
canToggleFullscreen(): boolean {
|
canToggleFullscreen(): boolean {
|
||||||
@@ -168,22 +176,38 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
await this.toggleFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleFullscreen(event?: Event): Promise<void> {
|
||||||
|
event?.preventDefault();
|
||||||
|
event?.stopPropagation();
|
||||||
|
|
||||||
if (!this.canToggleFullscreen()) {
|
if (!this.canToggleFullscreen()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tile = this.tileRef()?.nativeElement;
|
if (this.isFullscreen()) {
|
||||||
|
|
||||||
if (!tile || !tile.requestFullscreen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.fullscreenElement === tile) {
|
|
||||||
await document.exitFullscreen().catch(() => {});
|
await document.exitFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await tile.requestFullscreen().catch(() => {});
|
await this.enterFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterLandscapeFullscreen(event?: Event): Promise<void> {
|
||||||
|
event?.preventDefault();
|
||||||
|
event?.stopPropagation();
|
||||||
|
|
||||||
|
if (!this.canToggleFullscreen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isFullscreen()) {
|
||||||
|
await this.enterFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.lockLandscape();
|
||||||
}
|
}
|
||||||
|
|
||||||
async exitFullscreen(event?: Event): Promise<void> {
|
async exitFullscreen(event?: Event): Promise<void> {
|
||||||
@@ -263,6 +287,41 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
: 'Your preview stays muted locally to avoid audio feedback.';
|
: 'Your preview stays muted locally to avoid audio feedback.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canControlStreamAudio(): boolean {
|
||||||
|
const item = this.item();
|
||||||
|
|
||||||
|
return !item.isLocal && item.hasAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enterFullscreen(): Promise<void> {
|
||||||
|
const tile = this.tileRef()?.nativeElement;
|
||||||
|
|
||||||
|
if (tile?.requestFullscreen) {
|
||||||
|
await tile.requestFullscreen().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = this.videoRef()?.nativeElement as WebKitFullscreenVideoElement | undefined;
|
||||||
|
|
||||||
|
if (video?.webkitSupportsFullscreen && video.webkitEnterFullscreen) {
|
||||||
|
video.webkitEnterFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockLandscape(): Promise<void> {
|
||||||
|
if (!this.isMobile()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orientation = screen.orientation as LockableScreenOrientation | undefined;
|
||||||
|
|
||||||
|
await orientation?.lock?.('landscape').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private unlockOrientation(): void {
|
||||||
|
screen.orientation?.unlock?.();
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleFullscreenHeaderHide(): void {
|
private scheduleFullscreenHeaderHide(): void {
|
||||||
this.clearFullscreenHeaderHideTimeout();
|
this.clearFullscreenHeaderHideTimeout();
|
||||||
|
|
||||||
@@ -286,3 +345,12 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
this.fullscreenHeaderHideTimeoutId = null;
|
this.fullscreenHeaderHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebKitFullscreenVideoElement extends HTMLVideoElement {
|
||||||
|
webkitEnterFullscreen?: () => void;
|
||||||
|
webkitSupportsFullscreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LockableScreenOrientation extends ScreenOrientation {
|
||||||
|
lock?: (orientation: 'landscape') => Promise<void>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
<nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3">
|
<nav class="relative flex h-full min-w-14 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
|
||||||
<!-- Create button -->
|
<!-- Create button -->
|
||||||
<button
|
<button
|
||||||
appThemeNode="serversRailCreateButton"
|
appThemeNode="serversRailCreateButton"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
class="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90 md:h-10 md:w-10"
|
||||||
title="Create Server"
|
title="Create Server"
|
||||||
(click)="createServer()"
|
(click)="createServer()"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucidePlus"
|
name="lucidePlus"
|
||||||
class="w-5 h-5"
|
class="h-[22px] w-[22px] md:h-5 md:w-5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (dmRailComponent()) {
|
<app-dm-rail />
|
||||||
<ng-container *ngComponentOutlet="dmRailComponent()" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) {
|
@for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) {
|
||||||
<div class="group/call relative flex w-full justify-center">
|
<div class="group/call relative flex w-full justify-center">
|
||||||
@@ -27,7 +25,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 grid h-10 w-10 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg"
|
class="relative z-10 grid h-11 w-11 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg md:h-10 md:w-10"
|
||||||
[ngClass]="
|
[ngClass]="
|
||||||
callAvatarUrls(call).length > 0
|
callAvatarUrls(call).length > 0
|
||||||
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
|
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
|
||||||
@@ -61,7 +59,7 @@
|
|||||||
|
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucidePhone"
|
name="lucidePhone"
|
||||||
class="relative z-10 h-5 w-5 drop-shadow"
|
class="relative z-10 h-[22px] w-[22px] drop-shadow md:h-5 md:w-5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +81,7 @@
|
|||||||
<button
|
<button
|
||||||
appThemeNode="serversRailItem"
|
appThemeNode="serversRailItem"
|
||||||
type="button"
|
type="button"
|
||||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
|
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
|
||||||
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
||||||
[title]="room.name"
|
[title]="room.name"
|
||||||
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
|
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Type,
|
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
@@ -36,6 +35,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
|||||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||||
import { NotificationsFacade } from '../../../domains/notifications';
|
import { NotificationsFacade } from '../../../domains/notifications';
|
||||||
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
|
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
|
||||||
|
import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component';
|
||||||
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
NgIcon,
|
NgIcon,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
|
DmRailComponent,
|
||||||
LeaveServerDialogComponent,
|
LeaveServerDialogComponent,
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
UserBarComponent
|
UserBarComponent
|
||||||
@@ -71,12 +72,12 @@ export class ServersRailComponent {
|
|||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
|
private visibleSavedRoomCache: Room[] = [];
|
||||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
dmRailComponent = signal<Type<unknown> | null>(null);
|
|
||||||
menuX = signal(72);
|
menuX = signal(72);
|
||||||
menuY = signal(100);
|
menuY = signal(100);
|
||||||
contextRoom = signal<Room | null>(null);
|
contextRoom = signal<Room | null>(null);
|
||||||
@@ -95,9 +96,9 @@ export class ServersRailComponent {
|
|||||||
isOnDirectMessage = toSignal(
|
isOnDirectMessage = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/') || navigationEvent.urlAfterRedirects.startsWith('/pm/'))
|
map((navigationEvent) => this.isDirectMessageUrl(navigationEvent.urlAfterRedirects))
|
||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/dm/') || this.router.url.startsWith('/pm/') }
|
{ initialValue: this.isDirectMessageUrl(this.router.url) }
|
||||||
);
|
);
|
||||||
isOnCall = toSignal(
|
isOnCall = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
@@ -139,7 +140,7 @@ export class ServersRailComponent {
|
|||||||
passwordPromptRoom = signal<Room | null>(null);
|
passwordPromptRoom = signal<Room | null>(null);
|
||||||
joinPassword = signal('');
|
joinPassword = signal('');
|
||||||
joinPasswordError = signal<string | null>(null);
|
joinPasswordError = signal<string | null>(null);
|
||||||
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
|
||||||
voicePresenceByRoom = computed(() => {
|
voicePresenceByRoom = computed(() => {
|
||||||
const presence: Record<string, number> = {};
|
const presence: Record<string, number> = {};
|
||||||
const seenByRoom = new Map<string, Set<string>>();
|
const seenByRoom = new Map<string, Set<string>>();
|
||||||
@@ -182,10 +183,6 @@ export class ServersRailComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
void import('../../../domains/direct-message/feature/dm-rail/dm-rail.component').then((module) => {
|
|
||||||
this.dmRailComponent.set(module.DmRailComponent);
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const rooms = this.savedRooms();
|
const rooms = this.savedRooms();
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
@@ -237,6 +234,7 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
|
const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room;
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -244,20 +242,20 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isRoomMarkedBanned(room)) {
|
if (this.isRoomMarkedBanned(targetRoom)) {
|
||||||
this.bannedServerName.set(room.name);
|
this.bannedServerName.set(targetRoom.name);
|
||||||
this.showBannedDialog.set(true);
|
this.showBannedDialog.set(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.optimisticSelectedRoomId.set(room.id);
|
this.optimisticSelectedRoomId.set(targetRoom.id);
|
||||||
this.activateSavedRoom(room);
|
this.activateSavedRoom(targetRoom);
|
||||||
this.savedRoomJoinRequests.next({ room });
|
this.savedRoomJoinRequests.next({ room: targetRoom });
|
||||||
}
|
}
|
||||||
|
|
||||||
openCall(callId: string): void {
|
openCall(callId: string): void {
|
||||||
this.optimisticSelectedRoomId.set(null);
|
this.optimisticSelectedRoomId.set(null);
|
||||||
void this.router.navigate(['/call', callId]);
|
void this.directCalls.openCallView(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelectedCall(callIndex: number): boolean {
|
isSelectedCall(callIndex: number): boolean {
|
||||||
@@ -392,17 +390,46 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSelectedRoom(room: Room): boolean {
|
isSelectedRoom(room: Room): boolean {
|
||||||
|
if (this.isOnDirectMessage() || this.isOnCall()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const optimisticRoomId = this.optimisticSelectedRoomId();
|
const optimisticRoomId = this.optimisticSelectedRoomId();
|
||||||
|
|
||||||
if (optimisticRoomId) {
|
if (optimisticRoomId) {
|
||||||
return optimisticRoomId === room.id;
|
return optimisticRoomId === room.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isOnDirectMessage() || this.isOnCall()) {
|
return this.currentRoom()?.id === room.id;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.currentRoom()?.id === room.id;
|
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
|
||||||
|
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
|
||||||
|
const stabilizedRooms = nextRooms.map((room) => {
|
||||||
|
const previousRoom = previousById.get(room.id);
|
||||||
|
|
||||||
|
return previousRoom && this.hasSameRailRoomView(previousRoom, room) ? previousRoom : room;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
stabilizedRooms.length === this.visibleSavedRoomCache.length
|
||||||
|
&& stabilizedRooms.every((room, index) => room === this.visibleSavedRoomCache[index])
|
||||||
|
) {
|
||||||
|
return this.visibleSavedRoomCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.visibleSavedRoomCache = stabilizedRooms;
|
||||||
|
return stabilizedRooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSameRailRoomView(previousRoom: Room, nextRoom: Room): boolean {
|
||||||
|
return previousRoom.id === nextRoom.id && previousRoom.name === nextRoom.name && previousRoom.icon === nextRoom.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDirectMessageUrl(url: string): boolean {
|
||||||
|
const path = url.split(/[?#]/, 1)[0];
|
||||||
|
|
||||||
|
return path === '/dm' || path.startsWith('/dm/') || path === '/pm' || path.startsWith('/pm/');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
|
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
class="h-24 bg-gradient-to-r from-primary/30 to-primary/10"
|
class="h-24 bg-gradient-to-r from-primary/30 to-primary/10"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="-mt-12 flex flex-col items-center px-6">
|
<div class="-mt-16 flex flex-col items-center px-6">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="profileUser.displayName"
|
[name]="profileUser.displayName"
|
||||||
[avatarUrl]="profileUser.avatarUrl"
|
[avatarUrl]="profileUser.avatarUrl"
|
||||||
size="xl"
|
size="2xl"
|
||||||
[status]="profileUser.status"
|
[status]="profileUser.status"
|
||||||
[showStatusBadge]="true"
|
[showStatusBadge]="true"
|
||||||
ringClass="ring-4 ring-card"
|
ringClass="ring-4 ring-card"
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
</button>
|
</button>
|
||||||
@if (isEditable) {
|
@if (isEditable) {
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow"
|
class="pointer-events-none absolute bottom-2 right-2 flex h-9 w-9 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideCamera"
|
name="lucideCamera"
|
||||||
class="h-3.5 w-3.5"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,29 +16,42 @@ import { UserStatus } from '../../../shared-kernel';
|
|||||||
export class UserAvatarComponent {
|
export class UserAvatarComponent {
|
||||||
name = input.required<string>();
|
name = input.required<string>();
|
||||||
avatarUrl = input<string | undefined | null>();
|
avatarUrl = input<string | undefined | null>();
|
||||||
size = input<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('sm');
|
size = input<'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'>('sm');
|
||||||
ringClass = input<string>('');
|
ringClass = input<string>('');
|
||||||
status = input<UserStatus | undefined>();
|
status = input<UserStatus | undefined>();
|
||||||
showStatusBadge = input(false);
|
showStatusBadge = input(false);
|
||||||
|
|
||||||
statusBadgeColor = computed(() => {
|
statusBadgeColor = computed(() => {
|
||||||
switch (this.status()) {
|
switch (this.status()) {
|
||||||
case 'online': return 'bg-green-500';
|
case 'online':
|
||||||
case 'away': return 'bg-yellow-500';
|
return 'bg-green-500';
|
||||||
case 'busy': return 'bg-red-500';
|
case 'away':
|
||||||
case 'offline': return 'bg-gray-500';
|
return 'bg-yellow-500';
|
||||||
case 'disconnected': return 'bg-gray-500';
|
case 'busy':
|
||||||
default: return 'bg-gray-500';
|
return 'bg-red-500';
|
||||||
|
case 'offline':
|
||||||
|
return 'bg-gray-500';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'bg-gray-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
statusBadgeSizeClass = computed(() => {
|
statusBadgeSizeClass = computed(() => {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 'w-2 h-2';
|
case 'xs':
|
||||||
case 'sm': return 'w-3 h-3';
|
return 'w-2 h-2';
|
||||||
case 'md': return 'w-3.5 h-3.5';
|
case 'sm':
|
||||||
case 'lg': return 'w-4 h-4';
|
return 'w-3 h-3';
|
||||||
case 'xl': return 'w-4.5 h-4.5';
|
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';
|
||||||
|
case '2xl':
|
||||||
|
return 'w-6 h-6';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,31 +62,52 @@ export class UserAvatarComponent {
|
|||||||
|
|
||||||
sizeClasses(): string {
|
sizeClasses(): string {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 'w-7 h-7';
|
case 'xs':
|
||||||
case 'sm': return 'w-8 h-8';
|
return 'w-7 h-7';
|
||||||
case 'md': return 'w-10 h-10';
|
case 'sm':
|
||||||
case 'lg': return 'w-12 h-12';
|
return 'w-8 h-8';
|
||||||
case 'xl': return 'w-16 h-16';
|
case 'md':
|
||||||
|
return 'w-10 h-10';
|
||||||
|
case 'lg':
|
||||||
|
return 'w-12 h-12';
|
||||||
|
case 'xl':
|
||||||
|
return 'w-16 h-16';
|
||||||
|
case '2xl':
|
||||||
|
return 'w-32 h-32';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sizePx(): number {
|
sizePx(): number {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 28;
|
case 'xs':
|
||||||
case 'sm': return 32;
|
return 28;
|
||||||
case 'md': return 40;
|
case 'sm':
|
||||||
case 'lg': return 48;
|
return 32;
|
||||||
case 'xl': return 64;
|
case 'md':
|
||||||
|
return 40;
|
||||||
|
case 'lg':
|
||||||
|
return 48;
|
||||||
|
case 'xl':
|
||||||
|
return 64;
|
||||||
|
case '2xl':
|
||||||
|
return 128;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textClass(): string {
|
textClass(): string {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 'text-xs';
|
case 'xs':
|
||||||
case 'sm': return 'text-sm';
|
return 'text-xs';
|
||||||
case 'md': return 'text-base font-semibold';
|
case 'sm':
|
||||||
case 'lg': return 'text-lg font-semibold';
|
return 'text-sm';
|
||||||
case 'xl': return 'text-xl font-semibold';
|
case 'md':
|
||||||
|
return 'text-base font-semibold';
|
||||||
|
case 'lg':
|
||||||
|
return 'text-lg font-semibold';
|
||||||
|
case 'xl':
|
||||||
|
return 'text-xl font-semibold';
|
||||||
|
case '2xl':
|
||||||
|
return 'text-4xl font-semibold';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ function getDefaultTextChannelId(room: Room): string {
|
|||||||
return resolveActiveTextChannelId(enrichRoom(room).channels, 'general');
|
return resolveActiveTextChannelId(enrichRoom(room).channels, 'general');
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateRoomView(state: RoomsState, room: Room, isConnecting: boolean): RoomsState {
|
function activateRoomView(state: RoomsState, room: Room, isConnecting: boolean, updateSavedRooms = true): RoomsState {
|
||||||
const enriched = enrichRoom(room);
|
const enriched = enrichRoom(room);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentRoom: enriched,
|
currentRoom: enriched,
|
||||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
savedRooms: updateSavedRooms ? upsertRoom(state.savedRooms, enriched) : state.savedRooms,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
signalServerCompatibilityError: null,
|
signalServerCompatibilityError: null,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
@@ -237,7 +237,7 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => {
|
on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => {
|
||||||
if (skipBanCheck) {
|
if (skipBanCheck) {
|
||||||
return {
|
return {
|
||||||
...activateRoomView(state, room, true),
|
...activateRoomView(state, room, true, false),
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@ export const roomsReducer = createReducer(
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false)),
|
on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false, false)),
|
||||||
|
|
||||||
// Update room settings
|
// Update room settings
|
||||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user