Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors

This commit is contained in:
2026-03-06 04:47:07 +01:00
parent 2d84fbd91a
commit fe2347b54e
65 changed files with 3593 additions and 1030 deletions

View File

@@ -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

View File

@@ -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 `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement;

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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,

View File

@@ -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()"
/>
}

View File

@@ -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);
}
}

View File

@@ -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>
}

View File

@@ -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);
}

View File

@@ -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')
}
];

View File

@@ -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()"
/>
}

View File

@@ -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. */

View File

@@ -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,