Split chat component into smaller components
This commit is contained in:
@@ -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">
|
||||
<!-- Messages List -->
|
||||
<div
|
||||
#messagesContainer
|
||||
class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4"
|
||||
(scroll)="onScroll()"
|
||||
>
|
||||
<!-- Syncing indicator -->
|
||||
@if (syncing() && !loading()) {
|
||||
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
||||
<span>Syncing messages…</span>
|
||||
</div>
|
||||
}
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @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"
|
||||
<app-chat-message-list
|
||||
[allMessages]="allMessages()"
|
||||
[channelMessages]="channelMessages()"
|
||||
[loading]="loading()"
|
||||
[syncing]="syncing()"
|
||||
[currentUserId]="currentUser()?.id ?? null"
|
||||
[isAdmin]="isAdmin()"
|
||||
[bottomPadding]="composerBottomPadding()"
|
||||
[conversationKey]="conversationKey()"
|
||||
(replyRequested)="setReplyTo($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
(reactionAdded)="handleReactionAdded($event)"
|
||||
(reactionToggled)="handleReactionToggled($event)"
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(imageOpened)="openLightbox($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
/>
|
||||
|
||||
<!-- Message Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Reply indicator -->
|
||||
@if (message.replyToId) {
|
||||
@let repliedMsg = getRepliedMessage(message.replyToId);
|
||||
<div
|
||||
class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
(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"
|
||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
(heightChanged)="handleComposerHeightChanged($event)"
|
||||
/>
|
||||
@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"
|
||||
<app-chat-message-overlays
|
||||
[lightboxAttachment]="lightboxAttachment()"
|
||||
[imageContextMenu]="imageContextMenu()"
|
||||
(lightboxClosed)="closeLightbox()"
|
||||
(contextMenuClosed)="closeImageContextMenu()"
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(copyRequested)="copyImageToClipboard($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
/>
|
||||
</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)"
|
||||
>
|
||||
`
|
||||
</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()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Image Lightbox Modal -->
|
||||
@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"
|
||||
#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>
|
||||
|
||||
@@ -1,245 +1,12 @@
|
||||
/* ── Chat layout: messages scroll behind input ──── */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-messages-scroll {
|
||||
/* Fallback; dynamically overridden by updateScrollPadding() */
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.chat-bottom-bar {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Gradient fade-in above the bottom bar — blur only, no darkening */
|
||||
.chat-bottom-fade {
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
background: hsl(var(--background) / 0.85);
|
||||
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
@@ -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)"
|
||||
>
|
||||
`
|
||||
</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()"
|
||||
/>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 `})`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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]}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -120,6 +120,16 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
this.isMuted.set(false);
|
||||
this.volumePercent.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);
|
||||
|
||||
if (nextExpanded) {
|
||||
this.waveformUnavailable.set(false);
|
||||
requestAnimationFrame(() => {
|
||||
void this.ensureWaveformLoaded();
|
||||
});
|
||||
@@ -165,6 +176,12 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
this.applyAudioVolume(this.volumePercent());
|
||||
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||
this.currentTimeSeconds.set(audio.currentTime || 0);
|
||||
|
||||
if (this.waveformExpanded() && !this.waveSurfer && !this.waveformLoading()) {
|
||||
requestAnimationFrame(() => {
|
||||
void this.ensureWaveformLoaded();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onTimeUpdate(): void {
|
||||
@@ -266,7 +283,7 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private async ensureWaveformLoaded(): Promise<void> {
|
||||
if (this.waveformLoading() || this.waveSurfer || this.waveformUnavailable())
|
||||
if (this.waveformLoading() || this.waveSurfer)
|
||||
return;
|
||||
|
||||
const source = this.src();
|
||||
@@ -277,8 +294,18 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
return;
|
||||
|
||||
this.waveformLoading.set(true);
|
||||
this.waveformUnavailable.set(false);
|
||||
|
||||
try {
|
||||
await this.ensureAudioMetadata(audio);
|
||||
|
||||
await this.waitForNextPaint();
|
||||
|
||||
if (!this.waveformExpanded()) {
|
||||
this.waveformLoading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { default: WaveSurfer } = await import('wavesurfer.js');
|
||||
|
||||
this.waveSurfer = WaveSurfer.create({
|
||||
@@ -309,12 +336,39 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
});
|
||||
} catch {
|
||||
this.destroyWaveSurfer();
|
||||
this.waveformUnavailable.set(true);
|
||||
} finally {
|
||||
if (this.waveformUnavailable()) {
|
||||
this.waveformLoading.set(false);
|
||||
this.waveformUnavailable.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -363,6 +417,14 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
||||
].join('');
|
||||
}
|
||||
|
||||
private waitForNextPaint(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0)
|
||||
return '0:00';
|
||||
|
||||
Reference in New Issue
Block a user