Split chat component into smaller components

This commit is contained in:
2026-03-07 18:06:45 +01:00
parent 901df84d13
commit 66246e4e16
15 changed files with 2831 additions and 2289 deletions

View File

@@ -1,836 +1,40 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
<div class="chat-layout relative h-full"> <div class="chat-layout relative h-full">
<!-- Messages List --> <app-chat-message-list
<div [allMessages]="allMessages()"
#messagesContainer [channelMessages]="channelMessages()"
class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" [loading]="loading()"
(scroll)="onScroll()" [syncing]="syncing()"
> [currentUserId]="currentUser()?.id ?? null"
<!-- Syncing indicator --> [isAdmin]="isAdmin()"
@if (syncing() && !loading()) { [bottomPadding]="composerBottomPadding()"
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground"> [conversationKey]="conversationKey()"
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div> (replyRequested)="setReplyTo($event)"
<span>Syncing messages…</span> (deleteRequested)="handleDeleteRequested($event)"
</div> (editSaved)="handleEditSaved($event)"
} (reactionAdded)="handleReactionAdded($event)"
@if (loading()) { (reactionToggled)="handleReactionToggled($event)"
<div class="flex items-center justify-center py-8"> (downloadRequested)="downloadAttachment($event)"
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> (imageOpened)="openLightbox($event)"
</div> (imageContextMenuRequested)="openImageContextMenu($event)"
} @else if (messages().length === 0) { />
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
<!-- Infinite scroll: load-more sentinel at top -->
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
} @else {
<button
(click)="loadMore()"
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary"
>
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<div
[attr.data-message-id]="message.id"
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
[class.opacity-50]="message.isDeleted"
>
<!-- Avatar -->
<app-user-avatar
[name]="message.senderName"
size="md"
class="flex-shrink-0"
/>
<!-- Message Content --> <div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<div class="flex-1 min-w-0"> <app-chat-message-composer
<!-- Reply indicator --> [replyTo]="replyTo()"
@if (message.replyToId) { (messageSubmitted)="handleMessageSubmitted($event)"
@let repliedMsg = getRepliedMessage(message.replyToId); (typingStarted)="handleTypingStarted()"
<div (replyCleared)="clearReply()"
class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (heightChanged)="handleComposerHeightChanged($event)"
(click)="scrollToMessage(message.replyToId)"
>
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
<ng-icon
name="lucideReply"
class="w-3 h-3"
/>
@if (repliedMsg) {
<span class="font-medium">{{ repliedMsg.senderName }}</span>
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
<span class="text-xs text-muted-foreground">
{{ formatTimestamp(message.timestamp) }}
</span>
@if (message.editedAt) {
<span class="text-xs text-muted-foreground">(edited)</span>
}
</div>
@if (editingMessageId() === message.id) {
<!-- Edit Mode -->
<div class="mt-1 flex gap-2">
<input
type="text"
[(ngModel)]="editContent"
(keydown.enter)="saveEdit(message.id)"
(keydown.escape)="cancelEdit()"
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
(click)="saveEdit(message.id)"
class="p-1 text-primary hover:bg-primary/10 rounded"
>
<ng-icon
name="lucideCheck"
class="w-4 h-4"
/>
</button>
<button
(click)="cancelEdit()"
class="p-1 text-muted-foreground hover:bg-secondary rounded"
>
<ng-icon
name="lucideX"
class="w-4 h-4"
/>
</button>
</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark
[markdown]="message.content"
[processor]="remarkProcessor"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang)) {
<remark-mermaid [code]="node.value" />
} @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ 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) {
<div class="mt-2 space-y-2">
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<!-- Available image with hover overlay -->
<div
class="relative group/img inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="rounded-md max-h-80 w-auto cursor-pointer"
(click)="openLightbox(att)"
/>
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="View full size"
>
<ng-icon
name="lucideExpand"
class="w-4 h-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<!-- Downloading in progress -->
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
<ng-icon
name="lucideImage"
class="w-5 h-5 text-primary"
/>
</div>
<div class="flex-1 min-w-0">
<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>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</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>
} @else {
<!-- Unavailable - waiting for source -->
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
<ng-icon
name="lucideImage"
class="w-5 h-5 text-muted-foreground"
/>
</div>
<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 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
(click)="retryImageRequest(att, message.id)"
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
>
Retry
</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">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!isUploader(att)) {
@if (!att.available) {
<div class="w-24 h-1.5 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>
Cancel
</button>
}
} @else {
<button
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
(click)="downloadAttachment(att)"
>
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</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>
}
}
</div>
}
}
<!-- Reactions -->
@if (message.reactions.length > 0) {
<div class="flex flex-wrap gap-1 mt-2">
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
<button
(click)="toggleReaction(message.id, reaction.emoji)"
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
[class.ring-1]="reaction.hasCurrentUser"
[class.ring-primary]="reaction.hasCurrentUser"
>
<span>{{ reaction.emoji }}</span>
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
</div>
}
</div>
<!-- Message Actions (visible on hover) -->
@if (!message.isDeleted) {
<div
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg"
>
<!-- Emoji Picker Toggle -->
<div class="relative">
<button
(click)="toggleEmojiPicker(message.id)"
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
>
<ng-icon
name="lucideSmile"
class="w-4 h-4 text-muted-foreground"
/>
</button>
@if (showEmojiPicker() === message.id) {
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(message.id, emoji)"
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
>
{{ emoji }}
</button>
}
</div>
}
</div>
<!-- Reply -->
<button
(click)="setReplyTo(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon
name="lucideReply"
class="w-4 h-4 text-muted-foreground"
/>
</button>
<!-- Edit (own messages only) -->
@if (isOwnMessage(message)) {
<button
(click)="startEdit(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon
name="lucideEdit"
class="w-4 h-4 text-muted-foreground"
/>
</button>
}
<!-- Delete (own messages or admin) -->
@if (isOwnMessage(message) || isAdmin()) {
<button
(click)="deleteMessage(message)"
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4 text-destructive"
/>
</button>
}
</div>
}
</div>
}
}
<!-- New messages snackbar (center bottom inside container) -->
@if (showNewMessagesBar()) {
<div class="sticky bottom-4 flex justify-center pointer-events-none">
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
<span class="text-sm text-muted-foreground">New messages</span>
<button
(click)="readLatest()"
class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm"
>
Read latest
</button>
</div>
</div>
}
</div>
<!-- Bottom bar: floats over messages -->
<div
#bottomBar
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<!-- Reply Preview -->
@if (replyTo()) {
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
<ng-icon
name="lucideReply"
class="w-4 h-4 text-muted-foreground"
/>
<span class="text-sm text-muted-foreground flex-1">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button
(click)="clearReply()"
class="p-1 hover:bg-secondary rounded"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-muted-foreground"
/>
</button>
</div>
}
<!-- Typing Indicator -->
<app-typing-indicator />
<!-- Markdown Toolbar -->
@if (toolbarVisible()) {
<div
class="pointer-events-auto"
(mousedown)="$event.preventDefault()"
(mouseenter)="onToolbarMouseEnter()"
(mouseleave)="onToolbarMouseLeave()"
>
<div
class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm"
>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline('**')"
>
<b>B</b>
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline('*')"
>
<i>I</i>
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline('~~')"
>
<s>S</s>
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyInline(inlineCodeToken)"
>
&#96;
</button>
<span class="mx-1 text-muted-foreground">|</span>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHeading(1)"
>
H1
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHeading(2)"
>
H2
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHeading(3)"
>
H3
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyPrefix('> ')"
>
Quote
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyPrefix('- ')"
>
• List
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyOrderedList()"
>
1. List
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyCodeBlock()"
>
Code
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyLink()"
>
Link
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyImage()"
>
Image
</button>
<button
class="px-2 py-1 text-xs hover:bg-secondary rounded"
(click)="applyHorizontalRule()"
>
HR
</button>
</div>
</div>
}
<!-- Message Input -->
<div class="p-4 border-border">
<div
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(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"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange(); autoResizeTextarea()"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
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>
@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"
>
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</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) {
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button
(click)="removePendingFile(file)"
class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5"
>
Remove
</button>
</div>
}
</div>
}
</div>
</div>
</div>
@if (showKlipyGifPicker() && klipy.isEnabled()) {
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/> />
} </div>
<!-- Image Lightbox Modal --> <app-chat-message-overlays
@if (lightboxAttachment()) { [lightboxAttachment]="lightboxAttachment()"
<div [imageContextMenu]="imageContextMenu()"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm" (lightboxClosed)="closeLightbox()"
(click)="closeLightbox()" (contextMenuClosed)="closeImageContextMenu()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)" (downloadRequested)="downloadAttachment($event)"
(keydown.escape)="closeLightbox()" (copyRequested)="copyImageToClipboard($event)"
tabindex="0" (imageContextMenuRequested)="openImageContextMenu($event)"
#lightboxBackdrop />
>
<div
class="relative max-w-[90vw] max-h-[90vh]"
(click)="$event.stopPropagation()"
>
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<!-- Top-right action bar -->
<div class="absolute top-3 right-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon
name="lucideDownload"
class="w-5 h-5"
/>
</button>
<button
(click)="closeLightbox()"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Close"
>
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
</div>
<!-- Bottom info bar -->
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
<!-- Image Context Menu -->
@if (imageContextMenu()) {
<app-context-menu
[x]="imageContextMenu()!.x"
[y]="imageContextMenu()!.y"
(closed)="closeImageContextMenu()"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
class="context-menu-item-icon"
>
<ng-icon
name="lucideCopy"
class="w-4 h-4 text-muted-foreground"
/>
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4 text-muted-foreground"
/>
Save Image
</button>
</app-context-menu>
}
</div> </div>

View File

@@ -1,245 +1,12 @@
/* ── Chat layout: messages scroll behind input ──── */
.chat-layout { .chat-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.chat-messages-scroll {
/* Fallback; dynamically overridden by updateScrollPadding() */
padding-bottom: 120px;
}
.chat-bottom-bar { .chat-bottom-bar {
pointer-events: auto; pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: hsl(var(--background) / 0.85);
/* Inset from right so the scrollbar track stays visible */
right: 8px; right: 8px;
} background: hsl(var(--background) / 0.85);
/* Gradient fade-in above the bottom bar — blur only, no darkening */
.chat-bottom-fade {
height: 20px;
background: transparent;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
}
/* ── Chat textarea redesign ────────────────────── */
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
background: var(--textarea-bg);
/* Auto-resize: start at 62px, grow upward */
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
/* Show manual resize handle only while Ctrl is held */
&.ctrl-resize {
resize: vertical;
}
}
/* Send button: hidden by default, fades in on wrapper hover */
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
/* ── ngx-remark markdown styles ──────────────── */
.chat-markdown {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--foreground));
::ng-deep {
remark {
display: contents;
}
p {
margin: 0.25em 0;
}
strong {
font-weight: 700;
color: hsl(var(--foreground));
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
opacity: 0.7;
}
a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
ul, ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li {
margin: 0.125em 0;
}
blockquote {
border-left: 3px solid hsl(var(--primary) / 0.5);
margin: 0.5em 0;
padding: 0.25em 0.75em;
color: hsl(var(--muted-foreground));
background: hsl(var(--secondary) / 0.3);
border-radius: 0 var(--radius) var(--radius) 0;
}
code:not([class*='language-']) {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
background: hsl(var(--secondary));
padding: 0.15em 0.35em;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
}
pre {
overflow-x: auto;
max-width: 100%;
margin: 0.5em 0;
padding: 0.75em 1em;
background: hsl(var(--secondary));
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
code:not([class*='language-']) {
background: transparent;
padding: 0;
border-radius: 0;
white-space: pre;
word-break: normal;
}
}
pre[class*='language-'],
code[class*='language-'] {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
text-shadow: none;
}
pre[class*='language-'] {
padding: 0.875em 1rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
}
pre[class*='language-'] > code[class*='language-'] {
display: block;
background: transparent;
padding: 0;
border-radius: 0;
white-space: pre;
word-break: normal;
}
hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 0.75em 0;
}
table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.875em;
width: auto;
max-width: 100%;
overflow-x: auto;
display: block;
}
th, td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;
}
th {
background: hsl(var(--secondary));
font-weight: 600;
}
img {
max-width: 100%;
height: auto;
max-height: 320px;
border-radius: var(--radius);
display: block;
}
// Ensure consecutive paragraphs have minimal spacing for chat feel
p + p {
margin-top: 0.25em;
}
// Mermaid diagrams: prevent SVG from blocking clicks on the rest of the app
remark-mermaid {
display: block;
overflow-x: auto;
max-width: 100%;
svg {
pointer-events: none;
max-width: 100%;
height: auto;
}
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
<div #composerRoot>
@if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
/>
<span class="flex-1 text-sm text-muted-foreground">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button
(click)="clearReply()"
class="rounded p-1 hover:bg-secondary"
>
<ng-icon
name="lucideX"
class="h-4 w-4 text-muted-foreground"
/>
</button>
</div>
}
<app-typing-indicator />
@if (toolbarVisible()) {
<div
class="pointer-events-auto"
(mousedown)="$event.preventDefault()"
(mouseenter)="onToolbarMouseEnter()"
(mouseleave)="onToolbarMouseLeave()"
>
<div
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('**')"
>
<b>B</b>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('*')"
>
<i>I</i>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('~~')"
>
<s>S</s>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline(inlineCodeToken)"
>
&#96;
</button>
<span class="mx-1 text-muted-foreground">|</span>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(1)"
>
H1
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(2)"
>
H2
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(3)"
>
H3
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('> ')"
>
Quote
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('- ')"
>
• List
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyOrderedList()"
>
1. List
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyCodeBlock()"
>
Code
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyLink()"
>
Link
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyImage()"
>
Image
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHorizontalRule()"
>
HR
</button>
</div>
</div>
}
<div class="border-border p-4">
<div
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(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.opacity-100]="inputHovered() || showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
[class.text-primary]="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"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange(); autoResizeTextarea()"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
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.border-dashed]="dragActive()"
[class.border-primary]="dragActive()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
></textarea>
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
>
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</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) {
<div class="group flex items-center gap-2 rounded border border-border bg-secondary/60 px-2 py-1">
<div class="max-w-[14rem] truncate text-xs font-medium">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button
(click)="removePendingFile(file)"
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
>
Remove
</button>
</div>
}
</div>
}
</div>
</div>
</div>
@if (showKlipyGifPicker() && klipy.isEnabled()) {
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
}

View File

@@ -0,0 +1,29 @@
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
background: var(--textarea-bg);
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
&.ctrl-resize {
resize: vertical;
}
}
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}

View File

@@ -0,0 +1,493 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideReply,
lucideSend,
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service';
import { Message } from '../../../../../core/models';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { KlipyGifPickerComponent } from '../../../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
@Component({
selector: 'app-chat-message-composer',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
KlipyGifPickerComponent,
TypingIndicatorComponent
],
viewProviders: [
provideIcons({
lucideImage,
lucideReply,
lucideSend,
lucideX
})
],
templateUrl: './chat-message-composer.component.html',
styleUrl: './chat-message-composer.component.scss',
host: {
'(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)'
}
})
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
readonly replyTo = input<Message | null>(null);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
readonly replyCleared = output();
readonly heightChanged = output<number>();
readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
readonly inputHovered = signal(false);
readonly ctrlHeld = signal(false);
messageContent = '';
pendingFiles: File[] = [];
inlineCodeToken = '`';
private toolbarHovering = false;
private dragDepth = 0;
private lastTypingSentAt = 0;
private resizeObserver: ResizeObserver | null = null;
ngAfterViewInit(): void {
this.autoResizeTextarea();
this.observeHeight();
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
return;
const content = this.buildOutgoingMessageContent(raw);
this.messageSubmitted.emit({
content,
pendingFiles: [...this.pendingFiles]
});
this.messageContent = '';
this.pendingFiles = [];
this.pendingKlipyGif.set(null);
this.replyCleared.emit();
requestAnimationFrame(() => {
this.autoResizeTextarea();
this.messageInputRef?.nativeElement.focus();
});
}
onInputChange(): void {
const now = Date.now();
if (now - this.lastTypingSentAt > 1000) {
this.typingStarted.emit();
this.lastTypingSentAt = now;
}
}
clearReply(): void {
this.replyCleared.emit();
}
onEnter(event: Event): void {
const keyEvent = event as KeyboardEvent;
if (keyEvent.shiftKey)
return;
keyEvent.preventDefault();
this.sendMessage();
}
applyInline(token: string): void {
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyPrefix(prefix: string): void {
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyHeading(level: number): void {
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyOrderedList(): void {
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyCodeBlock(): void {
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyLink(): void {
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyImage(): void {
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyHorizontalRule(): void {
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
this.messageContent = result.text;
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) : '';
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
removePendingFile(file: File): void {
const index = this.pendingFiles.findIndex((pendingFile) => pendingFile === file);
if (index >= 0) {
this.pendingFiles.splice(index, 1);
this.emitHeight();
}
}
onDragEnter(event: DragEvent): void {
if (!this.hasPotentialFilePayload(event))
return;
event.preventDefault();
event.stopPropagation();
this.dragDepth++;
this.dragActive.set(true);
}
onDragOver(event: DragEvent): void {
if (!this.hasPotentialFilePayload(event))
return;
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
this.dragActive.set(true);
}
onDragLeave(event: DragEvent): void {
if (!this.dragActive())
return;
event.preventDefault();
event.stopPropagation();
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0) {
this.dragActive.set(false);
}
}
onDrop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragDepth = 0;
const droppedFiles = this.extractDroppedFiles(event);
if (droppedFiles.length === 0) {
this.dragActive.set(false);
return;
}
this.pendingFiles.push(...droppedFiles);
this.toolbarVisible.set(true);
this.dragActive.set(false);
this.emitHeight();
}
autoResizeTextarea(): void {
const element = this.messageInputRef?.nativeElement;
if (!element)
return;
element.style.height = 'auto';
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
}
onInputFocus(): void {
this.toolbarVisible.set(true);
}
onInputBlur(): void {
setTimeout(() => {
if (!this.toolbarHovering) {
this.toolbarVisible.set(false);
}
}, 150);
}
onToolbarMouseEnter(): void {
this.toolbarHovering = true;
}
onToolbarMouseLeave(): void {
this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false);
}
}
onDocKeydown(event: KeyboardEvent): void {
if (event.key === 'Control') {
this.ctrlHeld.set(true);
}
}
onDocKeyup(event: KeyboardEvent): void {
if (event.key === 'Control') {
this.ctrlHeld.set(false);
}
}
private getSelection(): { start: number; end: number } {
const element = this.messageInputRef?.nativeElement;
return {
start: element?.selectionStart ?? this.messageContent.length,
end: element?.selectionEnd ?? this.messageContent.length
};
}
private setSelection(start: number, end: number): void {
const element = this.messageInputRef?.nativeElement;
if (element) {
element.selectionStart = start;
element.selectionEnd = end;
element.focus();
}
}
private hasPotentialFilePayload(event: DragEvent): boolean {
const dataTransfer = event.dataTransfer;
if (!dataTransfer)
return false;
if (dataTransfer.files?.length)
return true;
const items = dataTransfer.items;
if (items?.length) {
for (const item of items) {
if (item.kind === 'file') {
return true;
}
}
}
const types = dataTransfer.types;
if (!types || types.length === 0)
return true;
for (const type of types) {
if (
type === 'Files' ||
type === 'application/x-moz-file' ||
type === 'public.file-url' ||
type === 'text/uri-list'
) {
return true;
}
}
return false;
}
private extractDroppedFiles(event: DragEvent): File[] {
const droppedFiles: File[] = [];
const items = event.dataTransfer?.items ?? null;
if (items && items.length) {
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
droppedFiles.push(file);
}
}
}
}
const files = event.dataTransfer?.files;
if (!files?.length)
return droppedFiles;
for (const file of files) {
const exists = droppedFiles.some(
(existingFile) =>
existingFile.name === file.name &&
existingFile.size === file.size &&
existingFile.type === file.type &&
existingFile.lastModified === file.lastModified
);
if (!exists) {
droppedFiles.push(file);
}
}
return droppedFiles;
}
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)})`;
}
private observeHeight(): void {
const root = this.composerRoot?.nativeElement;
if (!root)
return;
this.emitHeight();
if (typeof ResizeObserver === 'undefined')
return;
this.resizeObserver = new ResizeObserver(() => this.emitHeight());
this.resizeObserver.observe(root);
}
private emitHeight(): void {
const root = this.composerRoot?.nativeElement;
if (root) {
this.heightChanged.emit(root.offsetHeight);
}
}
}

View File

@@ -0,0 +1,416 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
@let msg = message();
@let attachmentsList = attachmentViewModels();
<div
[attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted"
>
<app-user-avatar
[name]="msg.senderName"
size="md"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
@if (msg.replyToId) {
@let reply = repliedMessage();
<div
class="mb-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
(click)="requestReferenceScroll(msg.replyToId)"
>
<div class="h-3 w-4 rounded-tl-md border-l-2 border-t-2 border-muted-foreground/50"></div>
<ng-icon
name="lucideReply"
class="h-3 w-3"
/>
@if (reply) {
<span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt) {
<span class="text-xs text-muted-foreground">(edited)</span>
}
</div>
@if (isEditing()) {
<div class="mt-1 flex gap-2">
<input
type="text"
[(ngModel)]="editContent"
(keydown.enter)="saveEdit()"
(keydown.escape)="cancelEdit()"
class="flex-1 rounded border border-border bg-secondary px-3 py-1 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
(click)="saveEdit()"
class="rounded p-1 text-primary hover:bg-primary/10"
>
<ng-icon
name="lucideCheck"
class="h-4 w-4"
/>
</button>
<button
(click)="cancelEdit()"
class="rounded p-1 text-muted-foreground hover:bg-secondary"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark
[markdown]="msg.content"
[processor]="$any(remarkProcessor)"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
<remark-mermaid [code]="getMermaidCode(node.value)" />
} @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ 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 max-w-full w-auto"
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 (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
>
<ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-0.5 text-xs"
[class.italic]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div>
</div>
<button
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
</div>
}
} @else if (att.isVideo || att.isAudio) {
@if (att.available && att.objectUrl) {
@if (att.isVideo) {
<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="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
} @else {
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium 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.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.mediaStatusText }}
</div>
</div>
<button
(click)="requestAttachment(att)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ att.mediaActionLabel }}
</button>
</div>
</div>
}
} @else {
<div class="rounded-md border border-border bg-secondary/40 p-2">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!att.isUploader) {
@if (!att.available) {
<div class="h-1.5 w-24 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
}
} @else {
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</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>
}
}
</div>
}
}
@if (msg.reactions.length > 0) {
<div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) {
<button
(click)="toggleReaction(reaction.emoji)"
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
[class.ring-1]="reaction.hasCurrentUser"
[class.ring-primary]="reaction.hasCurrentUser"
>
<span>{{ reaction.emoji }}</span>
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
</div>
}
</div>
@if (!msg.isDeleted) {
<div
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="relative">
<button
(click)="toggleEmojiPicker()"
class="rounded-l-lg p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideSmile"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (showEmojiPicker()) {
<div class="absolute bottom-full right-0 z-10 mb-2 flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(emoji)"
class="rounded p-1 text-lg transition-colors hover:bg-secondary"
>
{{ emoji }}
</button>
}
</div>
}
</div>
<button
(click)="requestReply()"
class="p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (isOwnMessage()) {
<button
(click)="startEdit()"
class="p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideEdit"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
@if (isOwnMessage() || isAdmin()) {
<button
(click)="requestDelete()"
class="rounded-r-lg p-1.5 transition-colors hover:bg-destructive/10"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4 text-destructive"
/>
</button>
}
</div>
}
</div>

View File

@@ -0,0 +1,181 @@
.chat-markdown {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--foreground));
::ng-deep {
remark {
display: contents;
}
p {
margin: 0.25em 0;
}
strong {
font-weight: 700;
color: hsl(var(--foreground));
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
opacity: 0.7;
}
a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
h1, h2, h3, h4, h5, h6 {
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
font-weight: 700;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
ul, ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.125em 0;
}
blockquote {
margin: 0.5em 0;
border-left: 3px solid hsl(var(--primary) / 0.5);
border-radius: 0 var(--radius) var(--radius) 0;
background: hsl(var(--secondary) / 0.3);
padding: 0.25em 0.75em;
color: hsl(var(--muted-foreground));
}
code:not([class*='language-']) {
white-space: pre-wrap;
word-break: break-word;
border-radius: 4px;
background: hsl(var(--secondary));
padding: 0.15em 0.35em;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
}
pre {
margin: 0.5em 0;
max-width: 100%;
overflow-x: auto;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: hsl(var(--secondary));
padding: 0.75em 1em;
code:not([class*='language-']) {
border-radius: 0;
background: transparent;
padding: 0;
white-space: pre;
word-break: normal;
}
}
pre[class*='language-'],
code[class*='language-'] {
text-shadow: none;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
}
pre[class*='language-'] {
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 0.875em 1rem;
}
pre[class*='language-'] > code[class*='language-'] {
display: block;
border-radius: 0;
background: transparent;
padding: 0;
white-space: pre;
word-break: normal;
}
hr {
margin: 0.75em 0;
border: none;
border-top: 1px solid hsl(var(--border));
}
table {
display: block;
margin: 0.5em 0;
width: auto;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.875em;
}
th, td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;
}
th {
background: hsl(var(--secondary));
font-weight: 600;
}
img {
display: block;
max-height: 320px;
max-width: 100%;
height: auto;
border-radius: var(--radius);
}
p + p {
margin-top: 0.25em;
}
remark-mermaid {
display: block;
max-width: 100%;
overflow-x: auto;
svg {
pointer-events: none;
max-width: 100%;
height: auto;
}
}
}
}

View File

@@ -0,0 +1,478 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
Component,
computed,
effect,
inject,
input,
output,
signal
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCheck,
lucideDownload,
lucideEdit,
lucideExpand,
lucideImage,
lucideReply,
lucideSmile,
lucideTrash2,
lucideX
} from '@ng-icons/lucide';
import { MermaidComponent, RemarkModule } from 'ngx-remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import {
Attachment,
AttachmentService,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../core/services/attachment.service';
import { KlipyService } from '../../../../../core/services/klipy.service';
import { Message } from '../../../../../core/models';
import {
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
UserAvatarComponent
} from '../../../../../shared';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.models';
const COMMON_EMOJIS = [
'👍',
'❤️',
'😂',
'😮',
'😢',
'🎉',
'🔥',
'👀'
];
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
html: 'markup',
js: 'javascript',
md: 'markdown',
plain: 'none',
plaintext: 'none',
py: 'python',
sh: 'bash',
shell: 'bash',
svg: 'markup',
text: 'none',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
zsh: 'bash'
};
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks) as any;
interface ChatMessageAttachmentViewModel extends Attachment {
isAudio: boolean;
isUploader: boolean;
isVideo: boolean;
mediaActionLabel: string;
mediaStatusText: string;
progressPercent: number;
}
@Component({
selector: 'app-chat-message-item',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
RemarkModule,
MermaidComponent,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideCheck,
lucideDownload,
lucideEdit,
lucideExpand,
lucideImage,
lucideReply,
lucideSmile,
lucideTrash2,
lucideX
})
],
templateUrl: './chat-message-item.component.html',
styleUrl: './chat-message-item.component.scss',
host: {
style: 'display: contents;'
}
})
export class ChatMessageItemComponent {
private readonly attachmentsSvc = inject(AttachmentService);
private readonly klipy = inject(KlipyService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly remarkProcessor: any = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly referenceRequested = output<string>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly commonEmojis = COMMON_EMOJIS;
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
editContent = '';
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
this.buildAttachmentViewModel(attachment)
);
});
private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated();
queueMicrotask(() => {
if (this.attachmentVersion() !== version) {
this.attachmentVersion.set(version);
}
});
});
startEdit(): void {
this.editContent = this.message().content;
this.isEditing.set(true);
}
saveEdit(): void {
if (!this.editContent.trim())
return;
this.editSaved.emit({
messageId: this.message().id,
content: this.editContent.trim()
});
this.cancelEdit();
}
cancelEdit(): void {
this.isEditing.set(false);
this.editContent = '';
}
toggleEmojiPicker(): void {
this.showEmojiPicker.update((current) => !current);
}
addReaction(emoji: string): void {
this.reactionAdded.emit({
messageId: this.message().id,
emoji
});
this.showEmojiPicker.set(false);
}
toggleReaction(emoji: string): void {
this.reactionToggled.emit({
messageId: this.message().id,
emoji
});
}
requestReply(): void {
this.replyRequested.emit(this.message());
}
requestDelete(): void {
this.deleteRequested.emit(this.message());
}
requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId);
}
isOwnMessage(): boolean {
return this.message().senderId === this.currentUserId();
}
getGroupedReactions(): { emoji: string; count: number; hasCurrentUser: boolean }[] {
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
const currentUserId = this.currentUserId();
this.message().reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || {
count: 0,
hasCurrentUser: false
};
groups.set(reaction.emoji, {
count: existing.count + 1,
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
});
});
return Array.from(groups.entries()).map(([emoji, data]) => ({
emoji,
...data
}));
}
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const time = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
const toDay = (value: Date) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0)
return time;
if (dayDiff === 1)
return 'Yesterday ' + time;
if (dayDiff < 7) {
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
}
return (
date.toLocaleDateString([], {
month: 'short',
day: 'numeric'
}) +
' ' +
time
);
}
getMarkdownImageSource(url?: string): string {
return url ? this.klipy.buildRenderableImageUrl(url) : '';
}
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
}
isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid';
}
getCodeBlockClass(lang?: string): string {
return `language-${this.normalizeCodeLanguage(lang)}`;
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
formatSpeed(bytesPerSecond?: number): string {
if (!bytesPerSecond || bytesPerSecond <= 0)
return '0 B/s';
const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
];
let speed = bytesPerSecond;
let unitIndex = 0;
while (speed >= 1024 && unitIndex < units.length - 1) {
speed /= 1024;
unitIndex++;
}
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[unitIndex]}`;
}
isVideoAttachment(attachment: Attachment): boolean {
return attachment.mime.startsWith('video/');
}
isAudioAttachment(attachment: Attachment): boolean {
return attachment.mime.startsWith('audio/');
}
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
return (
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
);
}
getMediaAttachmentStatusText(attachment: Attachment): string {
if (attachment.requestError)
return attachment.requestError;
if (this.requiresMediaDownloadAcceptance(attachment)) {
return this.isVideoAttachment(attachment)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(attachment)
? 'Waiting for video source…'
: 'Waiting for audio source…';
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
if (this.requiresMediaDownloadAcceptance(attachment)) {
return attachment.requestError ? 'Retry download' : 'Accept download';
}
return attachment.requestError ? 'Retry' : 'Request';
}
isUploader(attachment: Attachment): boolean {
const currentUserId = this.currentUserId();
return !!attachment.uploaderPeerId && !!currentUserId && attachment.uploaderPeerId === currentUserId;
}
requestAttachment(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.requestFile(this.message().id, liveAttachment);
}
}
cancelAttachment(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.cancelRequest(this.message().id, liveAttachment);
}
}
retryImageRequest(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.requestImageFromAnyPeer(this.message().id, liveAttachment);
}
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.imageOpened.emit(attachment);
}
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenuRequested.emit({
positionX: event.clientX,
positionY: event.clientY,
attachment
});
}
downloadAttachment(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
private normalizeCodeLanguage(lang?: string): string {
const normalized = (lang || '').trim().toLowerCase();
if (!normalized)
return 'none';
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
}
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment);
const requiresMediaDownloadAcceptance =
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
return {
...attachment,
isAudio,
isUploader: this.isUploader(attachment),
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError ? 'Retry download' : 'Accept download'
: attachment.requestError ? 'Retry' : 'Request',
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
? isVideo
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
: isVideo
? 'Waiting for video source…'
: 'Waiting for audio source…',
progressPercent: attachment.size > 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0
};
}
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc
.getForMessage(this.message().id)
.find((attachment) => attachment.id === attachmentId);
}
}

View File

@@ -0,0 +1,73 @@
<div
#messagesContainer
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()"
>
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@if (loading()) {
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (messages().length === 0) {
<div class="flex h-full flex-col items-center justify-center text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
} @else {
<button
type="button"
(click)="loadMore()"
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<app-chat-message-item
[message]="message"
[repliedMessage]="findRepliedMessage(message.replyToId)"
[currentUserId]="currentUserId()"
[isAdmin]="isAdmin()"
(replyRequested)="handleReplyRequested($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(referenceRequested)="handleReferenceRequested($event)"
(downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
/>
}
}
@if (showNewMessagesBar()) {
<div class="pointer-events-none sticky bottom-4 flex justify-center">
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
<span class="text-sm text-muted-foreground">New messages</span>
<button
type="button"
(click)="readLatest()"
class="rounded bg-primary px-2 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Read latest
</button>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,412 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
AfterViewChecked,
Component,
ElementRef,
OnDestroy,
ViewChild,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { Attachment } from '../../../../../core/services/attachment.service';
import { Message } from '../../../../../core/models';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.models';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal {
highlightElement(element: Element): void;
}
declare global {
interface Window {
Prism?: PrismGlobal;
}
}
@Component({
selector: 'app-chat-message-list',
standalone: true,
imports: [CommonModule, ChatMessageItemComponent],
templateUrl: './chat-message-list.component.html',
host: {
style: 'display: contents;'
}
})
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
readonly allMessages = input.required<Message[]>();
readonly channelMessages = input.required<Message[]>();
readonly loading = input(false);
readonly syncing = input(false);
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly bottomPadding = input(120);
readonly conversationKey = input.required<string>();
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
private readonly PAGE_SIZE = 50;
readonly displayLimit = signal(this.PAGE_SIZE);
readonly loadingMore = signal(false);
readonly showNewMessagesBar = signal(false);
readonly messages = computed(() => {
const all = this.channelMessages();
const limit = this.displayLimit();
if (all.length <= limit)
return all;
return all.slice(all.length - limit);
});
readonly hasMoreMessages = computed(
() => this.channelMessages().length > this.displayLimit()
);
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null;
private isAutoScrolling = false;
private lastMessageCount = 0;
private initialScrollPending = true;
private prismHighlightScheduled = false;
private readonly onConversationChanged = effect(() => {
void this.conversationKey();
this.resetScrollingState();
});
private readonly onMessagesChanged = effect(() => {
const currentCount = this.channelMessages().length;
const element = this.messagesContainer?.nativeElement;
if (!element) {
this.lastMessageCount = currentCount;
return;
}
if (this.initialScrollPending) {
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
if (distanceFromBottom <= 300) {
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else {
queueMicrotask(() => this.showNewMessagesBar.set(true));
}
}
this.lastMessageCount = currentCount;
});
ngAfterViewChecked(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
if (this.initialScrollPending) {
if (this.messages().length > 0) {
this.initialScrollPending = false;
this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight();
} else if (!this.loading()) {
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
return;
}
this.scheduleCodeHighlight();
}
ngOnDestroy(): void {
this.stopInitialScrollWatch();
}
findRepliedMessage(messageId?: string | null): Message | undefined {
if (!messageId)
return undefined;
return this.allMessages().find((message) => message.id === messageId);
}
onScroll(): void {
const element = this.messagesContainer?.nativeElement;
if (!element || this.isAutoScrolling)
return;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) {
this.showNewMessagesBar.set(false);
}
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages())
return;
this.loadingMore.set(true);
const element = this.messagesContainer?.nativeElement;
const previousScrollHeight = element?.scrollHeight ?? 0;
this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (element) {
const newScrollHeight = element.scrollHeight;
element.scrollTop += newScrollHeight - previousScrollHeight;
}
this.loadingMore.set(false);
});
});
}
readLatest(): void {
this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false);
}
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container)
return;
const element = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
element.classList.add('bg-primary/10');
setTimeout(() => element.classList.remove('bg-primary/10'), 2000);
}
}
handleReplyRequested(message: ChatMessageReplyEvent): void {
this.replyRequested.emit(message);
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
this.deleteRequested.emit(message);
}
handleEditSaved(event: ChatMessageEditEvent): void {
this.editSaved.emit(event);
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
this.reactionAdded.emit(event);
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
this.reactionToggled.emit(event);
}
handleReferenceRequested(messageId: string): void {
this.scrollToMessage(messageId);
}
handleDownloadRequested(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
handleImageOpened(attachment: Attachment): void {
this.imageOpened.emit(attachment);
}
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
this.imageContextMenuRequested.emit(event);
}
private resetScrollingState(): void {
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
}
private startInitialScrollWatch(): void {
this.stopInitialScrollWatch();
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
const snapToBottom = () => {
const container = this.messagesContainer?.nativeElement;
if (!container)
return;
this.isAutoScrolling = true;
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
};
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snapToBottom);
});
this.initialScrollObserver.observe(element, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom);
element.addEventListener('load', this.boundOnImageLoad, true);
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
}
private stopInitialScrollWatch(): void {
if (this.initialScrollObserver) {
this.initialScrollObserver.disconnect();
this.initialScrollObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener(
'load',
this.boundOnImageLoad,
true
);
this.boundOnImageLoad = null;
}
if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null;
}
}
private scrollToBottomSmooth(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
try {
element.scrollTo({
top: element.scrollHeight,
behavior: 'smooth'
});
} catch {
element.scrollTop = element.scrollHeight;
}
}
private scheduleScrollToBottomSmooth(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth());
});
}
private scheduleCodeHighlight(): void {
if (this.prismHighlightScheduled)
return;
this.prismHighlightScheduled = true;
requestAnimationFrame(() => {
this.prismHighlightScheduled = false;
this.highlightRenderedCodeBlocks();
});
}
private highlightRenderedCodeBlocks(): void {
const container = this.messagesContainer?.nativeElement;
const prism = window.Prism;
if (!container || !prism?.highlightElement)
return;
const blocks = container.querySelectorAll<HTMLElement>('pre > code[class*="language-"]');
for (const block of blocks) {
const signature = this.getCodeBlockSignature(block);
if (block.dataset['prismSignature'] === signature)
continue;
try {
prism.highlightElement(block);
} finally {
block.dataset['prismSignature'] = signature;
}
}
}
private getCodeBlockSignature(block: HTMLElement): string {
const value = `${block.className}:${block.textContent ?? ''}`;
let hash = 0;
for (let index = 0; index < value.length; index++) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
}
return String(hash);
}
}

View File

@@ -0,0 +1,79 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
>
<div
class="relative max-h-[90vh] max-w-[90vw]"
(click)="$event.stopPropagation()"
>
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<div class="absolute right-3 top-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-5 w-5"
/>
</button>
<button
(click)="closeLightbox()"
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Close"
>
<ng-icon
name="lucideX"
class="h-5 w-5"
/>
</button>
</div>
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="rounded-lg bg-black/60 px-3 py-1.5 backdrop-blur-sm">
<span class="text-sm text-white">{{ lightboxAttachment()!.filename }}</span>
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
@if (imageContextMenu()) {
<app-context-menu
[x]="imageContextMenu()!.positionX"
[y]="imageContextMenu()!.positionY"
(closed)="closeImageContextMenu()"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideCopy"
class="h-4 w-4 text-muted-foreground"
/>
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4 text-muted-foreground"
/>
Save Image
</button>
</app-context-menu>
}

View File

@@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common';
import {
Component,
input,
output
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCopy,
lucideDownload,
lucideX
} from '@ng-icons/lucide';
import { Attachment } from '../../../../../core/services/attachment.service';
import { ContextMenuComponent } from '../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';
@Component({
selector: 'app-chat-message-overlays',
standalone: true,
imports: [
CommonModule,
NgIcon,
ContextMenuComponent
],
viewProviders: [
provideIcons({
lucideCopy,
lucideDownload,
lucideX
})
],
templateUrl: './chat-message-overlays.component.html',
host: {
style: 'display: contents;'
}
})
export class ChatMessageOverlaysComponent {
readonly lightboxAttachment = input<Attachment | null>(null);
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
readonly lightboxClosed = output();
readonly contextMenuClosed = output();
readonly downloadRequested = output<Attachment>();
readonly copyRequested = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
closeLightbox(): void {
this.lightboxClosed.emit();
}
closeImageContextMenu(): void {
this.contextMenuClosed.emit();
}
downloadAttachment(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
copyImageToClipboard(attachment: Attachment): void {
this.copyRequested.emit(attachment);
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenuRequested.emit({
positionX: event.clientX,
positionY: event.clientY,
attachment
});
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
}

View File

@@ -0,0 +1,31 @@
import { Attachment } from '../../../../core/services/attachment.service';
import { Message } from '../../../../core/models';
export interface ChatMessageComposerSubmitEvent {
content: string;
pendingFiles: File[];
}
export interface ChatMessageEditEvent {
messageId: string;
content: string;
}
export interface ChatMessageReactionEvent {
messageId: string;
emoji: string;
}
export interface ChatMessageAttachmentEvent {
messageId: string;
attachment: Attachment;
}
export interface ChatMessageImageContextMenuEvent {
positionX: number;
positionY: number;
attachment: Attachment;
}
export type ChatMessageReplyEvent = Message;
export type ChatMessageDeleteEvent = Message;

View File

@@ -120,6 +120,16 @@ export class ChatAudioPlayerComponent implements OnDestroy {
this.isMuted.set(false); this.isMuted.set(false);
this.volumePercent.set(storedVolume); this.volumePercent.set(storedVolume);
this.lastNonZeroVolume.set(storedVolume); this.lastNonZeroVolume.set(storedVolume);
requestAnimationFrame(() => {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
audio.load();
this.applyAudioVolume(storedVolume);
});
}); });
} }
@@ -150,6 +160,7 @@ export class ChatAudioPlayerComponent implements OnDestroy {
this.waveformExpanded.set(nextExpanded); this.waveformExpanded.set(nextExpanded);
if (nextExpanded) { if (nextExpanded) {
this.waveformUnavailable.set(false);
requestAnimationFrame(() => { requestAnimationFrame(() => {
void this.ensureWaveformLoaded(); void this.ensureWaveformLoaded();
}); });
@@ -165,6 +176,12 @@ export class ChatAudioPlayerComponent implements OnDestroy {
this.applyAudioVolume(this.volumePercent()); this.applyAudioVolume(this.volumePercent());
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0); this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
this.currentTimeSeconds.set(audio.currentTime || 0); this.currentTimeSeconds.set(audio.currentTime || 0);
if (this.waveformExpanded() && !this.waveSurfer && !this.waveformLoading()) {
requestAnimationFrame(() => {
void this.ensureWaveformLoaded();
});
}
} }
onTimeUpdate(): void { onTimeUpdate(): void {
@@ -266,7 +283,7 @@ export class ChatAudioPlayerComponent implements OnDestroy {
} }
private async ensureWaveformLoaded(): Promise<void> { private async ensureWaveformLoaded(): Promise<void> {
if (this.waveformLoading() || this.waveSurfer || this.waveformUnavailable()) if (this.waveformLoading() || this.waveSurfer)
return; return;
const source = this.src(); const source = this.src();
@@ -277,8 +294,18 @@ export class ChatAudioPlayerComponent implements OnDestroy {
return; return;
this.waveformLoading.set(true); this.waveformLoading.set(true);
this.waveformUnavailable.set(false);
try { try {
await this.ensureAudioMetadata(audio);
await this.waitForNextPaint();
if (!this.waveformExpanded()) {
this.waveformLoading.set(false);
return;
}
const { default: WaveSurfer } = await import('wavesurfer.js'); const { default: WaveSurfer } = await import('wavesurfer.js');
this.waveSurfer = WaveSurfer.create({ this.waveSurfer = WaveSurfer.create({
@@ -309,14 +336,41 @@ export class ChatAudioPlayerComponent implements OnDestroy {
}); });
} catch { } catch {
this.destroyWaveSurfer(); this.destroyWaveSurfer();
this.waveformLoading.set(false);
this.waveformUnavailable.set(true); this.waveformUnavailable.set(true);
} finally {
if (this.waveformUnavailable()) {
this.waveformLoading.set(false);
}
} }
} }
private ensureAudioMetadata(audio: HTMLAudioElement): Promise<void> {
if (audio.readyState >= HTMLMediaElement.HAVE_METADATA)
return Promise.resolve();
return new Promise((resolve, reject) => {
const handleLoadedMetadata = (): void => {
cleanup();
resolve();
};
const handleCanPlay = (): void => {
cleanup();
resolve();
};
const handleError = (): void => {
cleanup();
reject(new Error('Failed to load audio metadata'));
};
const cleanup = (): void => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('canplay', handleCanPlay);
audio.removeEventListener('error', handleError);
};
audio.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true });
audio.addEventListener('canplay', handleCanPlay, { once: true });
audio.addEventListener('error', handleError, { once: true });
audio.load();
});
}
private destroyWaveSurfer(): void { private destroyWaveSurfer(): void {
if (!this.waveSurfer) if (!this.waveSurfer)
return; return;
@@ -363,6 +417,14 @@ export class ChatAudioPlayerComponent implements OnDestroy {
].join(''); ].join('');
} }
private waitForNextPaint(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
formatTime(seconds: number): string { formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) if (!Number.isFinite(seconds) || seconds < 0)
return '0:00'; return '0:00';