Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors
This commit is contained in:
@@ -17,7 +17,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||
/** Key used to persist voice settings (input/output devices, volume). */
|
||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||
|
||||
/** Key used to persist per-user volume overrides (0–200%). */
|
||||
/** Key used to persist per-user volume overrides (0-200%). */
|
||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||
|
||||
/** Regex that extracts a roomId from a `/room/:roomId` URL path. */
|
||||
|
||||
@@ -331,6 +331,7 @@ export type ChatEventType =
|
||||
| 'kick'
|
||||
| 'ban'
|
||||
| 'room-deleted'
|
||||
| 'host-change'
|
||||
| 'room-settings-update'
|
||||
| 'voice-state'
|
||||
| 'chat-inventory-request'
|
||||
@@ -364,6 +365,14 @@ export interface ChatEvent {
|
||||
targetUserId?: string;
|
||||
/** Room ID the event pertains to. */
|
||||
roomId?: string;
|
||||
/** Updated room host ID after an ownership change. */
|
||||
hostId?: string;
|
||||
/** Updated room host `oderId` after an ownership change. */
|
||||
hostOderId?: string;
|
||||
/** Previous room host ID before the ownership change. */
|
||||
previousHostId?: string;
|
||||
/** Previous room host `oderId` before the ownership change. */
|
||||
previousHostOderId?: string;
|
||||
/** User who issued a kick. */
|
||||
kickedBy?: string;
|
||||
/** User who issued a ban. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
@@ -14,8 +14,9 @@ import { DatabaseService } from './database.service';
|
||||
|
||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||
/** Maximum file size (bytes) that is automatically saved to disk (Electron). */
|
||||
const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
|
||||
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
/**
|
||||
* EWMA smoothing weight for the *previous* speed estimate.
|
||||
* The complementary weight (1 − this value) is applied to the
|
||||
@@ -27,6 +28,10 @@ const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT;
|
||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||
/** localStorage key used by the legacy attachment store (migration target). */
|
||||
const LEGACY_STORAGE_KEY = 'metoyou_attachments';
|
||||
/** User-facing error when no peers are available for a request. */
|
||||
const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
|
||||
/** User-facing error when connected peers cannot provide a requested file. */
|
||||
const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
|
||||
|
||||
/**
|
||||
* Metadata describing a file attachment linked to a chat message.
|
||||
@@ -69,6 +74,8 @@ export interface Attachment extends AttachmentMeta {
|
||||
startedAtMs?: number;
|
||||
/** Epoch ms of the most recent chunk received. */
|
||||
lastUpdateMs?: number;
|
||||
/** User-facing request failure shown in the attachment card. */
|
||||
requestError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,13 +229,19 @@ export class AttachmentService {
|
||||
* @param attachment - Attachment to request.
|
||||
*/
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||
this.touch();
|
||||
console.warn('[Attachments] No connected peers to request file from');
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearedRequestError)
|
||||
this.touch();
|
||||
|
||||
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
||||
|
||||
this.pendingRequests.set(requestKey, new Set());
|
||||
@@ -246,8 +259,12 @@ export class AttachmentService {
|
||||
|
||||
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
|
||||
const attachment = attachments.find((entry) => entry.id === fileId);
|
||||
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||
|
||||
this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||
if (!didSendRequest && attachment) {
|
||||
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
|
||||
this.touch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,8 +286,8 @@ export class AttachmentService {
|
||||
*
|
||||
* 1. Each file is assigned a UUID.
|
||||
* 2. A `file-announce` event is broadcast to peers.
|
||||
* 3. Images ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES} are immediately
|
||||
* streamed as chunked base-64.
|
||||
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
|
||||
* are immediately streamed as chunked base-64.
|
||||
*
|
||||
* @param messageId - ID of the parent message.
|
||||
* @param files - Array of user-selected `File` objects.
|
||||
@@ -328,8 +345,8 @@ export class AttachmentService {
|
||||
}
|
||||
} as any);
|
||||
|
||||
// Auto-stream small images
|
||||
if (attachment.isImage && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
// Auto-stream small inline-preview media
|
||||
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
await this.streamFileToPeers(messageId, fileId, file);
|
||||
}
|
||||
}
|
||||
@@ -401,6 +418,10 @@ export class AttachmentService {
|
||||
|
||||
const decodedBytes = this.base64ToUint8Array(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
|
||||
this.pendingRequests.delete(requestKey);
|
||||
this.clearAttachmentRequestError(attachment);
|
||||
|
||||
// Initialise assembly buffer on first chunk
|
||||
let chunkBuffer = this.chunkBuffers.get(assemblyKey);
|
||||
@@ -453,7 +474,7 @@ export class AttachmentService {
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
if (this.shouldPersistDownloadedAttachment(attachment)) {
|
||||
void this.saveFileToDisk(attachment, blob);
|
||||
}
|
||||
|
||||
@@ -652,6 +673,15 @@ export class AttachmentService {
|
||||
return `${messageId}:${fileId}`;
|
||||
}
|
||||
|
||||
/** Clear any user-facing request error stored on an attachment. */
|
||||
private clearAttachmentRequestError(attachment: Attachment): boolean {
|
||||
if (!attachment.requestError)
|
||||
return false;
|
||||
|
||||
attachment.requestError = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Check whether a specific transfer has been cancelled. */
|
||||
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
|
||||
return this.cancelledTransfers.has(
|
||||
@@ -659,9 +689,18 @@ export class AttachmentService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether a file is an image or video. */
|
||||
/** Check whether a file is inline-previewable media. */
|
||||
private isMedia(attachment: { mime: string }): boolean {
|
||||
return attachment.mime.startsWith('image/') || attachment.mime.startsWith('video/');
|
||||
return attachment.mime.startsWith('image/') ||
|
||||
attachment.mime.startsWith('video/') ||
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
/** Check whether a completed download should be cached on disk. */
|
||||
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
|
||||
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||
attachment.mime.startsWith('video/') ||
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -822,9 +861,11 @@ export class AttachmentService {
|
||||
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
|
||||
const subDirectory = attachment.mime.startsWith('video/')
|
||||
? 'video'
|
||||
: attachment.mime.startsWith('image/')
|
||||
? 'image'
|
||||
: 'files';
|
||||
: attachment.mime.startsWith('audio/')
|
||||
? 'audio'
|
||||
: attachment.mime.startsWith('image/')
|
||||
? 'image'
|
||||
: 'files';
|
||||
const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`;
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
@@ -21,7 +21,7 @@ interface ElectronAPI {
|
||||
* structured command/query objects through the unified `cqrs:command` and
|
||||
* `cqrs:query` channels exposed by the preload script.
|
||||
*
|
||||
* No initialisation IPC call is needed – the database is initialised and
|
||||
* No initialisation IPC call is needed - the database is initialised and
|
||||
* migrations are run in main.ts before the renderer window is created.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './electron-database.service';
|
||||
export * from './database.service';
|
||||
export * from './webrtc.service';
|
||||
export * from './server-directory.service';
|
||||
export * from './klipy.service';
|
||||
export * from './voice-session.service';
|
||||
export * from './voice-activity.service';
|
||||
export * from './external-link.service';
|
||||
|
||||
200
src/app/core/services/klipy.service.ts
Normal file
200
src/app/core/services/klipy.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
firstValueFrom,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerDirectoryService } from './server-directory.service';
|
||||
|
||||
export interface KlipyGif {
|
||||
id: string;
|
||||
slug: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
previewUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface KlipyAvailabilityResponse {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface KlipyGifSearchResponse {
|
||||
enabled: boolean;
|
||||
results: KlipyGif[];
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 24;
|
||||
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KlipyService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly availabilityState = signal({
|
||||
enabled: false,
|
||||
loading: true
|
||||
});
|
||||
private lastAvailabilityKey = '';
|
||||
|
||||
readonly isEnabled = computed(() => this.availabilityState().enabled);
|
||||
readonly isLoading = computed(() => this.availabilityState().loading);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const activeServer = this.serverDirectory.activeServer();
|
||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
|
||||
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
|
||||
|
||||
if (nextKey === this.lastAvailabilityKey)
|
||||
return;
|
||||
|
||||
this.lastAvailabilityKey = nextKey;
|
||||
void this.refreshAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
async refreshAvailability(): Promise<void> {
|
||||
this.availabilityState.set({ enabled: false,
|
||||
loading: true });
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<KlipyAvailabilityResponse>(
|
||||
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
|
||||
)
|
||||
);
|
||||
|
||||
this.availabilityState.set({
|
||||
enabled: response.enabled === true,
|
||||
loading: false
|
||||
});
|
||||
} catch {
|
||||
this.availabilityState.set({ enabled: false,
|
||||
loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
searchGifs(
|
||||
query: string,
|
||||
page = 1,
|
||||
perPage = DEFAULT_PAGE_SIZE
|
||||
): Observable<KlipyGifSearchResponse> {
|
||||
let params = new HttpParams()
|
||||
.set('page', String(Math.max(1, Math.floor(page))))
|
||||
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
||||
.set('customer_id', this.getOrCreateCustomerId());
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (trimmedQuery) {
|
||||
params = params.set('q', trimmedQuery);
|
||||
}
|
||||
|
||||
const locale = this.getPreferredLocale();
|
||||
|
||||
if (locale) {
|
||||
params = params.set('locale', locale);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
|
||||
.pipe(
|
||||
map((response) => ({
|
||||
enabled: response.enabled !== false,
|
||||
results: Array.isArray(response.results) ? response.results : [],
|
||||
hasNext: response.hasNext === true
|
||||
})),
|
||||
catchError((error) =>
|
||||
throwError(() => new Error(this.extractErrorMessage(error)))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
normalizeMediaUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
|
||||
if (!trimmed)
|
||||
return '';
|
||||
|
||||
if (trimmed.startsWith('//'))
|
||||
return `https:${trimmed}`;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
buildRenderableImageUrl(url: string): string {
|
||||
const trimmed = this.normalizeMediaUrl(url);
|
||||
|
||||
if (!trimmed)
|
||||
return '';
|
||||
|
||||
if (!/^https?:\/\//i.test(trimmed))
|
||||
return trimmed;
|
||||
|
||||
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
|
||||
private getPreferredLocale(): string | null {
|
||||
if (typeof navigator === 'undefined' || !navigator.language)
|
||||
return null;
|
||||
|
||||
const locale = navigator.language.trim();
|
||||
|
||||
return locale || null;
|
||||
}
|
||||
|
||||
private getOrCreateCustomerId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'server';
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY);
|
||||
|
||||
if (existing?.trim())
|
||||
return existing;
|
||||
|
||||
const created = window.crypto?.randomUUID?.()
|
||||
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 10)}`;
|
||||
|
||||
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
return `klipy-${Date.now().toString(36)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private extractErrorMessage(error: unknown): string {
|
||||
const httpError = error as {
|
||||
error?: {
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
if (typeof httpError?.error?.error === 'string')
|
||||
return httpError.error.error;
|
||||
|
||||
if (typeof httpError?.error?.message === 'string')
|
||||
return httpError.error.message;
|
||||
|
||||
if (typeof httpError?.message === 'string')
|
||||
return httpError.message;
|
||||
|
||||
return 'Failed to load GIFs from KLIPY.';
|
||||
}
|
||||
}
|
||||
@@ -309,10 +309,10 @@ export class ServerDirectoryService {
|
||||
/** Update an existing server listing. */
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo>
|
||||
updates: Partial<ServerInfo> & { currentOwnerId: string }
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
|
||||
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
|
||||
@@ -236,8 +236,8 @@ export class VoiceActivityService implements OnDestroy {
|
||||
// Compute RMS volume from time-domain data (values 0-255, centred at 128).
|
||||
let sumSquares = 0;
|
||||
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const normalised = (dataArray[i] - 128) / 128;
|
||||
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
|
||||
const normalised = (dataArray[sampleIndex] - 128) / 128;
|
||||
|
||||
sumSquares += normalised * normalised;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,26 @@
|
||||
<pre><code>{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[src]="getMarkdownImageSource(node.url)"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 w-auto max-w-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
</div>
|
||||
@if (getAttachments(message.id).length > 0) {
|
||||
@@ -206,7 +226,15 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
|
||||
<div
|
||||
class="text-xs mt-0.5"
|
||||
[class.text-destructive]="!!att.requestError"
|
||||
[class.text-muted-foreground]="!att.requestError"
|
||||
[class.opacity-70]="!att.requestError"
|
||||
[class.italic]="!att.requestError"
|
||||
>
|
||||
{{ att.requestError || 'Waiting for image source…' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -217,6 +245,74 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else if (isVideoAttachment(att) || isAudioAttachment(att)) {
|
||||
@if (att.available && att.objectUrl) {
|
||||
@if (isVideoAttachment(att)) {
|
||||
<app-chat-video-player
|
||||
[src]="att.objectUrl"
|
||||
[filename]="att.filename"
|
||||
[sizeLabel]="formatBytes(att.size)"
|
||||
(downloadRequested)="downloadAttachment(att)"
|
||||
/>
|
||||
} @else {
|
||||
<app-chat-audio-player
|
||||
[src]="att.objectUrl"
|
||||
[filename]="att.filename"
|
||||
[sizeLabel]="formatBytes(att.size)"
|
||||
(downloadRequested)="downloadAttachment(att)"
|
||||
/>
|
||||
}
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xl">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
|
||||
(click)="cancelAttachment(att, message.id)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
|
||||
@if (att.speedBps) {
|
||||
<span>{{ formatSpeed(att.speedBps) }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xl">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
<div
|
||||
class="mt-1 text-xs leading-relaxed"
|
||||
[class.text-destructive]="!!att.requestError"
|
||||
[class.text-muted-foreground]="!att.requestError"
|
||||
[class.opacity-80]="!att.requestError"
|
||||
>
|
||||
{{ getMediaAttachmentStatusText(att) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
(click)="requestAttachment(att, message.id)"
|
||||
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
{{ getMediaAttachmentActionLabel(att) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -244,7 +340,7 @@
|
||||
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
|
||||
(click)="requestAttachment(att, message.id)"
|
||||
>
|
||||
Request
|
||||
{{ att.requestError ? 'Retry' : 'Request' }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
@@ -267,6 +363,13 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!att.available && att.requestError) {
|
||||
<div
|
||||
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
|
||||
>
|
||||
{{ att.requestError }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -521,6 +624,43 @@
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
|
||||
@if (klipy.isEnabled()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openKlipyGifPicker()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||
[class.border-primary]="showKlipyGifPicker()"
|
||||
[class.text-primary]="showKlipyGifPicker()"
|
||||
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
||||
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
|
||||
aria-label="Search KLIPY GIFs"
|
||||
title="Search KLIPY GIFs"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">GIF</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
|
||||
aria-label="Send message"
|
||||
title="Send message"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSend"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
#messageInputRef
|
||||
rows="1"
|
||||
@@ -534,24 +674,14 @@
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
placeholder="Type a message..."
|
||||
class="chat-textarea w-full pl-3 pr-12 py-2 rounded-2xl border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
class="chat-textarea w-full rounded-[1.35rem] border border-border py-2 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.pr-16]="!klipy.isEnabled()"
|
||||
[class.pr-40]="klipy.isEnabled()"
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.border-dashed]="dragActive()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
|
||||
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
[class.visible]="inputHovered() || messageContent.trim().length > 0"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSend"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (dragActive()) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center"
|
||||
@@ -560,6 +690,39 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingKlipyGif()) {
|
||||
<div class="mt-2 flex">
|
||||
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
|
||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
||||
<img
|
||||
[src]="getPendingKlipyGifPreviewUrl()"
|
||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
|
||||
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
|
||||
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removePendingKlipyGif()"
|
||||
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingFiles.length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (file of pendingFiles; track file.name) {
|
||||
@@ -580,6 +743,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showKlipyGifPicker() && klipy.isEnabled()) {
|
||||
<app-klipy-gif-picker
|
||||
(gifSelected)="handleKlipyGifSelected($event)"
|
||||
(closed)="closeKlipyGifPicker()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Image Lightbox Modal -->
|
||||
@if (lightboxAttachment()) {
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
|
||||
import {
|
||||
AttachmentService,
|
||||
Attachment,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../core/services/attachment.service';
|
||||
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSend,
|
||||
@@ -42,8 +47,13 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/user
|
||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../shared';
|
||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
|
||||
import { RemarkModule, MermaidComponent } from 'ngx-remark';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -70,8 +80,11 @@ const COMMON_EMOJIS = [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
KlipyGifPickerComponent,
|
||||
TypingIndicatorComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent
|
||||
@@ -109,8 +122,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
@ViewChild('bottomBar') bottomBar!: ElementRef;
|
||||
|
||||
private store = inject(Store);
|
||||
readonly klipy = inject(KlipyService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private attachmentsSvc = inject(AttachmentService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private markdown = inject(ChatMarkdownService);
|
||||
@@ -163,6 +176,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
editingMessageId = signal<string | null>(null);
|
||||
replyTo = signal<Message | null>(null);
|
||||
showEmojiPicker = signal<string | null>(null);
|
||||
pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
showKlipyGifPicker = signal(false);
|
||||
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
|
||||
@@ -327,10 +342,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0)
|
||||
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
||||
return;
|
||||
|
||||
const content = this.markdown.appendImageMarkdown(raw);
|
||||
const content = this.buildOutgoingMessageContent(raw);
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.sendMessage({
|
||||
@@ -341,6 +356,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
|
||||
this.messageContent = '';
|
||||
this.pendingKlipyGif.set(null);
|
||||
this.clearReply();
|
||||
this.shouldScrollToBottom = true;
|
||||
// Reset textarea height after sending
|
||||
@@ -764,6 +780,51 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
openKlipyGifPicker(): void {
|
||||
if (!this.klipy.isEnabled())
|
||||
return;
|
||||
|
||||
this.showKlipyGifPicker.set(true);
|
||||
}
|
||||
|
||||
closeKlipyGifPicker(): void {
|
||||
this.showKlipyGifPicker.set(false);
|
||||
}
|
||||
|
||||
handleKlipyGifSelected(gif: KlipyGif): void {
|
||||
this.pendingKlipyGif.set(gif);
|
||||
this.closeKlipyGifPicker();
|
||||
|
||||
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
|
||||
this.sendMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
removePendingKlipyGif(): void {
|
||||
this.pendingKlipyGif.set(null);
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
getPendingKlipyGifPreviewUrl(): string {
|
||||
const gif = this.pendingKlipyGif();
|
||||
|
||||
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
|
||||
}
|
||||
|
||||
getMarkdownImageSource(url?: string): string {
|
||||
return url ? this.klipy.buildRenderableImageUrl(url) : '';
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
|
||||
}
|
||||
|
||||
/** Handle drag-enter to activate the drop zone overlay. */
|
||||
// Attachments: drag/drop and rendering
|
||||
onDragEnter(evt: DragEvent): void {
|
||||
@@ -945,6 +1006,47 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Whether an attachment can be played inline as video. */
|
||||
isVideoAttachment(att: Attachment): boolean {
|
||||
return att.mime.startsWith('video/');
|
||||
}
|
||||
|
||||
/** Whether an attachment can be played inline as audio. */
|
||||
isAudioAttachment(att: Attachment): boolean {
|
||||
return att.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
/** Whether the user must explicitly accept a media download before playback. */
|
||||
requiresMediaDownloadAcceptance(att: Attachment): boolean {
|
||||
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
|
||||
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||
}
|
||||
|
||||
/** User-facing status copy for an unavailable audio/video attachment. */
|
||||
getMediaAttachmentStatusText(att: Attachment): string {
|
||||
if (att.requestError)
|
||||
return att.requestError;
|
||||
|
||||
if (this.requiresMediaDownloadAcceptance(att)) {
|
||||
return this.isVideoAttachment(att)
|
||||
? 'Large video. Accept the download to watch it in chat.'
|
||||
: 'Large audio file. Accept the download to play it in chat.';
|
||||
}
|
||||
|
||||
return this.isVideoAttachment(att)
|
||||
? 'Waiting for video source…'
|
||||
: 'Waiting for audio source…';
|
||||
}
|
||||
|
||||
/** Action label for requesting an audio/video attachment. */
|
||||
getMediaAttachmentActionLabel(att: Attachment): string {
|
||||
if (this.requiresMediaDownloadAcceptance(att)) {
|
||||
return att.requestError ? 'Retry download' : 'Accept download';
|
||||
}
|
||||
|
||||
return att.requestError ? 'Retry' : 'Request';
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
@@ -955,10 +1057,33 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
|
||||
/** Download a completed attachment to the user's device. */
|
||||
downloadAttachment(att: Attachment): void {
|
||||
async downloadAttachment(att: Attachment): Promise<void> {
|
||||
if (!att.available || !att.objectUrl)
|
||||
return;
|
||||
|
||||
const electronApi = (window as any)?.electronAPI as {
|
||||
saveFileAs?: (
|
||||
defaultFileName: string,
|
||||
data: string
|
||||
) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
} | undefined;
|
||||
|
||||
if (electronApi?.saveFileAs) {
|
||||
const blob = await this.getAttachmentBlob(att);
|
||||
|
||||
if (blob) {
|
||||
try {
|
||||
const result = await electronApi.saveFileAs(
|
||||
att.filename,
|
||||
await this.blobToBase64(blob)
|
||||
);
|
||||
|
||||
if (result.saved || result.cancelled)
|
||||
return;
|
||||
} catch { /* fall back to browser download */ }
|
||||
}
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = att.objectUrl;
|
||||
@@ -968,6 +1093,39 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(att: Attachment): Promise<Blob | null> {
|
||||
if (!att.objectUrl)
|
||||
return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(att.objectUrl);
|
||||
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode attachment'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64 = ''] = reader.result.split(',', 2);
|
||||
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/** Request a file attachment to be transferred from the uploader peer. */
|
||||
requestAttachment(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.requestFile(messageId, att);
|
||||
@@ -1093,6 +1251,24 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.pendingFiles = [];
|
||||
}
|
||||
|
||||
private buildOutgoingMessageContent(raw: string): string {
|
||||
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
|
||||
const gif = this.pendingKlipyGif();
|
||||
|
||||
if (!gif)
|
||||
return withEmbeddedImages;
|
||||
|
||||
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
|
||||
|
||||
return withEmbeddedImages
|
||||
? `${withEmbeddedImages}\n${gifMarkdown}`
|
||||
: gifMarkdown;
|
||||
}
|
||||
|
||||
private buildKlipyGifMarkdown(gif: KlipyGif): string {
|
||||
return `})`;
|
||||
}
|
||||
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<div
|
||||
class="fixed inset-0 z-[94] bg-black/70 backdrop-blur-sm"
|
||||
(click)="close()"
|
||||
(keydown.enter)="close()"
|
||||
(keydown.space)="close()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close GIF picker"
|
||||
></div>
|
||||
<div class="fixed inset-0 z-[95] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto flex h-[min(80vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="KLIPY GIF picker"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
|
||||
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="close()"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close GIF picker"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border px-5 py-4">
|
||||
<label class="relative block">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
[ngModel]="searchQuery"
|
||||
(ngModelChange)="onSearchQueryChanged($event)"
|
||||
placeholder="Search KLIPY"
|
||||
class="w-full rounded-xl border border-border bg-background px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
@if (errorMessage()) {
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<span>{{ errorMessage() }}</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="retry()"
|
||||
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading() && results().length === 0) {
|
||||
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
|
||||
<p class="text-sm">Loading GIFs from KLIPY…</p>
|
||||
</div>
|
||||
} @else if (results().length === 0) {
|
||||
<div
|
||||
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border bg-secondary/10 px-6 text-center text-muted-foreground"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">No GIFs found</p>
|
||||
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||
@for (gif of results(); track gif.id) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectGif(gif)"
|
||||
class="group overflow-hidden rounded-2xl border border-border bg-secondary/10 text-left transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
|
||||
>
|
||||
<div
|
||||
class="relative overflow-hidden bg-secondary/30"
|
||||
[style.aspect-ratio]="gifAspectRatio(gif)"
|
||||
>
|
||||
<img
|
||||
[src]="gifPreviewUrl(gif)"
|
||||
[alt]="gif.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-3 py-2">
|
||||
<p class="truncate text-xs font-medium text-foreground">
|
||||
{{ gif.title || 'KLIPY GIF' }}
|
||||
</p>
|
||||
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 border-t border-border px-5 py-4">
|
||||
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
|
||||
|
||||
@if (hasNext()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="loadMore()"
|
||||
[disabled]="loading()"
|
||||
class="rounded-full border border-border px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ loading() ? 'Loading…' : 'Load more' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideImage,
|
||||
lucideSearch,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-klipy-gif-picker',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideImage,
|
||||
lucideSearch,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './klipy-gif-picker.component.html'
|
||||
})
|
||||
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly gifSelected = output<KlipyGif>();
|
||||
readonly closed = output<undefined>();
|
||||
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private currentPage = 1;
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private requestId = 0;
|
||||
|
||||
searchQuery = '';
|
||||
results = signal<KlipyGif[]>([]);
|
||||
loading = signal(false);
|
||||
errorMessage = signal('');
|
||||
hasNext = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.loadResults(true);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
requestAnimationFrame(() => {
|
||||
this.searchInput?.nativeElement.focus();
|
||||
this.searchInput?.nativeElement.select();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearSearchTimer();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
onSearchQueryChanged(query: string): void {
|
||||
this.searchQuery = query;
|
||||
this.clearSearchTimer();
|
||||
this.searchTimer = setTimeout(() => {
|
||||
void this.loadResults(true);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
void this.loadResults(true);
|
||||
}
|
||||
|
||||
async loadMore(): Promise<void> {
|
||||
if (this.loading() || !this.hasNext())
|
||||
return;
|
||||
|
||||
this.currentPage += 1;
|
||||
await this.loadResults(false);
|
||||
}
|
||||
|
||||
selectGif(gif: KlipyGif): void {
|
||||
this.gifSelected.emit(gif);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed.emit(undefined);
|
||||
}
|
||||
|
||||
gifAspectRatio(gif: KlipyGif): string {
|
||||
if (gif.width > 0 && gif.height > 0) {
|
||||
return `${gif.width} / ${gif.height}`;
|
||||
}
|
||||
|
||||
return '1 / 1';
|
||||
}
|
||||
|
||||
gifPreviewUrl(gif: KlipyGif): string {
|
||||
return this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url);
|
||||
}
|
||||
|
||||
private async loadResults(reset: boolean): Promise<void> {
|
||||
if (reset) {
|
||||
this.currentPage = 1;
|
||||
}
|
||||
|
||||
const requestId = ++this.requestId;
|
||||
|
||||
this.loading.set(true);
|
||||
this.errorMessage.set('');
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.klipy.searchGifs(this.searchQuery, this.currentPage)
|
||||
);
|
||||
|
||||
if (requestId !== this.requestId)
|
||||
return;
|
||||
|
||||
this.results.set(
|
||||
reset
|
||||
? response.results
|
||||
: this.mergeResults(this.results(), response.results)
|
||||
);
|
||||
|
||||
this.hasNext.set(response.hasNext);
|
||||
} catch (error) {
|
||||
if (requestId !== this.requestId)
|
||||
return;
|
||||
|
||||
this.errorMessage.set(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load GIFs from KLIPY.'
|
||||
);
|
||||
|
||||
if (reset) {
|
||||
this.results.set([]);
|
||||
}
|
||||
|
||||
this.hasNext.set(false);
|
||||
} finally {
|
||||
if (requestId === this.requestId) {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
|
||||
const seen = new Set(existing.map((gif) => gif.id));
|
||||
const merged = [...existing];
|
||||
|
||||
for (const gif of incoming) {
|
||||
if (seen.has(gif.id))
|
||||
continue;
|
||||
|
||||
merged.push(gif);
|
||||
seen.add(gif.id);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private clearSearchTimer(): void {
|
||||
if (this.searchTimer) {
|
||||
clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,11 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSearch,
|
||||
|
||||
@@ -46,36 +46,21 @@
|
||||
(closed)="closeMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
@if (isCurrentContextRoom()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="openForgetConfirm()"
|
||||
(click)="openLeaveConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Forget Server
|
||||
Leave Server
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- Forget confirmation dialog -->
|
||||
@if (showConfirm()) {
|
||||
<app-confirm-dialog
|
||||
title="Forget Server?"
|
||||
confirmLabel="Forget"
|
||||
(confirmed)="confirmForget()"
|
||||
(cancelled)="cancelForget()"
|
||||
[widthClass]="'w-[280px]'"
|
||||
>
|
||||
<p>
|
||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||
</p>
|
||||
</app-confirm-dialog>
|
||||
@if (showLeaveConfirm() && contextRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="contextRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -12,15 +12,22 @@ import { lucidePlus } from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../core/models';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
||||
import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ContextMenuComponent,
|
||||
LeaveServerDialogComponent,
|
||||
NgOptimizedImage
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
@@ -40,8 +47,8 @@ export class ServersRailComponent {
|
||||
menuX = signal(72); // default X: rail width (~64px) + padding
|
||||
menuY = signal(100); // default Y: arbitrary initial offset
|
||||
contextRoom = signal<Room | null>(null);
|
||||
// Confirmation dialog state
|
||||
showConfirm = signal(false);
|
||||
showLeaveConfirm = signal(false);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
/** Return the first character of a server name as its icon initial. */
|
||||
initial(name?: string): string {
|
||||
@@ -133,38 +140,39 @@ export class ServersRailComponent {
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
/** Leave the current server and navigate to the servers list. */
|
||||
leaveServer(): void {
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
openLeaveConfirm(): void {
|
||||
this.closeMenu();
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
|
||||
if (this.contextRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the forget-server confirmation dialog. */
|
||||
openForgetConfirm(): void {
|
||||
this.showConfirm.set(true);
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
|
||||
confirmForget(): void {
|
||||
/** Confirm the merged leave flow and remove the server locally. */
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
if (!ctx)
|
||||
return;
|
||||
|
||||
if (this.currentRoom()?.id === ctx.id) {
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
roomId: ctx.id,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
|
||||
if (isCurrentRoom) {
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
|
||||
this.showConfirm.set(false);
|
||||
this.showLeaveConfirm.set(false);
|
||||
this.contextRoom.set(null);
|
||||
}
|
||||
|
||||
/** Cancel the forget-server confirmation dialog. */
|
||||
cancelForget(): void {
|
||||
this.showConfirm.set(false);
|
||||
/** Cancel the leave-server confirmation dialog. */
|
||||
cancelLeave(): void {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen()) {
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
@@ -15,7 +16,7 @@
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
|
||||
class="pointer-events-auto relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
|
||||
[class.scale-100]="animating()"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.scale-95]="!animating()"
|
||||
@@ -97,6 +98,16 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openThirdPartyLicenses()"
|
||||
class="text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4"
|
||||
>
|
||||
Third-party licenses
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -174,6 +185,68 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showThirdPartyLicenses()) {
|
||||
<div
|
||||
class="absolute inset-0 z-10 bg-background/70 backdrop-blur-sm"
|
||||
(click)="closeThirdPartyLicenses()"
|
||||
(keydown.enter)="closeThirdPartyLicenses()"
|
||||
(keydown.space)="closeThirdPartyLicenses()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close third-party licenses"
|
||||
></div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-[11] flex items-center justify-center p-4 sm:p-6">
|
||||
<div class="pointer-events-auto w-full max-w-2xl max-h-full overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-foreground">Third-party licenses</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">License notices for bundled third-party libraries used by the app.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeThirdPartyLicenses()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close third-party licenses"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[min(70vh,42rem)] overflow-y-auto px-5 py-4 space-y-4">
|
||||
@for (license of thirdPartyLicenses; track license.id) {
|
||||
<section class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">{{ license.name }}</h5>
|
||||
<p class="text-xs text-muted-foreground">{{ license.licenseName }}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
[href]="license.sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs font-medium text-primary hover:underline underline-offset-4"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
class="mt-4 whitespace-pre-wrap break-words rounded-md bg-background/80 px-3 py-2 text-[11px] leading-5 text-muted-foreground"
|
||||
>{{ license.text }}</pre
|
||||
>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ServerSettingsComponent } from './server-settings/server-settings.compo
|
||||
import { MembersSettingsComponent } from './members-settings/members-settings.component';
|
||||
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-modal',
|
||||
@@ -64,6 +65,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
||||
export class SettingsModalComponent {
|
||||
readonly modal = inject(SettingsModalService);
|
||||
private store = inject(Store);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
@@ -80,7 +82,8 @@ export class SettingsModalComponent {
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
icon: 'lucideGlobe' }, { id: 'voice',
|
||||
icon: 'lucideGlobe' },
|
||||
{ id: 'voice',
|
||||
label: 'Voice & Audio',
|
||||
icon: 'lucideAudioLines' }
|
||||
];
|
||||
@@ -128,6 +131,7 @@ export class SettingsModalComponent {
|
||||
|
||||
// Animation
|
||||
animating = signal(false);
|
||||
showThirdPartyLicenses = signal(false);
|
||||
|
||||
constructor() {
|
||||
// Sync selected server when modal opens with a target
|
||||
@@ -164,6 +168,11 @@ export class SettingsModalComponent {
|
||||
}
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.showThirdPartyLicenses()) {
|
||||
this.closeThirdPartyLicenses();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
}
|
||||
@@ -171,10 +180,19 @@ export class SettingsModalComponent {
|
||||
|
||||
// ===== MODAL CONTROLS =====
|
||||
close(): void {
|
||||
this.showThirdPartyLicenses.set(false);
|
||||
this.animating.set(false);
|
||||
setTimeout(() => this.modal.close(), 200);
|
||||
}
|
||||
|
||||
openThirdPartyLicenses(): void {
|
||||
this.showThirdPartyLicenses.set(true);
|
||||
}
|
||||
|
||||
closeThirdPartyLicenses(): void {
|
||||
this.showThirdPartyLicenses.set(false);
|
||||
}
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
this.modal.navigate(page);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface ThirdPartyLicense {
|
||||
id: string;
|
||||
name: string;
|
||||
licenseName: string;
|
||||
sourceUrl: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
|
||||
{
|
||||
id: 'wavesurfer-js',
|
||||
name: 'wavesurfer.js',
|
||||
licenseName: 'BSD 3-Clause License',
|
||||
sourceUrl: 'https://github.com/katspaugh/wavesurfer.js/blob/main/LICENSE',
|
||||
text: [
|
||||
'BSD 3-Clause License',
|
||||
'',
|
||||
'Copyright (c) 2012-2023, katspaugh and contributors',
|
||||
'All rights reserved.',
|
||||
'',
|
||||
'Redistribution and use in source and binary forms, with or without modification, are permitted provided',
|
||||
'that the following conditions are met:',
|
||||
'',
|
||||
'* Redistributions of source code must retain the above copyright notice, this list of conditions and',
|
||||
' the following disclaimer.',
|
||||
'',
|
||||
'* Redistributions in binary form must reproduce the above copyright notice, this list of conditions',
|
||||
' and the following disclaimer in the documentation and/or other materials provided with the',
|
||||
' distribution.',
|
||||
'',
|
||||
'* Neither the name of the copyright holder nor the names of its contributors may be used to endorse',
|
||||
' or promote products derived from this software without specific prior written permission.',
|
||||
'',
|
||||
'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR',
|
||||
'IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND',
|
||||
'FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR',
|
||||
'CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL',
|
||||
'DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,',
|
||||
'DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER',
|
||||
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
|
||||
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
|
||||
].join('\n')
|
||||
}
|
||||
];
|
||||
@@ -134,3 +134,12 @@
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (showLeaveConfirm() && currentRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="currentRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -24,11 +24,16 @@ import { ServerDirectoryService } from '../../core/services/server-directory.ser
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { PlatformService } from '../../core/services/platform.service';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
LeaveServerDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMinus,
|
||||
lucideSquare,
|
||||
@@ -52,18 +57,19 @@ export class TitleBarComponent {
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
private currentUserSig = this.store.selectSignal(selectCurrentUser);
|
||||
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
username = computed(() => this.currentUser()?.displayName || 'Guest');
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
isAuthed = computed(() => !!this.currentUserSig());
|
||||
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
|
||||
inRoom = computed(() => !!this.currentRoomSig());
|
||||
roomName = computed(() => this.currentRoomSig()?.name || '');
|
||||
roomDescription = computed(() => this.currentRoomSig()?.description || '');
|
||||
isAuthed = computed(() => !!this.currentUser());
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
inRoom = computed(() => !!this.currentRoom());
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
roomDescription = computed(() => this.currentRoom()?.description || '');
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
showLeaveConfirm = signal(false);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
@@ -94,11 +100,18 @@ export class TitleBarComponent {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the current room and navigate back to the server search. */
|
||||
onBack() {
|
||||
// Leave room to ensure header switches to user/server view
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
this.router.navigate(['/search']);
|
||||
this.openLeaveConfirm();
|
||||
}
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
@@ -108,9 +121,29 @@ export class TitleBarComponent {
|
||||
|
||||
/** Leave the current server and navigate to the servers list. */
|
||||
leaveServer() {
|
||||
this._showMenu.set(false);
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
this.openLeaveConfirm();
|
||||
}
|
||||
|
||||
/** 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(['/search']);
|
||||
}
|
||||
|
||||
/** Cancel the leave-server confirmation dialog. */
|
||||
cancelLeave() {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
|
||||
/** Close the server dropdown menu. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<div class="audio-player-shell">
|
||||
<audio
|
||||
#audioEl
|
||||
[src]="src()"
|
||||
preload="metadata"
|
||||
(ended)="onPause()"
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(pause)="onPause()"
|
||||
(play)="onPlay()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(volumechange)="onVolumeChange()"
|
||||
></audio>
|
||||
|
||||
<div class="audio-top-bar">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
|
||||
@if (sizeLabel()) {
|
||||
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="audio-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save audio to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audio-body">
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePlayback()"
|
||||
class="audio-play-btn"
|
||||
[title]="isPlaying() ? 'Pause' : 'Play'"
|
||||
[attr.aria-label]="isPlaying() ? 'Pause audio' : 'Play audio'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="audio-main">
|
||||
<div
|
||||
class="audio-waveform-panel"
|
||||
[class.expanded]="waveformExpanded()"
|
||||
[attr.aria-hidden]="!waveformExpanded()"
|
||||
>
|
||||
<div class="audio-waveform-shell">
|
||||
<div
|
||||
#waveformContainer
|
||||
class="audio-waveform-container"
|
||||
[class.invisible]="waveformLoading() || waveformUnavailable()"
|
||||
></div>
|
||||
|
||||
@if (waveformLoading()) {
|
||||
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform…</div>
|
||||
} @else if (waveformUnavailable()) {
|
||||
<div class="audio-waveform-overlay text-muted-foreground">
|
||||
Couldn’t render a waveform preview for this file, but playback still works.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="durationSeconds() || 0"
|
||||
[value]="currentTimeSeconds()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.background]="seekTrackBackground()"
|
||||
aria-label="Seek audio"
|
||||
/>
|
||||
|
||||
<div class="audio-controls-row">
|
||||
<span class="audio-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
|
||||
|
||||
<div class="audio-actions-group">
|
||||
<div class="audio-volume-group">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
class="audio-control-btn"
|
||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||
[attr.aria-label]="isMuted() ? 'Unmute audio' : 'Mute audio'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="displayVolumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.background]="volumeTrackBackground()"
|
||||
aria-label="Audio volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleWaveform()"
|
||||
class="audio-control-btn"
|
||||
[title]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
|
||||
[attr.aria-label]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="waveformToggleIcon()"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,255 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.audio-player-shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background:
|
||||
radial-gradient(circle at top left, hsl(var(--primary) / 0.14), transparent 42%),
|
||||
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(var(--secondary) / 0.55) 100%);
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.audio-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0.875rem 0.625rem;
|
||||
}
|
||||
|
||||
.audio-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.875rem;
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
min-width: 3.25rem;
|
||||
border: 1px solid hsl(var(--primary) / 0.35);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: linear-gradient(180deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.76) 100%);
|
||||
box-shadow: 0 10px 22px rgb(0 0 0 / 16%);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
filter 0.16s ease;
|
||||
}
|
||||
|
||||
.audio-play-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.audio-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.audio-waveform-panel {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
margin-bottom 0.2s ease;
|
||||
}
|
||||
|
||||
.audio-waveform-panel.expanded {
|
||||
max-height: 5.5rem;
|
||||
opacity: 1;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-waveform-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 4.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 0.95rem;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--background) / 0.72) 0%, hsl(var(--secondary) / 0.28) 100%);
|
||||
}
|
||||
|
||||
.audio-waveform-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.audio-waveform-container.invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.audio-waveform-container ::part(wrapper) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audio-waveform-container ::part(cursor) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-waveform-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.audio-time-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audio-actions-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audio-volume-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audio-control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card) / 0.72);
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.audio-control-btn:hover {
|
||||
border-color: hsl(var(--primary) / 0.75);
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.seek-slider,
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 5.5rem;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-runnable-track,
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-thumb,
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-track,
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-thumb,
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
@keyframes audio-wave-motion {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.audio-body {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.audio-volume-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-waveform-shell {
|
||||
height: 3.75rem;
|
||||
}
|
||||
|
||||
.audio-controls-row {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import type WaveSurfer from 'wavesurfer.js';
|
||||
import {
|
||||
lucideChevronDown,
|
||||
lucideChevronUp,
|
||||
lucideDownload,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
const AUDIO_PLAYER_VOLUME_STORAGE_KEY = 'metoyou_audio_player_volume';
|
||||
const DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT = 50;
|
||||
|
||||
function getInitialSharedAudioPlayerVolume(): number {
|
||||
if (typeof window === 'undefined')
|
||||
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY);
|
||||
const parsed = Number(raw);
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 100)
|
||||
return parsed;
|
||||
} catch { /* ignore storage access issues */ }
|
||||
|
||||
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
|
||||
}
|
||||
|
||||
function persistSharedAudioPlayerVolume(volumePercent: number): void {
|
||||
if (typeof window === 'undefined')
|
||||
return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY, String(volumePercent));
|
||||
} catch { /* ignore storage access issues */ }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-audio-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideChevronDown,
|
||||
lucideChevronUp,
|
||||
lucideDownload,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-audio-player.component.html',
|
||||
styleUrl: './chat-audio-player.component.scss'
|
||||
})
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
downloadRequested = output<undefined>();
|
||||
|
||||
@ViewChild('audioEl') audioRef?: ElementRef<HTMLAudioElement>;
|
||||
@ViewChild('waveformContainer') waveformContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
isPlaying = signal(false);
|
||||
isMuted = signal(false);
|
||||
waveformExpanded = signal(false);
|
||||
waveformLoading = signal(false);
|
||||
waveformUnavailable = signal(false);
|
||||
currentTimeSeconds = signal(0);
|
||||
durationSeconds = signal(0);
|
||||
volumePercent = signal(getInitialSharedAudioPlayerVolume());
|
||||
private lastNonZeroVolume = signal(getInitialSharedAudioPlayerVolume());
|
||||
private waveSurfer: WaveSurfer | null = null;
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const duration = this.durationSeconds();
|
||||
|
||||
if (duration <= 0)
|
||||
return 0;
|
||||
|
||||
return (this.currentTimeSeconds() / duration) * 100;
|
||||
});
|
||||
seekTrackBackground = computed(() => this.buildSliderBackground(this.progressPercent()));
|
||||
waveformToggleIcon = computed(() => this.waveformExpanded() ? 'lucideChevronUp' : 'lucideChevronDown');
|
||||
displayVolumePercent = computed(() => this.isMuted() ? 0 : this.volumePercent());
|
||||
volumeTrackBackground = computed(() => {
|
||||
const volume = Math.max(0, Math.min(100, this.displayVolumePercent()));
|
||||
|
||||
return this.buildSliderBackground(volume);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
void this.src();
|
||||
const storedVolume = getInitialSharedAudioPlayerVolume();
|
||||
|
||||
this.destroyWaveSurfer();
|
||||
this.waveformExpanded.set(false);
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(false);
|
||||
this.currentTimeSeconds.set(0);
|
||||
this.durationSeconds.set(0);
|
||||
this.isPlaying.set(false);
|
||||
this.isMuted.set(false);
|
||||
this.volumePercent.set(storedVolume);
|
||||
this.lastNonZeroVolume.set(storedVolume);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyWaveSurfer();
|
||||
}
|
||||
|
||||
togglePlayback(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
if (audio.paused || audio.ended) {
|
||||
void audio.play().catch(() => {
|
||||
this.isPlaying.set(false);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
audio.pause();
|
||||
}
|
||||
|
||||
toggleWaveform(): void {
|
||||
const nextExpanded = !this.waveformExpanded();
|
||||
|
||||
this.waveformExpanded.set(nextExpanded);
|
||||
|
||||
if (nextExpanded) {
|
||||
requestAnimationFrame(() => {
|
||||
void this.ensureWaveformLoaded();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onLoadedMetadata(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
this.applyAudioVolume(this.volumePercent());
|
||||
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||
this.currentTimeSeconds.set(audio.currentTime || 0);
|
||||
}
|
||||
|
||||
onTimeUpdate(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
this.currentTimeSeconds.set(audio.currentTime || 0);
|
||||
}
|
||||
|
||||
onPlay(): void {
|
||||
this.isPlaying.set(true);
|
||||
}
|
||||
|
||||
onPause(): void {
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
|
||||
onSeek(event: Event): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
const nextTime = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
if (!Number.isFinite(nextTime))
|
||||
return;
|
||||
|
||||
audio.currentTime = nextTime;
|
||||
this.currentTimeSeconds.set(nextTime);
|
||||
}
|
||||
|
||||
onVolumeInput(event: Event): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
|
||||
|
||||
if (nextVolume <= 0) {
|
||||
audio.volume = 0;
|
||||
audio.muted = true;
|
||||
this.isMuted.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.volume = nextVolume / 100;
|
||||
audio.muted = false;
|
||||
|
||||
this.volumePercent.set(nextVolume);
|
||||
this.lastNonZeroVolume.set(nextVolume);
|
||||
this.isMuted.set(false);
|
||||
this.setSharedVolume(nextVolume);
|
||||
}
|
||||
|
||||
onVolumeChange(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
this.isMuted.set(audio.muted || audio.volume === 0);
|
||||
|
||||
if (!audio.muted && audio.volume > 0) {
|
||||
const volume = Math.round(audio.volume * 100);
|
||||
|
||||
this.volumePercent.set(volume);
|
||||
this.lastNonZeroVolume.set(volume);
|
||||
this.setSharedVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
if (audio.muted || audio.volume === 0) {
|
||||
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
|
||||
|
||||
audio.muted = false;
|
||||
audio.volume = restoredVolume / 100;
|
||||
this.volumePercent.set(restoredVolume);
|
||||
this.isMuted.set(false);
|
||||
this.setSharedVolume(restoredVolume);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.muted = true;
|
||||
this.isMuted.set(true);
|
||||
}
|
||||
|
||||
requestDownload(): void {
|
||||
this.downloadRequested.emit(undefined);
|
||||
}
|
||||
|
||||
private async ensureWaveformLoaded(): Promise<void> {
|
||||
if (this.waveformLoading() || this.waveSurfer || this.waveformUnavailable())
|
||||
return;
|
||||
|
||||
const source = this.src();
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
const waveformContainer = this.waveformContainer?.nativeElement;
|
||||
|
||||
if (!source || !audio || !waveformContainer)
|
||||
return;
|
||||
|
||||
this.waveformLoading.set(true);
|
||||
|
||||
try {
|
||||
const { default: WaveSurfer } = await import('wavesurfer.js');
|
||||
|
||||
this.waveSurfer = WaveSurfer.create({
|
||||
barGap: 2,
|
||||
barRadius: 999,
|
||||
barWidth: 3,
|
||||
container: waveformContainer,
|
||||
cursorWidth: 0,
|
||||
dragToSeek: true,
|
||||
height: 56,
|
||||
hideScrollbar: true,
|
||||
interact: true,
|
||||
media: audio,
|
||||
normalize: true,
|
||||
progressColor: this.resolveThemeColor('--primary', 'hsl(262 83% 58%)', 0.9),
|
||||
waveColor: this.resolveThemeColor('--foreground', 'hsl(215 16% 47%)', 0.22)
|
||||
});
|
||||
|
||||
this.waveSurfer.on('error', () => {
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(true);
|
||||
this.destroyWaveSurfer();
|
||||
});
|
||||
|
||||
this.waveSurfer.on('ready', () => {
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(false);
|
||||
});
|
||||
} catch {
|
||||
this.destroyWaveSurfer();
|
||||
this.waveformUnavailable.set(true);
|
||||
} finally {
|
||||
if (this.waveformUnavailable()) {
|
||||
this.waveformLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private destroyWaveSurfer(): void {
|
||||
if (!this.waveSurfer)
|
||||
return;
|
||||
|
||||
this.waveSurfer.destroy();
|
||||
this.waveSurfer = null;
|
||||
}
|
||||
|
||||
private resolveThemeColor(cssVarName: string, fallback: string, alpha: number): string {
|
||||
if (typeof window === 'undefined')
|
||||
return fallback;
|
||||
|
||||
const rawValue = window.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(cssVarName)
|
||||
.trim();
|
||||
|
||||
if (!rawValue)
|
||||
return fallback;
|
||||
|
||||
return `hsl(${rawValue} / ${alpha})`;
|
||||
}
|
||||
|
||||
private applyAudioVolume(volumePercent: number): void {
|
||||
const audio = this.audioRef?.nativeElement;
|
||||
|
||||
if (!audio)
|
||||
return;
|
||||
|
||||
audio.volume = this.isMuted() ? 0 : volumePercent / 100;
|
||||
audio.muted = this.isMuted();
|
||||
}
|
||||
|
||||
private setSharedVolume(volumePercent: number): void {
|
||||
persistSharedAudioPlayerVolume(volumePercent);
|
||||
}
|
||||
|
||||
private buildSliderBackground(fillPercent: number): string {
|
||||
return [
|
||||
'linear-gradient(90deg, ',
|
||||
'hsl(var(--primary)) 0%, ',
|
||||
`hsl(var(--primary)) ${fillPercent}%, `,
|
||||
`hsl(var(--secondary)) ${fillPercent}%, `,
|
||||
'hsl(var(--secondary)) 100%)'
|
||||
].join('');
|
||||
}
|
||||
|
||||
formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0)
|
||||
return '0:00';
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<div
|
||||
#playerRoot
|
||||
class="video-player-shell"
|
||||
[class.fullscreen]="isFullscreen()"
|
||||
[class.controls-hidden]="isFullscreen() && !controlsVisible()"
|
||||
(mousemove)="onPlayerMouseMove()"
|
||||
>
|
||||
@if (!isFullscreen()) {
|
||||
<div class="video-top-bar">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
|
||||
@if (sizeLabel()) {
|
||||
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="video-stage"
|
||||
(click)="onVideoClick()"
|
||||
(dblclick)="onVideoDoubleClick($event)"
|
||||
(keydown.enter)="onVideoClick()"
|
||||
(keydown.space)="onVideoClick(); $event.preventDefault()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Toggle video playback"
|
||||
>
|
||||
<video
|
||||
#videoEl
|
||||
[src]="src()"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="chat-video-element"
|
||||
(ended)="onPause()"
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(pause)="onPause()"
|
||||
(play)="onPlay()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(volumechange)="onVolumeChange()"
|
||||
></video>
|
||||
|
||||
@if (!isPlaying()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="onOverlayPlayClick($event)"
|
||||
class="video-play-overlay"
|
||||
title="Play video"
|
||||
aria-label="Play video"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
class="w-8 h-8"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="video-bottom-bar"
|
||||
[class.fullscreen-overlay]="isFullscreen()"
|
||||
[class.hidden-overlay]="isFullscreen() && !controlsVisible()"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
[max]="durationSeconds() || 0"
|
||||
[value]="currentTimeSeconds()"
|
||||
(input)="onSeek($event)"
|
||||
class="seek-slider"
|
||||
[style.background]="seekTrackBackground()"
|
||||
aria-label="Seek video"
|
||||
/>
|
||||
|
||||
<div class="video-controls-row">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
(click)="togglePlayback()"
|
||||
class="video-control-btn"
|
||||
[title]="isPlaying() ? 'Pause' : 'Play'"
|
||||
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span class="video-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
|
||||
</div>
|
||||
|
||||
<div class="video-volume-group">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
class="video-control-btn"
|
||||
[title]="isMuted() ? 'Unmute' : 'Mute'"
|
||||
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="isMuted() ? 0 : volumePercent()"
|
||||
(input)="onVolumeInput($event)"
|
||||
class="volume-slider"
|
||||
[style.background]="volumeTrackBackground()"
|
||||
aria-label="Video volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (isFullscreen()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="requestDownload()"
|
||||
class="video-control-btn"
|
||||
title="Save to folder"
|
||||
aria-label="Save video to folder"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleFullscreen()"
|
||||
class="video-control-btn"
|
||||
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
|
||||
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isFullscreen() ? 'lucideMinimize' : 'lucideMaximize'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,243 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.video-player-shell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.16), transparent 38%),
|
||||
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(222deg 47% 8%) 100%);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.video-top-bar,
|
||||
.video-bottom-bar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.video-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0.875rem 0.625rem;
|
||||
background: linear-gradient(180deg, rgb(6 10 18 / 82%) 0%, rgb(6 10 18 / 30%) 100%);
|
||||
}
|
||||
|
||||
.video-stage {
|
||||
position: relative;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen .video-stage {
|
||||
height: 100vh;
|
||||
background: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen.controls-hidden .video-stage,
|
||||
.video-player-shell.fullscreen.controls-hidden .chat-video-element {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.chat-video-element {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: min(28rem, 70vh);
|
||||
background: rgb(0 0 0 / 85%);
|
||||
cursor: pointer;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-player-shell.fullscreen .chat-video-element {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.video-play-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: rgb(8 14 24 / 78%);
|
||||
box-shadow: 0 12px 24px rgb(0 0 0 / 35%);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.video-play-overlay:hover {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
background: rgb(12 18 30 / 88%);
|
||||
}
|
||||
|
||||
.video-bottom-bar {
|
||||
padding: 0.75rem 0.875rem 0.875rem;
|
||||
background: linear-gradient(180deg, rgb(6 10 18 / 38%) 0%, rgb(6 10 18 / 86%) 100%);
|
||||
}
|
||||
|
||||
.video-bottom-bar.fullscreen-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
padding-bottom: max(0.875rem, env(safe-area-inset-bottom));
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.video-bottom-bar.hidden-overlay {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.video-control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card) / 0.72);
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.video-control-btn:hover {
|
||||
border-color: hsl(var(--primary) / 0.75);
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.video-time-label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-volume-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.seek-slider,
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--primary)) var(--value, 0%), hsl(var(--secondary)) var(--value, 0%), hsl(var(--secondary)) 100%);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 5.5rem;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-runnable-track,
|
||||
.volume-slider::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.seek-slider::-webkit-slider-thumb,
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -4px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-track,
|
||||
.volume-slider::-moz-range-track {
|
||||
height: 6px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.seek-slider::-moz-range-thumb,
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.video-top-bar {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.video-bottom-bar {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.video-controls-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.video-volume-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-time-label {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideDownload,
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-video-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideDownload,
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucidePause,
|
||||
lucidePlay,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-video-player.component.html',
|
||||
styleUrl: './chat-video-player.component.scss'
|
||||
})
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
export class ChatVideoPlayerComponent implements OnDestroy {
|
||||
src = input.required<string>();
|
||||
filename = input.required<string>();
|
||||
sizeLabel = input<string>('');
|
||||
downloadRequested = output<undefined>();
|
||||
|
||||
private readonly SINGLE_CLICK_DELAY_MS = 300;
|
||||
private readonly FULLSCREEN_IDLE_MS = 2200;
|
||||
|
||||
@ViewChild('playerRoot') playerRoot?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('videoEl') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
|
||||
isPlaying = signal(false);
|
||||
isMuted = signal(false);
|
||||
isFullscreen = signal(false);
|
||||
controlsVisible = signal(true);
|
||||
currentTimeSeconds = signal(0);
|
||||
durationSeconds = signal(0);
|
||||
volumePercent = signal(100);
|
||||
private lastNonZeroVolume = signal(100);
|
||||
private singleClickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
progressPercent = computed(() => {
|
||||
const duration = this.durationSeconds();
|
||||
|
||||
if (duration <= 0)
|
||||
return 0;
|
||||
|
||||
return (this.currentTimeSeconds() / duration) * 100;
|
||||
});
|
||||
seekTrackBackground = computed(() => {
|
||||
const progress = Math.max(0, Math.min(100, this.progressPercent()));
|
||||
|
||||
return this.buildSliderBackground(progress);
|
||||
});
|
||||
volumeTrackBackground = computed(() => {
|
||||
const volume = Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()));
|
||||
|
||||
return this.buildSliderBackground(volume);
|
||||
});
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
const player = this.playerRoot?.nativeElement;
|
||||
const isFullscreen = !!player && document.fullscreenElement === player;
|
||||
|
||||
this.isFullscreen.set(isFullscreen);
|
||||
|
||||
if (isFullscreen) {
|
||||
this.revealControlsTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearControlsHideTimer();
|
||||
this.clearSingleClickTimer();
|
||||
}
|
||||
|
||||
onPlayerMouseMove(): void {
|
||||
if (!this.isFullscreen())
|
||||
return;
|
||||
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onVideoClick(): void {
|
||||
this.clearSingleClickTimer();
|
||||
this.revealControlsTemporarily();
|
||||
this.singleClickTimer = setTimeout(() => {
|
||||
this.singleClickTimer = null;
|
||||
this.togglePlayback();
|
||||
}, this.SINGLE_CLICK_DELAY_MS);
|
||||
}
|
||||
|
||||
onVideoDoubleClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.clearSingleClickTimer();
|
||||
void this.toggleFullscreen();
|
||||
}
|
||||
|
||||
onOverlayPlayClick(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.togglePlayback();
|
||||
}
|
||||
|
||||
togglePlayback(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
if (video.paused || video.ended) {
|
||||
void video.play().catch(() => {
|
||||
this.isPlaying.set(false);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
video.pause();
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onLoadedMetadata(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
this.durationSeconds.set(Number.isFinite(video.duration) ? video.duration : 0);
|
||||
this.currentTimeSeconds.set(video.currentTime || 0);
|
||||
}
|
||||
|
||||
onTimeUpdate(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
this.currentTimeSeconds.set(video.currentTime || 0);
|
||||
}
|
||||
|
||||
onPlay(): void {
|
||||
this.isPlaying.set(true);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onPause(): void {
|
||||
this.isPlaying.set(false);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onSeek(event: Event): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
const nextTime = Number((event.target as HTMLInputElement).value);
|
||||
|
||||
if (!Number.isFinite(nextTime))
|
||||
return;
|
||||
|
||||
video.currentTime = nextTime;
|
||||
this.currentTimeSeconds.set(nextTime);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onVolumeInput(event: Event): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
|
||||
|
||||
video.volume = nextVolume / 100;
|
||||
video.muted = nextVolume === 0;
|
||||
|
||||
if (nextVolume > 0)
|
||||
this.lastNonZeroVolume.set(nextVolume);
|
||||
|
||||
this.volumePercent.set(nextVolume);
|
||||
this.isMuted.set(video.muted);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
onVolumeChange(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
this.isMuted.set(video.muted || video.volume === 0);
|
||||
|
||||
if (!video.muted && video.volume > 0) {
|
||||
const volume = Math.round(video.volume * 100);
|
||||
|
||||
this.volumePercent.set(volume);
|
||||
this.lastNonZeroVolume.set(volume);
|
||||
}
|
||||
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
const video = this.videoRef?.nativeElement;
|
||||
|
||||
if (!video)
|
||||
return;
|
||||
|
||||
if (video.muted || video.volume === 0) {
|
||||
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
|
||||
|
||||
video.muted = false;
|
||||
video.volume = restoredVolume / 100;
|
||||
this.volumePercent.set(restoredVolume);
|
||||
this.isMuted.set(false);
|
||||
this.revealControlsTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
video.muted = true;
|
||||
this.isMuted.set(true);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
async toggleFullscreen(): Promise<void> {
|
||||
const player = this.playerRoot?.nativeElement;
|
||||
|
||||
if (!player)
|
||||
return;
|
||||
|
||||
if (document.fullscreenElement === player) {
|
||||
await document.exitFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
await player.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
|
||||
requestDownload(): void {
|
||||
this.downloadRequested.emit(undefined);
|
||||
this.revealControlsTemporarily();
|
||||
}
|
||||
|
||||
private buildSliderBackground(fillPercent: number): string {
|
||||
return [
|
||||
'linear-gradient(90deg, ',
|
||||
'hsl(var(--primary)) 0%, ',
|
||||
`hsl(var(--primary)) ${fillPercent}%, `,
|
||||
`hsl(var(--secondary)) ${fillPercent}%, `,
|
||||
'hsl(var(--secondary)) 100%)'
|
||||
].join('');
|
||||
}
|
||||
|
||||
private revealControlsTemporarily(): void {
|
||||
if (!this.isFullscreen()) {
|
||||
this.controlsVisible.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlsVisible.set(true);
|
||||
this.clearControlsHideTimer();
|
||||
this.controlsHideTimer = setTimeout(() => {
|
||||
this.controlsVisible.set(false);
|
||||
}, this.FULLSCREEN_IDLE_MS);
|
||||
}
|
||||
|
||||
private clearControlsHideTimer(): void {
|
||||
if (this.controlsHideTimer) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSingleClickTimer(): void {
|
||||
if (this.singleClickTimer) {
|
||||
clearTimeout(this.singleClickTimer);
|
||||
this.singleClickTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0)
|
||||
return '0:00';
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close dialog"
|
||||
></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||
[class]="widthClass()"
|
||||
>
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button
|
||||
(click)="cancelled.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
|
||||
>
|
||||
{{ cancelLabel() }}
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmed.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||
[class.bg-primary]="variant() === 'primary'"
|
||||
[class.text-primary-foreground]="variant() === 'primary'"
|
||||
[class.hover:bg-primary/90]="variant() === 'primary'"
|
||||
[class.bg-destructive]="variant() === 'danger'"
|
||||
[class.text-destructive-foreground]="variant() === 'danger'"
|
||||
[class.hover:bg-destructive/90]="variant() === 'danger'"
|
||||
>
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,51 +26,10 @@ import {
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
template: `
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close dialog"
|
||||
></div>
|
||||
<!-- Dialog -->
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
|
||||
[class]="widthClass()">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<ng-content />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button
|
||||
(click)="cancelled.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
|
||||
>
|
||||
{{ cancelLabel() }}
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmed.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
|
||||
[class.bg-primary]="variant() === 'primary'"
|
||||
[class.text-primary-foreground]="variant() === 'primary'"
|
||||
[class.hover:bg-primary/90]="variant() === 'primary'"
|
||||
[class.bg-destructive]="variant() === 'danger'"
|
||||
[class.text-destructive-foreground]="variant() === 'danger'"
|
||||
[class.hover:bg-destructive/90]="variant() === 'danger'"
|
||||
>
|
||||
{{ confirmLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [':host { display: contents; }']
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
/** Dialog title. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/30"
|
||||
(click)="cancelled.emit(undefined)"
|
||||
(keydown.enter)="cancelled.emit(undefined)"
|
||||
(keydown.space)="cancelled.emit(undefined)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close dialog"
|
||||
></div>
|
||||
<div
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[360px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card shadow-lg"
|
||||
>
|
||||
<div class="space-y-3 p-4">
|
||||
<h4 class="font-semibold text-foreground">Leave Server?</h4>
|
||||
<div class="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Leaving will remove
|
||||
<span class="font-medium text-foreground">{{ room().name }}</span>
|
||||
from your My Servers list.
|
||||
</p>
|
||||
|
||||
@if (isOwner()) {
|
||||
<div class="space-y-2 rounded-md border border-border/80 bg-secondary/20 p-3">
|
||||
<p class="text-foreground">You are the current owner of this server.</p>
|
||||
<p>You can optionally promote another member before leaving. If you skip this step, the server will continue without an owner.</p>
|
||||
|
||||
@if (ownerCandidates().length > 0) {
|
||||
<label class="block space-y-1">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> New owner </span>
|
||||
<select
|
||||
class="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[ngModel]="selectedOwnerKey()"
|
||||
(ngModelChange)="selectedOwnerKey.set($event || '')"
|
||||
>
|
||||
<option value="">Skip owner transfer</option>
|
||||
@for (member of ownerCandidates(); track roomMemberKey(member)) {
|
||||
<option [value]="roomMemberKey(member)">{{ member.displayName }} - {{ roleLabel(member) }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
} @else {
|
||||
<p>No other known members are available to promote right now.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 border-t border-border p-3">
|
||||
<button
|
||||
(click)="cancelled.emit(undefined)"
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmLeave()"
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-destructive px-3 py-2 text-sm text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
User
|
||||
} from '../../../core/models';
|
||||
|
||||
export interface LeaveServerDialogResult {
|
||||
nextOwnerKey?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-leave-server-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './leave-server-dialog.component.html'
|
||||
})
|
||||
export class LeaveServerDialogComponent {
|
||||
room = input.required<Room>();
|
||||
currentUser = input<User | null>(null);
|
||||
confirmed = output<LeaveServerDialogResult>();
|
||||
cancelled = output<undefined>();
|
||||
|
||||
selectedOwnerKey = signal('');
|
||||
|
||||
isOwner = computed(() => {
|
||||
const room = this.room();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!room || !user)
|
||||
return false;
|
||||
|
||||
return room.hostId === user.id || room.hostId === user.oderId;
|
||||
});
|
||||
|
||||
ownerCandidates = computed(() => {
|
||||
const room = this.room();
|
||||
const user = this.currentUser();
|
||||
const userIds = new Set([user?.id, user?.oderId].filter((value): value is string => !!value));
|
||||
|
||||
return (room.members ?? []).filter((member) => !userIds.has(member.id) && !userIds.has(member.oderId || ''));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.room();
|
||||
this.currentUser();
|
||||
this.selectedOwnerKey.set('');
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.cancelled.emit(undefined);
|
||||
}
|
||||
|
||||
confirmLeave(): void {
|
||||
this.confirmed.emit(
|
||||
this.selectedOwnerKey()
|
||||
? { nextOwnerKey: this.selectedOwnerKey() }
|
||||
: {}
|
||||
);
|
||||
}
|
||||
|
||||
roomMemberKey(member: RoomMember): string {
|
||||
return member.oderId || member.id;
|
||||
}
|
||||
|
||||
roleLabel(member: RoomMember): string {
|
||||
switch (member.role) {
|
||||
case 'host':
|
||||
return 'Owner';
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'moderator':
|
||||
return 'Moderator';
|
||||
default:
|
||||
return 'Member';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@if (avatarUrl()) {
|
||||
<img
|
||||
[ngSrc]="avatarUrl()!"
|
||||
[width]="sizePx()"
|
||||
[height]="sizePx()"
|
||||
alt=""
|
||||
class="rounded-full object-cover"
|
||||
[class]="sizeClasses() + ' ' + ringClass()"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||
>
|
||||
{{ initial() }}
|
||||
</div>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
@@ -17,24 +18,11 @@ import { Component, input } from '@angular/core';
|
||||
@Component({
|
||||
selector: 'app-user-avatar',
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (avatarUrl()) {
|
||||
<img
|
||||
[src]="avatarUrl()"
|
||||
alt=""
|
||||
class="rounded-full object-cover"
|
||||
[class]="sizeClasses() + ' ' + ringClass()"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||
>
|
||||
{{ initial() }}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [':host { display: contents; }']
|
||||
imports: [NgOptimizedImage],
|
||||
templateUrl: './user-avatar.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class UserAvatarComponent {
|
||||
/** Display name - first character is used as fallback initial. */
|
||||
@@ -62,6 +50,16 @@ export class UserAvatarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map size token to explicit pixel dimensions for image optimisation. */
|
||||
sizePx(): number {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 28;
|
||||
case 'sm': return 32;
|
||||
case 'md': return 40;
|
||||
case 'lg': return 48;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map size token to text size for initials. */
|
||||
textClass(): string {
|
||||
switch (this.size()) {
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
export { ContextMenuComponent } from './components/context-menu/context-menu.component';
|
||||
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
|
||||
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
|
||||
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
|
||||
export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component';
|
||||
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
|
||||
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
|
||||
|
||||
@@ -22,17 +22,16 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { UsersActions } from '../users/users.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectSavedRooms
|
||||
} from './rooms.selectors';
|
||||
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||
import {
|
||||
areRoomMembersEqual,
|
||||
findRoomMember,
|
||||
mergeRoomMembers,
|
||||
pruneRoomMembers,
|
||||
removeRoomMember,
|
||||
roomMemberFromUser,
|
||||
touchRoomMemberLastSeen,
|
||||
transferRoomOwnership,
|
||||
updateRoomMemberRole,
|
||||
upsertRoomMember
|
||||
} from './room-members.helpers';
|
||||
@@ -79,7 +78,10 @@ export class RoomMembersSyncEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([, currentUser, currentRoom]) => {
|
||||
mergeMap(([
|
||||
, currentUser,
|
||||
currentRoom
|
||||
]) => {
|
||||
if (!currentUser || !currentRoom)
|
||||
return EMPTY;
|
||||
|
||||
@@ -119,7 +121,12 @@ export class RoomMembersSyncEffects {
|
||||
this.store.select(selectSavedRooms),
|
||||
this.store.select(selectCurrentUser)
|
||||
),
|
||||
mergeMap(([message, currentRoom, savedRooms, currentUser]) => {
|
||||
mergeMap(([
|
||||
message,
|
||||
currentRoom,
|
||||
savedRooms,
|
||||
currentUser
|
||||
]) => {
|
||||
const signalingMessage = message as any;
|
||||
const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined;
|
||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||
@@ -136,7 +143,7 @@ export class RoomMembersSyncEffects {
|
||||
|
||||
let members = room.members ?? [];
|
||||
|
||||
for (const user of signalingMessage.users as Array<{ oderId: string; displayName: string }>) {
|
||||
for (const user of signalingMessage.users as { oderId: string; displayName: string }[]) {
|
||||
if (!user?.oderId || user.oderId === myId)
|
||||
continue;
|
||||
|
||||
@@ -229,7 +236,12 @@ export class RoomMembersSyncEffects {
|
||||
this.store.select(selectSavedRooms),
|
||||
this.store.select(selectCurrentUser)
|
||||
),
|
||||
mergeMap(([event, currentRoom, savedRooms, currentUser]) => {
|
||||
mergeMap(([
|
||||
event,
|
||||
currentRoom,
|
||||
savedRooms,
|
||||
currentUser
|
||||
]) => {
|
||||
switch (event.type) {
|
||||
case 'member-roster-request': {
|
||||
const actions = this.handleMemberRosterRequest(event, currentRoom, savedRooms, currentUser ?? null);
|
||||
@@ -249,6 +261,12 @@ export class RoomMembersSyncEffects {
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
}
|
||||
|
||||
case 'host-change': {
|
||||
const actions = this.handleIncomingHostChange(event, currentRoom, savedRooms, currentUser ?? null);
|
||||
|
||||
return actions.length > 0 ? actions : EMPTY;
|
||||
}
|
||||
|
||||
case 'role-change': {
|
||||
const actions = this.handleIncomingRoleChange(event, currentRoom, savedRooms);
|
||||
|
||||
@@ -326,6 +344,7 @@ export class RoomMembersSyncEffects {
|
||||
return [];
|
||||
|
||||
const isCurrentRoom = currentRoom?.id === room.id;
|
||||
|
||||
let members = room.members ?? [];
|
||||
|
||||
if (currentUser) {
|
||||
@@ -392,6 +411,78 @@ export class RoomMembersSyncEffects {
|
||||
return actions;
|
||||
}
|
||||
|
||||
private handleIncomingHostChange(
|
||||
event: any,
|
||||
currentRoom: Room | null,
|
||||
savedRooms: Room[],
|
||||
currentUser: User | null
|
||||
): Action[] {
|
||||
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||
|
||||
if (!room)
|
||||
return [];
|
||||
|
||||
const members = Array.isArray(event.members)
|
||||
? pruneRoomMembers(event.members)
|
||||
: transferRoomOwnership(
|
||||
room.members ?? [],
|
||||
event.hostId || event.hostOderId
|
||||
? {
|
||||
id: event.hostId,
|
||||
oderId: event.hostOderId
|
||||
}
|
||||
: null,
|
||||
{
|
||||
id: event.previousHostId,
|
||||
oderId: event.previousHostOderId
|
||||
}
|
||||
);
|
||||
const hostId = typeof event.hostId === 'string' ? event.hostId : '';
|
||||
const actions: Action[] = [
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
hostId,
|
||||
members
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
for (const previousHostKey of new Set([event.previousHostId, event.previousHostOderId].filter((value): value is string => !!value))) {
|
||||
actions.push(
|
||||
UsersActions.updateUserRole({
|
||||
userId: previousHostKey,
|
||||
role: 'member'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const nextHostKey of new Set([event.hostId, event.hostOderId].filter((value): value is string => !!value))) {
|
||||
actions.push(
|
||||
UsersActions.updateUserRole({
|
||||
userId: nextHostKey,
|
||||
role: 'host'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (currentUser) {
|
||||
const isCurrentUserNextHost = event.hostId === currentUser.id || event.hostOderId === currentUser.oderId;
|
||||
const isCurrentUserPreviousHost = event.previousHostId === currentUser.id || event.previousHostOderId === currentUser.oderId;
|
||||
|
||||
if (isCurrentUserPreviousHost && !isCurrentUserNextHost) {
|
||||
actions.push(UsersActions.updateCurrentUser({ updates: { role: 'member' } }));
|
||||
}
|
||||
|
||||
if (isCurrentUserNextHost) {
|
||||
actions.push(UsersActions.updateCurrentUser({ updates: { role: 'host' } }));
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private handleIncomingRoleChange(
|
||||
event: any,
|
||||
currentRoom: Room | null,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
RoomMember,
|
||||
User
|
||||
} from '../../core/models';
|
||||
import { RoomMember, User } from '../../core/models';
|
||||
|
||||
/** Remove members that have not been seen for roughly two months. */
|
||||
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;
|
||||
@@ -234,7 +231,7 @@ export function touchRoomMemberLastSeen(
|
||||
/** Remove a member from a room roster by either ID flavor. */
|
||||
export function removeRoomMember(
|
||||
members: RoomMember[] = [],
|
||||
...identifiers: Array<string | undefined>
|
||||
...identifiers: (string | undefined)[]
|
||||
): RoomMember[] {
|
||||
const ids = new Set(identifiers.filter((identifier): identifier is string => !!identifier));
|
||||
|
||||
@@ -246,6 +243,43 @@ export function removeRoomMember(
|
||||
);
|
||||
}
|
||||
|
||||
/** Reassign ownership within a room roster, optionally leaving the room ownerless. */
|
||||
export function transferRoomOwnership(
|
||||
members: RoomMember[] = [],
|
||||
nextOwner: Partial<RoomMember> | null,
|
||||
previousOwner?: Pick<RoomMember, 'id' | 'oderId'>,
|
||||
now = Date.now()
|
||||
): RoomMember[] {
|
||||
const nextMembers = pruneRoomMembers(members, now).map((member) => {
|
||||
const isPreviousOwner =
|
||||
member.role === 'host'
|
||||
|| (!!previousOwner?.id && member.id === previousOwner.id)
|
||||
|| (!!previousOwner?.oderId && member.oderId === previousOwner.oderId);
|
||||
|
||||
return isPreviousOwner
|
||||
? { ...member,
|
||||
role: 'member' as const }
|
||||
: member;
|
||||
});
|
||||
|
||||
if (!nextOwner || !(nextOwner.id || nextOwner.oderId))
|
||||
return pruneRoomMembers(nextMembers, now);
|
||||
|
||||
const existingNextOwner = findRoomMember(nextMembers, nextOwner.id || nextOwner.oderId);
|
||||
const nextOwnerMember: RoomMember = {
|
||||
id: existingNextOwner?.id || nextOwner.id || nextOwner.oderId || '',
|
||||
oderId: existingNextOwner?.oderId || nextOwner.oderId || undefined,
|
||||
username: existingNextOwner?.username || nextOwner.username || '',
|
||||
displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User',
|
||||
avatarUrl: existingNextOwner?.avatarUrl || nextOwner.avatarUrl || undefined,
|
||||
role: 'host',
|
||||
joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now,
|
||||
lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now
|
||||
};
|
||||
|
||||
return upsertRoomMember(nextMembers, nextOwnerMember, now);
|
||||
}
|
||||
|
||||
/** Update a persisted member role without touching presence timestamps. */
|
||||
export function updateRoomMemberRole(
|
||||
members: RoomMember[] = [],
|
||||
|
||||
@@ -42,7 +42,7 @@ export const RoomsActions = createActionGroup({
|
||||
'Delete Room': props<{ roomId: string }>(),
|
||||
'Delete Room Success': props<{ roomId: string }>(),
|
||||
|
||||
'Forget Room': props<{ roomId: string }>(),
|
||||
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
|
||||
'Forget Room Success': props<{ roomId: string }>(),
|
||||
|
||||
'Update Room Settings': props<{ settings: Partial<RoomSettings> }>(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
/* eslint-disable id-length */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, complexity */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { RoomsActions } from './rooms.actions';
|
||||
import { UsersActions } from '../users/users.actions';
|
||||
import { MessagesActions } from '../messages/messages.actions';
|
||||
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
||||
import { selectCurrentRoom } from './rooms.selectors';
|
||||
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
VoiceState
|
||||
} from '../../core/models';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { findRoomMember } from './room-members.helpers';
|
||||
import {
|
||||
findRoomMember,
|
||||
removeRoomMember,
|
||||
transferRoomOwnership
|
||||
} from './room-members.helpers';
|
||||
|
||||
/** Build a minimal User object from signaling payload. */
|
||||
function buildSignalingUser(
|
||||
@@ -337,12 +341,64 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/** Forgets a room locally: removes from DB and leaves the signaling server for that room. */
|
||||
/** Leaves a server, optionally transfers ownership, and removes it locally. */
|
||||
forgetRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.forgetRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
switchMap(([{ roomId }, currentUser]) => {
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectSavedRooms)
|
||||
),
|
||||
switchMap(([
|
||||
{ roomId, nextOwnerKey },
|
||||
currentUser,
|
||||
currentRoom,
|
||||
savedRooms
|
||||
]) => {
|
||||
const room = currentRoom?.id === roomId
|
||||
? currentRoom
|
||||
: (savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null);
|
||||
const isRoomOwner = !!currentUser && !!room && (room.hostId === currentUser.id || room.hostId === currentUser.oderId);
|
||||
|
||||
if (currentUser && room && isRoomOwner) {
|
||||
const nextOwner = nextOwnerKey
|
||||
? (findRoomMember(room.members ?? [], nextOwnerKey) ?? null)
|
||||
: null;
|
||||
const updatedMembers = removeRoomMember(
|
||||
transferRoomOwnership(
|
||||
room.members ?? [],
|
||||
nextOwner,
|
||||
{
|
||||
id: room.hostId,
|
||||
oderId: currentUser.oderId
|
||||
}
|
||||
),
|
||||
currentUser.id,
|
||||
currentUser.oderId
|
||||
);
|
||||
const nextHostId = nextOwner?.id || nextOwner?.oderId || '';
|
||||
const nextHostOderId = nextOwner?.oderId || '';
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'host-change',
|
||||
roomId,
|
||||
hostId: nextHostId,
|
||||
hostOderId: nextHostOderId,
|
||||
previousHostId: room.hostId,
|
||||
previousHostOderId: currentUser.oderId,
|
||||
members: updatedMembers
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(roomId, {
|
||||
currentOwnerId: currentUser.id,
|
||||
ownerId: nextHostId,
|
||||
ownerPublicKey: nextHostOderId
|
||||
}).subscribe({
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
if (currentUser) {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'member-leave',
|
||||
@@ -359,7 +415,9 @@ export class RoomsEffects {
|
||||
// Leave this specific server (doesn't affect other servers)
|
||||
this.webrtc.leaveRoom(roomId);
|
||||
|
||||
return of(RoomsActions.forgetRoomSuccess({ roomId }));
|
||||
return currentRoom?.id === roomId
|
||||
? [RoomsActions.leaveRoomSuccess(), RoomsActions.forgetRoomSuccess({ roomId })]
|
||||
: of(RoomsActions.forgetRoomSuccess({ roomId }));
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -539,12 +597,8 @@ export class RoomsEffects {
|
||||
onJoinRoomSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.joinRoomSuccess),
|
||||
mergeMap(({ room }) => [
|
||||
MessagesActions.loadMessages({ roomId: room.id }),
|
||||
// Don't load users from database - they come from signaling server
|
||||
// UsersActions.loadRoomUsers({ roomId: room.id }),
|
||||
UsersActions.loadBans()
|
||||
])
|
||||
// Don't load users from database - they come from signaling server.
|
||||
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ import {
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import {
|
||||
BanEntry,
|
||||
User
|
||||
} from '../../core/models';
|
||||
import { BanEntry, User } from '../../core/models';
|
||||
|
||||
@Injectable()
|
||||
export class UsersEffects {
|
||||
|
||||
116
src/index.html
116
src/index.html
@@ -1,50 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MeToYou</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob:;">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<script>
|
||||
// Polyfills for Node.js modules used in browser
|
||||
if (typeof global === 'undefined') {
|
||||
window.global = window;
|
||||
}
|
||||
if (typeof process === 'undefined') {
|
||||
window.process = { env: {}, browser: true, version: '', versions: {} };
|
||||
}
|
||||
// Add nextTick polyfill for simple-peer/streams
|
||||
if (typeof process.nextTick === 'undefined') {
|
||||
window.process.nextTick = function(fn) {
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
}
|
||||
if (typeof Buffer === 'undefined') {
|
||||
window.Buffer = {
|
||||
isBuffer: function() { return false; },
|
||||
from: function() { return []; },
|
||||
alloc: function() { return []; }
|
||||
};
|
||||
}
|
||||
// Polyfill for util module (used by simple-peer/debug)
|
||||
if (typeof window.util === 'undefined') {
|
||||
window.util = {
|
||||
debuglog: function() { return function() {}; },
|
||||
inspect: function(obj) { return JSON.stringify(obj); },
|
||||
format: function() { return Array.prototype.slice.call(arguments).join(' '); },
|
||||
inherits: function(ctor, superCtor) {
|
||||
ctor.super_ = superCtor;
|
||||
ctor.prototype = Object.create(superCtor.prototype, {
|
||||
constructor: { value: ctor, enumerable: false, writable: true, configurable: true }
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>MeToYou</title>
|
||||
<base href="/" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:;"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="favicon.ico"
|
||||
/>
|
||||
<script>
|
||||
// Polyfills for Node.js modules used in browser
|
||||
if (typeof global === 'undefined') {
|
||||
window.global = window;
|
||||
}
|
||||
if (typeof process === 'undefined') {
|
||||
window.process = { env: {}, browser: true, version: '', versions: {} };
|
||||
}
|
||||
// Add nextTick polyfill for simple-peer/streams
|
||||
if (typeof process.nextTick === 'undefined') {
|
||||
window.process.nextTick = function (fn) {
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
}
|
||||
if (typeof Buffer === 'undefined') {
|
||||
window.Buffer = {
|
||||
isBuffer: function () {
|
||||
return false;
|
||||
},
|
||||
from: function () {
|
||||
return [];
|
||||
},
|
||||
alloc: function () {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
// Polyfill for util module (used by simple-peer/debug)
|
||||
if (typeof window.util === 'undefined') {
|
||||
window.util = {
|
||||
debuglog: function () {
|
||||
return function () {};
|
||||
},
|
||||
inspect: function (obj) {
|
||||
return JSON.stringify(obj);
|
||||
},
|
||||
format: function () {
|
||||
return Array.prototype.slice.call(arguments).join(' ');
|
||||
},
|
||||
inherits: function (ctor, superCtor) {
|
||||
ctor.super_ = superCtor;
|
||||
ctor.prototype = Object.create(superCtor.prototype, {
|
||||
constructor: { value: ctor, enumerable: false, writable: true, configurable: true }
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user