All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 7m55s
Queue Release Build / build-windows (push) Successful in 28m37s
Queue Release Build / build-linux (push) Successful in 47m3s
Queue Release Build / build-android (push) Successful in 20m33s
Queue Release Build / finalize (push) Successful in 3m48s
Expose settings logout on mobile where the title bar is hidden, and enable Capacitor data settings with storage visibility and local erase/sign-out. Co-authored-by: Cursor <cursoragent@cursor.com>
421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
Component,
|
|
inject,
|
|
computed,
|
|
signal
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import { Store } from '@ngrx/store';
|
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
import {
|
|
lucideMinus,
|
|
lucideSquare,
|
|
lucideX,
|
|
lucideChevronLeft,
|
|
lucideHash,
|
|
lucideMenu,
|
|
lucidePackage,
|
|
lucideRefreshCw,
|
|
lucideShield
|
|
} from '@ng-icons/lucide';
|
|
import { NavigationEnd, Router } from '@angular/router';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
import { filter, map } from 'rxjs';
|
|
import {
|
|
selectCurrentRoom,
|
|
selectActiveChannelId,
|
|
selectTextChannels,
|
|
selectVoiceChannels,
|
|
selectIsSignalServerReconnecting,
|
|
selectSignalServerCompatibilityError
|
|
} from '../../../store/rooms/rooms.selectors';
|
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
|
import { RealtimeSessionFacade } from '../../../core/realtime';
|
|
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
|
import { PlatformService } from '../../../core/platform';
|
|
import { UserLogoutService } from '../../../domains/authentication/application/services/user-logout.service';
|
|
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
|
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
|
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
|
|
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
|
import {
|
|
PluginRegistryService,
|
|
PluginRequirementStateService,
|
|
PluginStoreService
|
|
} from '../../../domains/plugins';
|
|
import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plugin-install-scope.logic';
|
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
|
|
|
@Component({
|
|
selector: 'app-title-bar',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
NgIcon,
|
|
LeaveServerDialogComponent,
|
|
ThemeNodeDirective,
|
|
ModalBackdropComponent,
|
|
...APP_TRANSLATE_IMPORTS
|
|
],
|
|
viewProviders: [
|
|
provideIcons({ lucideMinus,
|
|
lucideSquare,
|
|
lucideX,
|
|
lucideChevronLeft,
|
|
lucideHash,
|
|
lucideMenu,
|
|
lucidePackage,
|
|
lucideRefreshCw,
|
|
lucideShield })
|
|
],
|
|
templateUrl: './title-bar.component.html'
|
|
})
|
|
/**
|
|
* Electron-style title bar with window controls, navigation, and server menu.
|
|
*/
|
|
export class TitleBarComponent {
|
|
private readonly appI18n = inject(AppI18nService);
|
|
private store = inject(Store);
|
|
private electronBridge = inject(ElectronBridgeService);
|
|
private serverDirectory = inject(ServerDirectoryFacade);
|
|
private router = inject(Router);
|
|
private webrtc = inject(RealtimeSessionFacade);
|
|
private platform = inject(PlatformService);
|
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
|
private settingsModal = inject(SettingsModalService);
|
|
private pluginRegistry = inject(PluginRegistryService);
|
|
private pluginRequirements = inject(PluginRequirementStateService);
|
|
private pluginStore = inject(PluginStoreService);
|
|
private userLogout = inject(UserLogoutService);
|
|
|
|
private getWindowControlsApi() {
|
|
return this.electronBridge.getApi();
|
|
}
|
|
|
|
isElectron = computed(() => this.platform.isElectron);
|
|
showMenuState = computed(() => false);
|
|
|
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
|
|
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
|
|
isConnected = computed(() => this.webrtc.isConnected());
|
|
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
|
isAuthed = computed(() => !!this.currentUser());
|
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
textChannels = this.store.selectSignal(selectTextChannels);
|
|
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
|
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
|
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
|
isInDirectMessage = toSignal(
|
|
this.router.events.pipe(
|
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
|
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/'))
|
|
),
|
|
{ initialValue: this.router.url.startsWith('/dm/') }
|
|
);
|
|
isInRoomView = toSignal(
|
|
this.router.events.pipe(
|
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
|
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/room/'))
|
|
),
|
|
{ initialValue: this.router.url.startsWith('/room/') }
|
|
);
|
|
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
|
|
roomName = computed(() => this.currentRoom()?.name || '');
|
|
activeTextChannelName = computed(() => {
|
|
const textChannels = this.textChannels();
|
|
|
|
if (textChannels.length === 0) {
|
|
return this.appI18n.instant('shell.titleBar.noTextChannels');
|
|
}
|
|
|
|
const id = this.activeChannelId();
|
|
const activeChannel = textChannels.find((channel) => channel.id === id) ?? textChannels[0];
|
|
|
|
return activeChannel ? activeChannel.name : id;
|
|
});
|
|
connectedVoiceChannelName = computed(() => {
|
|
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
|
|
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
|
|
|
|
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
|
|
});
|
|
roomContextMeta = computed(() => {
|
|
if (!this.currentRoom()) {
|
|
return '';
|
|
}
|
|
|
|
const parts = [this.appI18n.instant('shell.titleBar.textChannelCount', { count: this.textChannels().length })];
|
|
|
|
if (this.voiceChannels().length > 0) {
|
|
parts.push(this.appI18n.instant('shell.titleBar.voiceChannelCount', { count: this.voiceChannels().length }));
|
|
}
|
|
|
|
return parts.join(' | ');
|
|
});
|
|
showRoomCompatibilityNotice = computed(() =>
|
|
this.inRoom() && !!this.signalServerCompatibilityError()
|
|
);
|
|
showRoomReconnectNotice = computed(() =>
|
|
this.inRoom()
|
|
&& !this.signalServerCompatibilityError()
|
|
&& (
|
|
this.isSignalServerReconnecting()
|
|
|| this.webrtc.shouldShowConnectionError()
|
|
|| this.isReconnecting()
|
|
)
|
|
);
|
|
serverPluginCount = computed(() => this.pluginRegistry.entries()
|
|
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
|
|
.length);
|
|
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
|
|
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
|
|
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
|
|
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
|
|
private _showMenu = signal(false);
|
|
showMenu = computed(() => this._showMenu());
|
|
showLeaveConfirm = signal(false);
|
|
inviteStatus = signal<string | null>(null);
|
|
creatingInvite = signal(false);
|
|
pluginRequirementBusy = signal(false);
|
|
pluginRequirementError = signal<string | null>(null);
|
|
|
|
/** Minimize the Electron window. */
|
|
minimize() {
|
|
const api = this.getWindowControlsApi();
|
|
|
|
api?.minimizeWindow?.();
|
|
}
|
|
|
|
/** Maximize or restore the Electron window. */
|
|
maximize() {
|
|
const api = this.getWindowControlsApi();
|
|
|
|
api?.maximizeWindow?.();
|
|
}
|
|
|
|
/** Close the Electron window. */
|
|
close() {
|
|
const api = this.getWindowControlsApi();
|
|
|
|
api?.closeWindow?.();
|
|
}
|
|
|
|
/** Navigate to the login page. */
|
|
goLogin() {
|
|
this.router.navigate(['/login'], {
|
|
queryParams: buildLoginReturnQueryParams(this.router.url)
|
|
});
|
|
}
|
|
|
|
openPluginStore(): void {
|
|
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
|
|
|
|
this._showMenu.set(false);
|
|
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
|
}
|
|
|
|
openServerPlugins(): void {
|
|
const roomId = this.currentRoom()?.id;
|
|
|
|
if (!roomId) {
|
|
return;
|
|
}
|
|
|
|
this._showMenu.set(false);
|
|
this.settingsModal.open('serverPlugins', roomId);
|
|
}
|
|
|
|
openSettings(): void {
|
|
this._showMenu.set(false);
|
|
this.settingsModal.open('general');
|
|
}
|
|
|
|
async openDocumentation(): Promise<void> {
|
|
const api = this.electronBridge.getApi();
|
|
|
|
this._showMenu.set(false);
|
|
|
|
if (!api) {
|
|
return;
|
|
}
|
|
|
|
const result = await api.openDocusaurusDocs();
|
|
|
|
if (result && !result.opened) {
|
|
this.inviteStatus.set(result.reason ?? this.appI18n.instant('shell.titleBar.docsOpenFailed'));
|
|
}
|
|
}
|
|
|
|
/** Open the unified leave-server confirmation dialog. */
|
|
private openLeaveConfirm() {
|
|
this._showMenu.set(false);
|
|
|
|
if (this.currentRoom()) {
|
|
this.showLeaveConfirm.set(true);
|
|
}
|
|
}
|
|
|
|
/** Toggle the server dropdown menu. */
|
|
toggleMenu() {
|
|
this.inviteStatus.set(null);
|
|
this._showMenu.set(!this._showMenu());
|
|
}
|
|
|
|
/** Create a new invite link for the active room and copy it to the clipboard. */
|
|
async createInviteLink(): Promise<void> {
|
|
const room = this.currentRoom();
|
|
const user = this.currentUser();
|
|
|
|
if (!room || !user || this.creatingInvite()) {
|
|
return;
|
|
}
|
|
|
|
this.creatingInvite.set(true);
|
|
this.inviteStatus.set(this.appI18n.instant('shell.titleBar.creatingInvite'));
|
|
|
|
try {
|
|
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
|
room.id,
|
|
{
|
|
requesterDisplayName: user.displayName,
|
|
requesterRole: user.role
|
|
},
|
|
this.toSourceSelector(room)
|
|
));
|
|
|
|
await this.copyInviteLink(invite.browserUrl);
|
|
this.inviteStatus.set(this.appI18n.instant('shell.titleBar.inviteCopied'));
|
|
} catch (error: unknown) {
|
|
const inviteError = error as { error?: { error?: string } };
|
|
|
|
this.inviteStatus.set(inviteError?.error?.error || this.appI18n.instant('shell.titleBar.inviteCreateFailed'));
|
|
} finally {
|
|
this.creatingInvite.set(false);
|
|
}
|
|
}
|
|
|
|
/** Leave the current server and navigate to the servers list. */
|
|
leaveServer() {
|
|
this.openLeaveConfirm();
|
|
}
|
|
|
|
installRequiredServerPlugins(): void {
|
|
void this.installServerRequirements(this.requiredPluginRequirements());
|
|
}
|
|
|
|
installOptionalServerPlugin(requirement: PluginRequirementSummary): void {
|
|
void this.installServerRequirements([requirement]);
|
|
}
|
|
|
|
rejectOptionalServerPlugin(requirement: PluginRequirementSummary): void {
|
|
this.pluginRequirements.dismissOptionalRequirement(requirement);
|
|
this.pluginRequirementError.set(null);
|
|
}
|
|
|
|
hideOptionalServerPlugin(requirement: PluginRequirementSummary): void {
|
|
this.pluginRequirements.dismissOptionalRequirement(requirement, { persist: true });
|
|
this.pluginRequirementError.set(null);
|
|
}
|
|
|
|
/** Confirm the unified leave action and remove the server locally. */
|
|
confirmLeave(result: { nextOwnerKey?: string }) {
|
|
const roomId = this.currentRoom()?.id;
|
|
|
|
this.showLeaveConfirm.set(false);
|
|
|
|
if (!roomId)
|
|
return;
|
|
|
|
this.store.dispatch(RoomsActions.forgetRoom({
|
|
roomId,
|
|
nextOwnerKey: result.nextOwnerKey
|
|
}));
|
|
|
|
this.router.navigate(['/dashboard']);
|
|
}
|
|
|
|
/** Cancel the leave-server confirmation dialog. */
|
|
cancelLeave() {
|
|
this.showLeaveConfirm.set(false);
|
|
}
|
|
|
|
/** Close the server dropdown menu. */
|
|
closeMenu() {
|
|
this._showMenu.set(false);
|
|
}
|
|
|
|
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
|
|
const room = this.currentRoom();
|
|
|
|
if (!room || requirements.length === 0 || this.pluginRequirementBusy()) {
|
|
return;
|
|
}
|
|
|
|
this.pluginRequirementBusy.set(true);
|
|
this.pluginRequirementError.set(null);
|
|
|
|
try {
|
|
await this.pluginStore.installServerRequirementsLocally(room.id, requirements, { activate: true });
|
|
} catch (error) {
|
|
this.pluginRequirementError.set(error instanceof Error ? error.message : this.appI18n.instant('shell.titleBar.pluginInstallFailed'));
|
|
} finally {
|
|
this.pluginRequirementBusy.set(false);
|
|
}
|
|
}
|
|
|
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
|
logout() {
|
|
this._showMenu.set(false);
|
|
this.userLogout.logout();
|
|
}
|
|
|
|
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
|
if (navigator.clipboard?.writeText) {
|
|
try {
|
|
await navigator.clipboard.writeText(inviteUrl);
|
|
return;
|
|
} catch {}
|
|
}
|
|
|
|
const textarea = document.createElement('textarea');
|
|
|
|
textarea.value = inviteUrl;
|
|
textarea.setAttribute('readonly', 'true');
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
textarea.style.pointerEvents = 'none';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
|
|
try {
|
|
const copied = document.execCommand('copy');
|
|
|
|
if (copied) {
|
|
return;
|
|
}
|
|
} catch {
|
|
/* fall through to prompt fallback */
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
|
|
window.prompt(this.appI18n.instant('shell.titleBar.copyInvitePrompt'), inviteUrl);
|
|
}
|
|
|
|
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
|
return {
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
};
|
|
}
|
|
}
|