fix: improve plugins functionality with server management
This commit is contained in:
@@ -8,11 +8,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (currentRoom()?.icon) {
|
||||
<img
|
||||
[src]="currentRoom()!.icon"
|
||||
[alt]="currentRoom()!.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + currentRoom()!.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||
@if (room.icon) {
|
||||
<img
|
||||
[src]="room.icon"
|
||||
[alt]="room.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + room.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
Type,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -7,7 +15,17 @@ import { Store } from '@ngrx/store';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
|
||||
import {
|
||||
EMPTY,
|
||||
Subject,
|
||||
catchError,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
switchMap,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../../shared-kernel';
|
||||
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
|
||||
@@ -20,7 +38,11 @@ import { NotificationsFacade } from '../../../domains/notifications';
|
||||
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent
|
||||
} from '../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
@@ -143,7 +165,8 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
if (!name)
|
||||
return '?';
|
||||
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
|
||||
@@ -195,7 +218,8 @@ export class ServersRailComponent {
|
||||
confirmPasswordJoin(): void {
|
||||
const room = this.passwordPromptRoom();
|
||||
|
||||
if (!room) return;
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
|
||||
@@ -235,7 +259,8 @@ export class ServersRailComponent {
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
if (!ctx) return;
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||
|
||||
@@ -338,7 +363,8 @@ export class ServersRailComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId) return EMPTY;
|
||||
if (!currentUserId)
|
||||
return EMPTY;
|
||||
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
@@ -54,7 +62,7 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
case 'running':
|
||||
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
|
||||
case 'starting':
|
||||
return 'Starting…';
|
||||
return 'Starting...';
|
||||
case 'error':
|
||||
return `Error: ${snapshot.error ?? 'unknown error'}`;
|
||||
case 'stopped':
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
|
||||
@if (serverData()?.icon) {
|
||||
<img
|
||||
[src]="serverData()!.icon"
|
||||
[alt]="serverData()!.name + ' icon'"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||
[style.backgroundImage]="'url(' + serverData()!.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Room } from '../../../../shared-kernel';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
@@ -50,6 +51,7 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
export class ServerSettingsComponent {
|
||||
private store = inject(Store);
|
||||
private modal = inject(SettingsModalService);
|
||||
private serverIconImages = inject(ServerIconImageService);
|
||||
|
||||
/** The currently selected server, passed from the parent. */
|
||||
server = input<Room | null>(null);
|
||||
@@ -181,7 +183,7 @@ export class ServerSettingsComponent {
|
||||
this.modal.navigate('network');
|
||||
}
|
||||
|
||||
onServerIconSelected(event: Event): void {
|
||||
async onServerIconSelected(event: Event): Promise<void> {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const file = inputElement.files?.[0];
|
||||
|
||||
@@ -191,37 +193,24 @@ export class ServerSettingsComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.iconError.set('Choose an image file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 512 * 1024) {
|
||||
this.iconError.set('Choose an image smaller than 512 KB.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const room = this.server();
|
||||
const icon = typeof reader.result === 'string' ? reader.result : '';
|
||||
const icon = await this.serverIconImages.process(file);
|
||||
|
||||
if (!room || !icon) {
|
||||
this.iconError.set('Could not read that image.');
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.iconError.set(null);
|
||||
this.store.dispatch(RoomsActions.updateServerIcon({
|
||||
roomId: room.id,
|
||||
icon
|
||||
icon: icon.dataUrl
|
||||
}));
|
||||
this.showSaveSuccess('icon');
|
||||
};
|
||||
|
||||
reader.onerror = () => this.iconError.set('Could not read that image.');
|
||||
reader.readAsDataURL(file);
|
||||
this.showSaveSuccess('icon');
|
||||
} catch (error) {
|
||||
this.iconError.set(error instanceof Error ? error.message : 'Could not read that image.');
|
||||
}
|
||||
}
|
||||
|
||||
removeServerIcon(): void {
|
||||
@@ -236,6 +225,7 @@ export class ServerSettingsComponent {
|
||||
roomId: room.id,
|
||||
icon: ''
|
||||
}));
|
||||
|
||||
this.showSaveSuccess('icon');
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,24 @@
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (hasServerPlugins()) {
|
||||
<button
|
||||
type="button"
|
||||
class="relative grid h-8 w-8 place-items-center rounded-md text-foreground transition-colors hover:bg-secondary"
|
||||
(click)="openServerPlugins()"
|
||||
title="Server plugins"
|
||||
aria-label="Server plugins"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="absolute right-0 top-0 min-w-3 rounded-full bg-primary px-1 text-[9px] font-semibold leading-3 text-primary-foreground">
|
||||
{{ serverPluginCount() }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isElectron()) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,6 +245,123 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (optionalPluginRequirement(); as requirement) {
|
||||
<section
|
||||
class="flex min-h-10 items-center justify-between gap-3 border-b border-border bg-primary/10 px-4 py-2 text-sm text-foreground"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucidePackage"
|
||||
class="h-4 w-4 shrink-0 text-primary"
|
||||
/>
|
||||
<p class="truncate">
|
||||
Optional server plugin available:
|
||||
<span class="font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
||||
@if (optionalPluginRequirementCount() > 1) {
|
||||
<span class="text-muted-foreground">+{{ optionalPluginRequirementCount() - 1 }} more</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if (pluginRequirementError()) {
|
||||
<span class="max-w-56 truncate text-xs text-destructive">{{ pluginRequirementError() }}</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="rejectOptionalServerPlugin(requirement)"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="hideOptionalServerPlugin(requirement)"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-primary bg-primary px-2.5 py-1 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="installOptionalServerPlugin(requirement)"
|
||||
>
|
||||
{{ pluginRequirementBusy() ? 'Installing' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (requiredPluginRequirements().length > 0 && currentRoom()) {
|
||||
<div
|
||||
class="fixed inset-0 z-[80] bg-black/60"
|
||||
role="presentation"
|
||||
></div>
|
||||
<section
|
||||
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(38rem,calc(100vh-2rem))] w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="required-server-plugin-title"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<header class="border-b border-border p-4">
|
||||
<p class="text-sm text-muted-foreground">Required server plugins</p>
|
||||
<h2
|
||||
id="required-server-plugin-title"
|
||||
class="mt-1 text-lg font-semibold"
|
||||
>
|
||||
{{ currentRoom()!.name }} requires a plugin update
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 space-y-3 overflow-auto p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
An admin added required plugins for this server. Install them to keep using the server, or leave the server.
|
||||
</p>
|
||||
@for (requirement of requiredPluginRequirements(); track requirement.pluginId) {
|
||||
<article class="rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
|
||||
@if (requirement.reason) {
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ requirement.reason }}</p>
|
||||
}
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
@if (pluginRequirementError()) {
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginRequirementError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="confirmLeave({})"
|
||||
>
|
||||
Leave server
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
|
||||
[disabled]="pluginRequirementBusy()"
|
||||
(click)="installRequiredServerPlugins()"
|
||||
>
|
||||
{{ pluginRequirementBusy() ? 'Installing' : 'Install plugins' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucidePackage,
|
||||
lucideRefreshCw
|
||||
lucideRefreshCw,
|
||||
lucideShield
|
||||
} from '@ng-icons/lucide';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -42,9 +43,15 @@ import { PlatformService } from '../../../core/platform';
|
||||
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { LeaveServerDialogComponent } from '../../../shared';
|
||||
import { Room } from '../../../shared-kernel';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
@@ -64,7 +71,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucidePackage,
|
||||
lucideRefreshCw })
|
||||
lucideRefreshCw,
|
||||
lucideShield })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
@@ -80,6 +88,9 @@ export class TitleBarComponent {
|
||||
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 getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
@@ -153,11 +164,20 @@ export class TitleBarComponent {
|
||||
|| 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() {
|
||||
@@ -192,6 +212,17 @@ export class TitleBarComponent {
|
||||
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');
|
||||
@@ -267,6 +298,24 @@ export class TitleBarComponent {
|
||||
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;
|
||||
@@ -294,6 +343,25 @@ export class TitleBarComponent {
|
||||
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 : 'Unable to install server plugin');
|
||||
} finally {
|
||||
this.pluginRequirementBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
Reference in New Issue
Block a user