fix: improve plugins functionality with server management
This commit is contained in:
@@ -153,12 +153,16 @@ The API service normalises every `ServerInfo` response, filling in `sourceId`, `
|
||||
|
||||
That search fan-out is discovery only. Once a room is created or joined, the room keeps an authoritative signal-server affinity via its `sourceId` / `sourceUrl`. The join response can repair stale saved metadata, and reconnect logic now retries that authoritative endpoint first before probing any other configured endpoints.
|
||||
|
||||
The `/search` My Servers row and the server rail both read from the active user's local room ownership. Switching accounts reloads that scoped cache so joined servers and local history do not bleed between users.
|
||||
|
||||
Fallback stays temporary. If the authoritative endpoint is unavailable, the client can probe other active compatible endpoints as a last resort for the current session, but it does not rewrite the room's saved affinity to that fallback endpoint.
|
||||
|
||||
## Server-owned room metadata
|
||||
|
||||
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
|
||||
|
||||
Server icons are uploaded through the server settings page. Static sources are drawn into a `64x64` canvas and encoded using the smallest browser-supported output among WebP, JPEG, and PNG. Small animated GIF/WebP icons are kept animated. Server icon UI surfaces render the image as a CSS background instead of an `<img>` element so the icon cannot be dragged out of the app.
|
||||
|
||||
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation deduplicates channel names within each channel type, so a text `general` channel and a voice `General` channel can coexist while duplicate voice-to-voice or text-to-text names are still rejected.
|
||||
|
||||
## Default endpoint management
|
||||
|
||||
@@ -103,11 +103,11 @@
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
||||
@if (server.icon) {
|
||||
<img
|
||||
[src]="server.icon"
|
||||
[alt]="server.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(' + server.icon + ')'"
|
||||
></div>
|
||||
} @else {
|
||||
{{ server.name[0] || '?' }}
|
||||
}
|
||||
@@ -297,6 +297,96 @@
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (pluginConsentDialog(); as dialog) {
|
||||
<div
|
||||
class="fixed inset-0 z-50 bg-black/50"
|
||||
role="presentation"
|
||||
></div>
|
||||
<section
|
||||
class="fixed left-1/2 top-1/2 z-[51] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,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="join-plugin-consent-title"
|
||||
>
|
||||
<header class="border-b border-border p-4">
|
||||
<p class="text-sm text-muted-foreground">Plugin downloads</p>
|
||||
<h2
|
||||
id="join-plugin-consent-title"
|
||||
class="mt-1 text-lg font-semibold"
|
||||
>
|
||||
{{ dialog.server.name }} uses plugins
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="grid min-h-0 gap-4 overflow-auto p-4">
|
||||
@if (dialog.required.length > 0) {
|
||||
<section class="grid gap-2">
|
||||
<h3 class="text-sm font-semibold">Required before joining</h3>
|
||||
@for (requirement of dialog.required; track requirement.pluginId) {
|
||||
<div 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>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (dialog.optional.length > 0) {
|
||||
<section class="grid gap-2">
|
||||
<h3 class="text-sm font-semibold">Optional plugins</h3>
|
||||
@for (requirement of dialog.optional; track requirement.pluginId) {
|
||||
<label class="flex items-start gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
|
||||
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
||||
@if (requirement.reason) {
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pluginConsentError()) {
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
(click)="closePluginConsentDialog()"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
Cancel join
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmPluginConsent()"
|
||||
[disabled]="pluginConsentBusy()"
|
||||
class="inline-flex min-h-8 items-center justify-center 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:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{{ pluginConsentBusy() ? 'Downloading' : dialog.required.length > 0 ? 'Accept and join' : 'Join' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, effect, inject, OnInit, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { debounceTime, distinctUntilChanged, firstValueFrom, Subject } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import { Room, User } from '../../../../shared-kernel';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
Room,
|
||||
User,
|
||||
type PluginRequirementSummary
|
||||
} from '../../../../shared-kernel';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
type LeaveServerDialogResult
|
||||
} from '../../../../shared';
|
||||
import { hasRoomBanForUser } from '../../../access-control';
|
||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
|
||||
|
||||
interface JoinPluginConsentDialog {
|
||||
optional: PluginRequirementSummary[];
|
||||
password?: string;
|
||||
required: PluginRequirementSummary[];
|
||||
server: ServerInfo;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
UserSearchListComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSearch,
|
||||
@@ -49,6 +96,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private pluginRequirements = inject(PluginRequirementService);
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
@@ -69,6 +118,10 @@ export class ServerSearchComponent implements OnInit {
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
joinedServerMenuId = signal<string | null>(null);
|
||||
leaveDialogRoom = signal<Room | null>(null);
|
||||
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||
pluginConsentBusy = signal(false);
|
||||
pluginConsentError = signal<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
@@ -138,7 +191,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName()) return;
|
||||
if (!this.newServerName())
|
||||
return;
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
@@ -244,10 +298,65 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.joinPasswordError.set(null);
|
||||
}
|
||||
|
||||
closePluginConsentDialog(): void {
|
||||
if (this.pluginConsentBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pluginConsentDialog.set(null);
|
||||
this.selectedOptionalPluginIds.set(new Set());
|
||||
this.pluginConsentError.set(null);
|
||||
}
|
||||
|
||||
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
|
||||
this.selectedOptionalPluginIds.update((selectedIds) => {
|
||||
const nextIds = new Set(selectedIds);
|
||||
|
||||
if (checked) {
|
||||
nextIds.add(pluginId);
|
||||
} else {
|
||||
nextIds.delete(pluginId);
|
||||
}
|
||||
|
||||
return nextIds;
|
||||
});
|
||||
}
|
||||
|
||||
async confirmPluginConsent(): Promise<void> {
|
||||
const dialog = this.pluginConsentDialog();
|
||||
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptionalIds = this.selectedOptionalPluginIds();
|
||||
const acceptedRequirements = dialog.required.concat(
|
||||
dialog.optional.filter((requirement) => selectedOptionalIds.has(requirement.pluginId))
|
||||
);
|
||||
|
||||
this.pluginConsentBusy.set(true);
|
||||
this.pluginConsentError.set(null);
|
||||
|
||||
try {
|
||||
await this.attemptJoinServer(dialog.server, dialog.password, {
|
||||
acceptedRequirements,
|
||||
skipPluginConsent: true
|
||||
});
|
||||
|
||||
this.pluginConsentDialog.set(null);
|
||||
this.selectedOptionalPluginIds.set(new Set());
|
||||
} catch (error) {
|
||||
this.pluginConsentError.set(error instanceof Error ? error.message : 'Unable to install server plugins');
|
||||
} finally {
|
||||
this.pluginConsentBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const server = this.passwordPromptServer();
|
||||
|
||||
if (!server) return;
|
||||
if (!server)
|
||||
return;
|
||||
|
||||
await this.attemptJoinServer(server, this.joinPassword());
|
||||
}
|
||||
@@ -259,7 +368,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
getServerUserCount(server: ServerInfo): number {
|
||||
const candidate = server as ServerInfo & { currentUsers?: number };
|
||||
|
||||
if (typeof server.userCount === 'number') return server.userCount;
|
||||
if (typeof server.userCount === 'number')
|
||||
return server.userCount;
|
||||
|
||||
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
||||
}
|
||||
@@ -302,7 +412,11 @@ export class ServerSearchComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
|
||||
private async attemptJoinServer(
|
||||
server: ServerInfo,
|
||||
password?: string,
|
||||
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
@@ -315,6 +429,16 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
if (options.skipPluginConsent !== true) {
|
||||
const consentDialog = await this.buildPluginConsentDialog(server, password);
|
||||
|
||||
if (consentDialog) {
|
||||
this.pluginConsentDialog.set(consentDialog);
|
||||
this.selectedOptionalPluginIds.set(new Set(consentDialog.optional.map((requirement) => requirement.pluginId)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.serverDirectory.requestJoin(
|
||||
{
|
||||
@@ -351,6 +475,11 @@ export class ServerSearchComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.closePasswordDialog();
|
||||
|
||||
if (options.acceptedRequirements?.length) {
|
||||
await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true });
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: resolvedServer.id,
|
||||
@@ -378,9 +507,40 @@ export class ServerSearchComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.joinErrorMessage.set(message);
|
||||
|
||||
if (options.skipPluginConsent) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPluginConsentDialog(server: ServerInfo, password?: string): Promise<JoinPluginConsentDialog | null> {
|
||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl({
|
||||
sourceId: server.sourceId,
|
||||
sourceUrl: server.sourceUrl
|
||||
});
|
||||
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(apiBaseUrl, server.id));
|
||||
const installedPluginIds = await this.pluginStore.getLocalServerInstalledPluginIds(server.id);
|
||||
const installableRequirements = snapshot.requirements
|
||||
.filter((requirement) => !installedPluginIds.has(requirement.pluginId))
|
||||
.filter((requirement) => !!requirement.manifest || !!requirement.installUrl);
|
||||
const required = installableRequirements.filter((requirement) => requirement.status === 'required');
|
||||
const optional = installableRequirements.filter(
|
||||
(requirement) => requirement.status === 'optional' || requirement.status === 'recommended'
|
||||
);
|
||||
|
||||
if (required.length === 0 && optional.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
optional,
|
||||
password,
|
||||
required,
|
||||
server
|
||||
};
|
||||
}
|
||||
|
||||
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||
if (!currentUser) {
|
||||
return;
|
||||
@@ -415,6 +575,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
description: currentUser.description,
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt
|
||||
});
|
||||
|
||||
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
|
||||
type: 'server_icon_sync_request',
|
||||
serverId: server.id,
|
||||
@@ -444,7 +605,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion) return;
|
||||
if (requestVersion !== this.banLookupRequestVersion)
|
||||
return;
|
||||
|
||||
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
@@ -453,7 +615,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !currentUserId) return false;
|
||||
if (!currentUser && !currentUserId)
|
||||
return false;
|
||||
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { isAnimatedGif, isAnimatedWebp } from '../../../profile-avatar/infrastructure/services/profile-avatar-image.service';
|
||||
|
||||
export interface ProcessedServerIcon {
|
||||
dataUrl: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const SERVER_ICON_SIZE = 64;
|
||||
const STATIC_ICON_CANDIDATES = [
|
||||
{ mime: 'image/webp', quality: 0.82 },
|
||||
{ mime: 'image/jpeg', quality: 0.82 },
|
||||
{ mime: 'image/png' }
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerIconImageService {
|
||||
async process(file: File): Promise<ProcessedServerIcon> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Choose an image file.');
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
try {
|
||||
const image = await this.loadImage(objectUrl);
|
||||
const isAnimated = await this.isAnimated(file);
|
||||
|
||||
if (isAnimated && image.naturalWidth <= SERVER_ICON_SIZE && image.naturalHeight <= SERVER_ICON_SIZE) {
|
||||
const dataUrl = await this.readBlobAsDataUrl(file);
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
mime: file.type || this.resolveMimeFromDataUrl(dataUrl),
|
||||
size: file.size
|
||||
};
|
||||
}
|
||||
|
||||
return await this.renderStaticIcon(image);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async renderStaticIcon(image: HTMLImageElement): Promise<ProcessedServerIcon> {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Canvas not supported.');
|
||||
}
|
||||
|
||||
canvas.width = SERVER_ICON_SIZE;
|
||||
canvas.height = SERVER_ICON_SIZE;
|
||||
|
||||
const scale = Math.max(SERVER_ICON_SIZE / image.naturalWidth, SERVER_ICON_SIZE / image.naturalHeight);
|
||||
const drawWidth = image.naturalWidth * scale;
|
||||
const drawHeight = image.naturalHeight * scale;
|
||||
const drawX = (SERVER_ICON_SIZE - drawWidth) / 2;
|
||||
const drawY = (SERVER_ICON_SIZE - drawHeight) / 2;
|
||||
|
||||
context.clearRect(0, 0, SERVER_ICON_SIZE, SERVER_ICON_SIZE);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
const candidates = await Promise.all(
|
||||
STATIC_ICON_CANDIDATES.map(async (candidate) => {
|
||||
const blob = await this.canvasToBlob(canvas, candidate.mime, candidate.quality);
|
||||
const dataUrl = await this.readBlobAsDataUrl(blob);
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
mime: blob.type || candidate.mime,
|
||||
size: blob.size
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return candidates.reduce((smallest, candidate) => (candidate.size < smallest.size ? candidate : smallest));
|
||||
}
|
||||
|
||||
private async isAnimated(file: File): Promise<boolean> {
|
||||
const mime = file.type.toLowerCase();
|
||||
|
||||
if (mime !== 'image/gif' && mime !== 'image/webp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
return mime === 'image/gif' ? isAnimatedGif(buffer) : isAnimatedWebp(buffer);
|
||||
}
|
||||
|
||||
private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to render server image.'));
|
||||
},
|
||||
type,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Failed to encode server image.'));
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read server image.'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Failed to load server image.'));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
private resolveMimeFromDataUrl(dataUrl: string): string {
|
||||
const match = /^data:([^;,]+)/.exec(dataUrl);
|
||||
|
||||
return match?.[1] || 'image/webp';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user