fix: Mobile style fixes and other small ui fixes

This commit is contained in:
2026-05-18 23:14:16 +02:00
parent afb64520ed
commit 94428ed170
32 changed files with 808 additions and 239 deletions

View File

@@ -3,6 +3,9 @@ import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
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 type {
Channel,
@@ -28,6 +31,7 @@ import type {
PluginApiAvatarUpdate,
PluginApiActionContext,
PluginApiActionSource,
PluginApiAttachmentImportRequest,
PluginApiChannelRequest,
PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest,
@@ -44,11 +48,13 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
private readonly attachments = inject(AttachmentFacade);
private readonly capabilities = inject(PluginCapabilityService);
private readonly db = inject(DatabaseService);
private readonly logger = inject(PluginLoggerService);
private readonly messageBus = inject(PluginMessageBusService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService);
@@ -73,6 +79,10 @@ export class PluginClientApiService {
requireCapability('channels.manage');
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) => {
requireCapability('channels.manage');
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
@@ -143,6 +153,15 @@ export class PluginClientApiService {
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: {
addCustomAudioStream: async (request) => {
requireCapability('media.addAudioStream');
@@ -190,6 +209,10 @@ export class PluginClientApiService {
requireCapability('messages.send');
this.receivePluginUserMessage(pluginId, request);
},
import: async (messages) => {
requireCapability('messages.sync');
await this.importPluginMessages(pluginId, messages);
},
setTyping: (isTyping, channelId) => {
requireCapability('messages.send');
this.setTyping(pluginId, isTyping, channelId);
@@ -301,6 +324,58 @@ export class PluginClientApiService {
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) => {
requireCapability('server.manage');
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 {
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);

View File

@@ -74,6 +74,11 @@ export interface PluginApiAudioClipRequest {
url: string;
}
export interface PluginApiAttachmentImportRequest {
files: File[];
messageId: string;
}
export interface PluginApiCustomStreamRequest {
label?: string;
stream: MediaStream;
@@ -195,6 +200,7 @@ export interface PluginApiUiContributionMap {
export interface TojuClientPluginApi {
readonly channels: {
addAudioChannel: (request: PluginApiChannelRequest) => void;
addTextChannel: (request: PluginApiChannelRequest) => void;
addVideoChannel: (request: PluginApiChannelRequest) => void;
list: () => Channel[];
remove: (channelId: string) => void;
@@ -221,6 +227,9 @@ export interface TojuClientPluginApi {
remove: (key: string) => Promise<void>;
write: (key: string, value: unknown) => Promise<void>;
};
readonly attachments: {
import: (request: PluginApiAttachmentImportRequest) => Promise<void>;
};
readonly media: {
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
@@ -235,6 +244,7 @@ export interface TojuClientPluginApi {
readCurrent: () => Message[];
send: (content: string, channelId?: string) => Message;
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
import: (messages: Message[]) => Promise<void>;
setTyping: (isTyping: boolean, channelId?: string) => void;
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
sync: (messages: Message[]) => void;
@@ -261,6 +271,7 @@ export interface TojuClientPluginApi {
readonly server: {
getCurrent: () => Room | null;
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
updateIcon: (icon: string) => Promise<void>;
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
};

View File

@@ -1,13 +1,13 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<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"
>
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<div class="flex min-w-0 items-center gap-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 md:flex-1">
<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"
(click)="close()"
>
@@ -21,38 +21,40 @@
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
</div>
</div>
<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"
[disabled]="busyAll()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
(click)="openStore()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
<button
type="button"
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()"
(click)="activateAll()"
>
<ng-icon
name="lucidePlay"
size="16"
/>
Activate ready plugins
</button>
<button
type="button"
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()"
>
<ng-icon
name="lucideStore"
size="16"
/>
Open Plugin Store
</button>
</div>
</header>
<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"
>
<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'"
(click)="setTab('installed')"
>
@@ -64,7 +66,7 @@
</button>
<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'"
(click)="setTab('extensions')"
>
@@ -76,7 +78,7 @@
</button>
<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'"
(click)="setTab('requirements')"
>
@@ -88,7 +90,7 @@
</button>
<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'"
(click)="setTab('settings')"
>
@@ -100,7 +102,7 @@
</button>
<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'"
(click)="setTab('docs')"
>
@@ -112,7 +114,7 @@
</button>
<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'"
(click)="setTab('logs')"
>
@@ -124,7 +126,7 @@
</button>
</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()) {
@case ('extensions') {
<div class="space-y-4">
@@ -216,7 +218,7 @@
@for (entry of entries(); track trackEntry($index, entry)) {
<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)"
(click)="selectPlugin(entry.manifest.id)"
>
@@ -224,7 +226,7 @@
</button>
}
</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) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
@if (selectedSettingsPages().length > 0) {
@@ -255,7 +257,7 @@
@for (entry of entries(); track trackEntry($index, entry)) {
<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)"
(click)="selectPlugin(entry.manifest.id)"
>
@@ -263,14 +265,14 @@
</button>
}
</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) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
<div class="mt-4 flex flex-wrap gap-2">
@for (doc of selectedDocs(); track doc.label) {
<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"
target="_blank"
rel="noreferrer"
@@ -292,7 +294,7 @@
@for (entry of entries(); track trackEntry($index, entry)) {
<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)"
(click)="selectPlugin(entry.manifest.id)"
>
@@ -323,7 +325,7 @@
<div class="space-y-3">
@if (entries().length === 0) {
<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"
>
<ng-icon
@@ -337,7 +339,7 @@
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
<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-primary]="isSelected(entry)"
>
@@ -351,17 +353,17 @@
<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>
</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
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)"
>
Select
</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"
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)"
>
<ng-icon
@@ -372,7 +374,7 @@
</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"
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)"
(click)="activate(entry)"
>
@@ -384,7 +386,7 @@
</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"
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"
(click)="reload(entry)"
>
@@ -396,7 +398,7 @@
</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"
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"
(click)="unload(entry)"
>
@@ -416,7 +418,7 @@
}
</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) {
<div class="flex items-center gap-2">
<ng-icon
@@ -430,14 +432,14 @@
} @else {
<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)"
>
Grant all requested
</button>
<div class="mt-3 space-y-2">
@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
type="checkbox"
class="h-4 w-4"