Files
Toju/toju-app/src/app/features/shell/title-bar/title-bar.component.ts
Myx 07e91a0d09
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
fix: Bug - Add logout in mobile version of settings, allow clearing data on android
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>
2026-06-11 22:31:40 +02:00

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
};
}
}