feat: plugins v1.5

This commit is contained in:
2026-04-29 01:14:30 +02:00
parent 6920f93b41
commit eabbc08896
59 changed files with 2197 additions and 352 deletions

View File

@@ -17,8 +17,8 @@
/>
</button>
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">Plugins</h2>
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
<h2 class="truncate text-base font-semibold">{{ managerTitle() }}</h2>
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
</div>
</div>
<button
@@ -232,7 +232,7 @@
@for (page of selectedSettingsPages(); track page.id) {
<article class="rounded-md border border-border bg-background/40 p-3">
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
<app-plugin-render-host [render]="page.contribution.render"></app-plugin-render-host>
<app-plugin-render-host [render]="page.contribution.render" />
</article>
}
</div>
@@ -331,8 +331,8 @@
name="lucidePackage"
size="28"
/>
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
<p class="mt-3 text-sm font-medium">{{ emptyTitle() }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ emptyBody() }}</p>
</div>
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
@@ -370,6 +370,18 @@
/>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
<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"
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
(click)="activate(entry)"
>
<ng-icon
name="lucidePlay"
size="14"
/>
Activate
</button>
<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"

View File

@@ -5,6 +5,7 @@ import {
Output,
computed,
inject,
input,
signal
} from '@angular/core';
import { Router } from '@angular/router';
@@ -21,13 +22,14 @@ import {
lucideStore,
lucideX
} from '@ng-icons/lucide';
import type { PluginCapabilityId } from '../../../../shared-kernel';
import type { PluginCapabilityId, TojuPluginInstallScope } from '../../../../shared-kernel';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginHostService } from '../../application/services/plugin-host.service';
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service';
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
import type { RegisteredPlugin } from '../../domain/models/plugin-runtime.models';
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
@@ -60,6 +62,8 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
export class PluginManagerComponent {
@Output() readonly closed = new EventEmitter<void>();
readonly scope = input<TojuPluginInstallScope>('client');
readonly capabilities = inject(PluginCapabilityService);
readonly host = inject(PluginHostService);
readonly logger = inject(PluginLoggerService);
@@ -71,7 +75,12 @@ export class PluginManagerComponent {
readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(null);
readonly entries = this.registry.entries;
readonly allEntries = this.registry.entries;
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
readonly managerTitle = computed(() => this.scope() === 'server' ? 'Server plugins' : 'Client plugins');
readonly managerDescription = computed(() => this.scope() === 'server'
? 'Plugins installed for the current chat server.'
: 'Global client plugins installed on this device.');
readonly selectedPlugin = computed(() => {
const selectedPluginId = this.selectedPluginId();
@@ -89,17 +98,18 @@ export class PluginManagerComponent {
.slice(-20) : [];
});
readonly extensionCounts = computed(() => ({
appPages: this.uiRegistry.appPages().length,
channelSections: this.uiRegistry.channelSections().length,
composerActions: this.uiRegistry.composerActions().length,
embeds: this.uiRegistry.embeds().length,
profileActions: this.uiRegistry.profileActions().length,
settingsPages: this.uiRegistry.settingsPages().length,
sidePanels: this.uiRegistry.sidePanels().length,
toolbarActions: this.uiRegistry.toolbarActions().length
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
composerActions: this.uiRegistry.composerActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
embeds: this.uiRegistry.embedRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
}));
readonly requirementComparisons = this.requirementState.comparisons;
readonly uiConflicts = this.uiRegistry.conflicts;
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
readonly selectedRequirement = computed(() => {
const selectedPlugin = this.selectedPlugin();
@@ -113,6 +123,10 @@ export class PluginManagerComponent {
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
: [];
});
readonly emptyTitle = computed(() => this.scope() === 'server' ? 'No server plugins installed.' : 'No client plugins installed.');
readonly emptyBody = computed(() => this.scope() === 'server'
? 'Server-scoped plugins use scope: server in toju-plugin.json.'
: 'Client-scoped plugins use scope: client or omit scope in toju-plugin.json.');
readonly selectedDocs = computed(() => {
const manifest = this.selectedPlugin()?.manifest;
@@ -176,11 +190,21 @@ export class PluginManagerComponent {
}
}
async activate(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.activatePluginById(entry.manifest.id);
} finally {
this.busyPluginId.set(null);
}
}
async unload(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.deactivatePlugin(entry.manifest.id);
await this.host.deactivatePlugin(entry.manifest.id, { forgetActivation: true });
} finally {
this.busyPluginId.set(null);
}
@@ -194,6 +218,10 @@ export class PluginManagerComponent {
return this.selectedPlugin()?.manifest.id === entry.manifest.id;
}
isActive(entry: RegisteredPlugin): boolean {
return this.host.isPluginActive(entry.manifest.id);
}
close(): void {
this.closed.emit();
}
@@ -205,4 +233,12 @@ export class PluginManagerComponent {
trackCapability(index: number, capability: PluginCapabilityId): string {
return capability;
}
private entryBelongsToScope(entry: RegisteredPlugin): boolean {
return getPluginInstallScope(entry.manifest) === this.scope();
}
private hasVisiblePlugin(pluginId: string): boolean {
return this.entries().some((entry) => entry.manifest.id === pluginId);
}
}

View File

@@ -20,7 +20,9 @@
<div class="plugin-store__title-copy">
<h1>Plugin Store</h1>
<p>{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources</p>
<p>
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
</p>
</div>
</div>
@@ -52,10 +54,10 @@
<div class="plugin-store__source-form">
<label class="plugin-store__input-shell plugin-store__source-input">
<input
type="url"
type="text"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json"
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
aria-label="Plugin source manifest URL"
/>
</label>
@@ -232,8 +234,9 @@
type="button"
(click)="runPrimaryAction(plugin)"
[disabled]="isPrimaryActionDisabled(plugin)"
[title]="serverInstallButtonTitle(plugin)"
class="plugin-store__primary-button plugin-card__primary-action"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall'"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
@@ -311,4 +314,107 @@
</aside>
}
</div>
@if (serverInstallDialog(); as dialog) {
<div
class="plugin-store__modal-backdrop"
role="presentation"
></div>
<section
class="plugin-store__install-modal"
role="dialog"
aria-modal="true"
aria-labelledby="server-plugin-install-title"
>
<header class="plugin-store__install-header">
<div>
<p>Server plugin install</p>
<h2 id="server-plugin-install-title">{{ dialog.manifest.title }}</h2>
</div>
<button
type="button"
(click)="closeServerInstallDialog()"
class="plugin-store__icon-button"
title="Cancel install"
>
<ng-icon name="lucideX" />
</button>
</header>
<div class="plugin-store__install-body">
<label class="plugin-store__field">
<span>Install to server</span>
<select
[value]="dialog.selectedServerId"
[disabled]="serverInstallBusy()"
(change)="selectServerInstallTarget($any($event.target).value)"
>
@for (server of manageableServers(); track trackServer($index, server)) {
<option [value]="server.id">{{ server.name }}</option>
}
</select>
</label>
<label class="plugin-store__capability-row">
<input
type="checkbox"
[checked]="serverInstallOptional()"
[disabled]="serverInstallBusy()"
(change)="serverInstallOptional.set($any($event.target).checked)"
/>
<span>Optional for server members</span>
</label>
<div class="plugin-store__capability-list">
<div class="plugin-store__capability-list-header">
<h3>Capabilities</h3>
<span>{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
</div>
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
<label class="plugin-store__capability-row">
<input
type="checkbox"
[checked]="selectedCapabilityIds().has(capability)"
[disabled]="serverInstallBusy()"
(change)="toggleInstallCapability(capability, $any($event.target).checked)"
/>
<span>{{ capability }}</span>
</label>
}
} @else {
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
}
</div>
@if (serverInstallError()) {
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
}
</div>
<footer class="plugin-store__install-actions">
<button
type="button"
(click)="closeServerInstallDialog()"
[disabled]="serverInstallBusy()"
class="plugin-store__secondary-button"
>
Cancel
</button>
<button
type="button"
(click)="confirmServerInstall()"
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
class="plugin-store__primary-button"
>
<ng-icon
name="lucidePlus"
[class.is-spinning]="serverInstallBusy()"
/>
Install and Activate
</button>
</footer>
</section>
}
</main>

View File

@@ -262,6 +262,7 @@ ng-icon {
.plugin-store__source-row {
gap: 0.375rem;
min-width: 0;
}
.plugin-store__source-filter,
@@ -401,7 +402,6 @@ ng-icon {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-store__readme pre {
@@ -414,6 +414,123 @@ ng-icon {
background: hsl(var(--secondary) / 0.5);
}
.plugin-store__modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgb(0 0 0 / 0.6);
}
.plugin-store__install-modal {
position: fixed;
z-index: 81;
top: 50%;
left: 50%;
display: flex;
width: min(34rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100vh - 2rem));
flex-direction: column;
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--foreground));
background: hsl(var(--card));
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
}
.plugin-store__install-header,
.plugin-store__install-actions,
.plugin-store__capability-list-header,
.plugin-store__capability-row {
display: flex;
align-items: center;
}
.plugin-store__install-header {
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid hsl(var(--border));
padding: 1rem;
}
.plugin-store__install-header p,
.plugin-store__install-header h2,
.plugin-store__capability-list-header h3,
.plugin-store__muted-text {
margin: 0;
}
.plugin-store__install-header p,
.plugin-store__field span,
.plugin-store__capability-list-header span,
.plugin-store__muted-text {
color: hsl(var(--muted-foreground));
font-size: 0.78rem;
}
.plugin-store__install-header h2 {
margin-top: 0.2rem;
font-size: 1.05rem;
}
.plugin-store__install-body {
display: grid;
min-height: 0;
gap: 1rem;
overflow: auto;
padding: 1rem;
}
.plugin-store__field {
display: grid;
gap: 0.4rem;
}
.plugin-store__field select {
min-height: 2.25rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.45rem 0.65rem;
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.plugin-store__capability-list {
display: grid;
gap: 0.4rem;
}
.plugin-store__capability-list-header {
justify-content: space-between;
gap: 0.75rem;
}
.plugin-store__capability-list-header h3 {
font-size: 0.9rem;
}
.plugin-store__capability-row {
gap: 0.55rem;
border: 1px solid hsl(var(--border));
border-radius: 0.45rem;
padding: 0.5rem 0.6rem;
background: hsl(var(--background) / 0.5);
font-size: 0.82rem;
}
.plugin-store__capability-row input {
width: 1rem;
height: 1rem;
}
.plugin-store__install-actions {
justify-content: flex-end;
gap: 0.5rem;
border-top: 1px solid hsl(var(--border));
padding: 0.85rem 1rem;
}
.plugin-store__empty {
display: grid;
min-height: 14rem;

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Store as NgRxStore } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
@@ -24,9 +25,25 @@ import {
} from '@ng-icons/lucide';
import { ExternalLinkService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
import type {
PluginCapabilityId,
Room,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginStoreService } from '../../application/services/plugin-store.service';
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
interface ServerPluginInstallDialog {
manifest: TojuPluginManifest;
plugin: PluginStoreEntry;
selectedServerId: string;
}
@Component({
selector: 'app-plugin-store',
standalone: true,
@@ -54,6 +71,28 @@ import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/pl
})
export class PluginStoreComponent implements OnInit {
readonly store = inject(PluginStoreService);
readonly capabilities = inject(PluginCapabilityService);
readonly ngrxStore = inject(NgRxStore);
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
readonly manageableServers = computed(() => {
const user = this.currentUser();
if (!user) {
return [];
}
const roomsById = new Map(this.savedRooms().map((room) => [room.id, room]));
const currentRoom = this.currentRoom();
if (currentRoom) {
roomsById.set(currentRoom.id, currentRoom);
}
return Array.from(roomsById.values())
.filter((room) => this.canManageServerPlugins(room, user));
});
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
readonly filteredPlugins = computed(() => {
@@ -96,6 +135,11 @@ export class PluginStoreComponent implements OnInit {
readonly readme = signal<PluginStoreReadme | null>(null);
readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(null);
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
readonly serverInstallOptional = signal(false);
readonly serverInstallError = signal<string | null>(null);
readonly serverInstallBusy = signal(false);
private destroyed = false;
private readonly destroyRef = inject(DestroyRef);
@@ -181,8 +225,10 @@ export class PluginStoreComponent implements OnInit {
this.actionBusyPluginId.set(plugin.id);
try {
if (action === 'Uninstall') {
this.store.uninstallPlugin(plugin.id);
if (action === 'Uninstall' || action === 'Remove from Server') {
await this.store.uninstallPlugin(plugin.id, plugin.scope);
} else if (this.isServerScopedPlugin(plugin)) {
await this.openServerInstallDialog(plugin);
} else {
await this.store.installPlugin(plugin);
}
@@ -229,13 +275,118 @@ export class PluginStoreComponent implements OnInit {
this.readmeError.set(null);
}
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
this.actionBusyPluginId.set(plugin.id);
this.serverInstallError.set(null);
try {
const manifest = await this.store.loadInstallManifest(plugin);
const selectedServerId = this.defaultServerInstallTargetId();
if (!selectedServerId) {
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
}
this.selectedCapabilityIds.set(new Set(manifest.capabilities ?? []));
this.serverInstallOptional.set(false);
this.serverInstallDialog.set({ manifest, plugin, selectedServerId });
} catch (error) {
if (this.destroyed) {
return;
}
this.actionError.set(error instanceof Error ? error.message : 'Unable to prepare server plugin install');
} finally {
if (!this.destroyed) {
this.actionBusyPluginId.set(null);
}
}
}
closeServerInstallDialog(): void {
if (this.serverInstallBusy()) {
return;
}
this.serverInstallDialog.set(null);
this.serverInstallError.set(null);
this.serverInstallOptional.set(false);
this.selectedCapabilityIds.set(new Set());
}
selectServerInstallTarget(serverId: string): void {
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
}
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
this.selectedCapabilityIds.update((capabilities) => {
const nextCapabilities = new Set(capabilities);
if (checked) {
nextCapabilities.add(capability);
} else {
nextCapabilities.delete(capability);
}
return nextCapabilities;
});
}
async confirmServerInstall(): Promise<void> {
const dialog = this.serverInstallDialog();
if (!dialog) {
return;
}
this.serverInstallBusy.set(true);
this.serverInstallError.set(null);
try {
for (const capability of dialog.manifest.capabilities ?? []) {
if (this.selectedCapabilityIds().has(capability)) {
this.capabilities.grant(dialog.manifest.id, capability);
} else {
this.capabilities.revoke(dialog.manifest.id, capability);
}
}
await this.store.installPlugin(dialog.plugin, {
activate: true,
manifest: dialog.manifest,
optional: this.serverInstallOptional(),
serverId: dialog.selectedServerId
});
if (this.destroyed) {
return;
}
this.serverInstallDialog.set(null);
this.serverInstallOptional.set(false);
this.selectedCapabilityIds.set(new Set());
} catch (error) {
if (this.destroyed) {
return;
}
this.serverInstallError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
} finally {
if (!this.destroyed) {
this.serverInstallBusy.set(false);
}
}
}
goBack(): void {
void this.router.navigateByUrl(this.getReturnUrl());
}
async openManager(): Promise<void> {
const currentRoomId = this.currentRoom()?.id;
await this.router.navigateByUrl(this.getReturnUrl());
this.settingsModal.open('plugins');
this.settingsModal.open(this.store.hasActiveServerInstallScope() ? 'serverPlugins' : 'plugins', currentRoomId);
}
selectSource(sourceUrl: string | null): void {
@@ -262,9 +413,18 @@ export class PluginStoreComponent implements OnInit {
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
return this.isPluginBusy(plugin)
|| !this.canRunPrimaryAction(plugin)
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
}
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
if (!this.isServerScopedPlugin(plugin)) {
return this.store.canInstallPlugin(plugin);
}
return this.manageableServers().length > 0;
}
primaryActionIcon(plugin: PluginStoreEntry): string {
const action = this.store.getActionLabel(plugin);
@@ -272,6 +432,10 @@ export class PluginStoreComponent implements OnInit {
return 'lucideTrash2';
}
if (action === 'Remove from Server') {
return 'lucideTrash2';
}
return 'lucidePlus';
}
@@ -287,6 +451,24 @@ export class PluginStoreComponent implements OnInit {
}
}
trackServer(index: number, server: Room): string {
return server.id;
}
trackInstallCapability(index: number, capability: PluginCapabilityId): string {
return capability;
}
isServerScopedPlugin(plugin: PluginStoreEntry): boolean {
return plugin.scope === 'server';
}
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
? 'Requires owner or Manage Server access on a chat server'
: this.store.getActionLabel(plugin);
}
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
return [
plugin.author,
@@ -307,4 +489,14 @@ export class PluginStoreComponent implements OnInit {
return '/search';
}
private defaultServerInstallTargetId(): string | null {
const currentRoomId = this.currentRoom()?.id ?? null;
return this.manageableServers().find((room) => room.id === currentRoomId)?.id ?? this.manageableServers()[0]?.id ?? null;
}
private canManageServerPlugins(room: Room, user: User): boolean {
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
}
}