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">
|
<div class="chat-layout relative h-full">
|
||||||
<!-- Messages List -->
|
<app-chat-message-list
|
||||||
<div
|
[allMessages]="allMessages()"
|
||||||
#messagesContainer
|
[channelMessages]="channelMessages()"
|
||||||
class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4"
|
[loading]="loading()"
|
||||||
(scroll)="onScroll()"
|
[syncing]="syncing()"
|
||||||
>
|
[currentUserId]="currentUser()?.id ?? null"
|
||||||
<!-- Syncing indicator -->
|
[isAdmin]="isAdmin()"
|
||||||
@if (syncing() && !loading()) {
|
[bottomPadding]="composerBottomPadding()"
|
||||||
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
|
[conversationKey]="conversationKey()"
|
||||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
(replyRequested)="setReplyTo($event)"
|
||||||
<span>Syncing messages…</span>
|
(deleteRequested)="handleDeleteRequested($event)"
|
||||||
</div>
|
(editSaved)="handleEditSaved($event)"
|
||||||
}
|
(reactionAdded)="handleReactionAdded($event)"
|
||||||
@if (loading()) {
|
(reactionToggled)="handleReactionToggled($event)"
|
||||||
<div class="flex items-center justify-center py-8">
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
(imageOpened)="openLightbox($event)"
|
||||||
</div>
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
} @else if (messages().length === 0) {
|
/>
|
||||||
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
||||||
<p class="text-lg">No messages yet</p>
|
|
||||||
<p class="text-sm">Be the first to say something!</p>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- Infinite scroll: load-more sentinel at top -->
|
|
||||||
@if (hasMoreMessages()) {
|
|
||||||
<div class="flex items-center justify-center py-3">
|
|
||||||
@if (loadingMore()) {
|
|
||||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
(click)="loadMore()"
|
|
||||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Load older messages
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@for (message of messages(); track message.id) {
|
|
||||||
<div
|
|
||||||
[attr.data-message-id]="message.id"
|
|
||||||
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
|
|
||||||
[class.opacity-50]="message.isDeleted"
|
|
||||||
>
|
|
||||||
<!-- Avatar -->
|
|
||||||
<app-user-avatar
|
|
||||||
[name]="message.senderName"
|
|
||||||
size="md"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Message Content -->
|
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||||
<div class="flex-1 min-w-0">
|
<app-chat-message-composer
|
||||||
<!-- Reply indicator -->
|
[replyTo]="replyTo()"
|
||||||
@if (message.replyToId) {
|
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||||
@let repliedMsg = getRepliedMessage(message.replyToId);
|
(typingStarted)="handleTypingStarted()"
|
||||||
<div
|
(replyCleared)="clearReply()"
|
||||||
class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
(heightChanged)="handleComposerHeightChanged($event)"
|
||||||
(click)="scrollToMessage(message.replyToId)"
|
|
||||||
>
|
|
||||||
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideReply"
|
|
||||||
class="w-3 h-3"
|
|
||||||
/>
|
|
||||||
@if (repliedMsg) {
|
|
||||||
<span class="font-medium">{{ repliedMsg.senderName }}</span>
|
|
||||||
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="italic">Original message not found</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="flex items-baseline gap-2">
|
|
||||||
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{{ formatTimestamp(message.timestamp) }}
|
|
||||||
</span>
|
|
||||||
@if (message.editedAt) {
|
|
||||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (editingMessageId() === message.id) {
|
|
||||||
<!-- Edit Mode -->
|
|
||||||
<div class="mt-1 flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="editContent"
|
|
||||||
(keydown.enter)="saveEdit(message.id)"
|
|
||||||
(keydown.escape)="cancelEdit()"
|
|
||||||
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
(click)="saveEdit(message.id)"
|
|
||||||
class="p-1 text-primary hover:bg-primary/10 rounded"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideCheck"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="cancelEdit()"
|
|
||||||
class="p-1 text-muted-foreground hover:bg-secondary rounded"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideX"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="chat-markdown mt-1 break-words">
|
|
||||||
<remark
|
|
||||||
[markdown]="message.content"
|
|
||||||
[processor]="remarkProcessor"
|
|
||||||
>
|
|
||||||
<ng-template
|
|
||||||
[remarkTemplate]="'code'"
|
|
||||||
let-node
|
|
||||||
>
|
|
||||||
@if (isMermaidCodeBlock(node.lang)) {
|
|
||||||
<remark-mermaid [code]="node.value" />
|
|
||||||
} @else {
|
|
||||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
<ng-template
|
|
||||||
[remarkTemplate]="'image'"
|
|
||||||
let-node
|
|
||||||
>
|
|
||||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
|
||||||
<img
|
|
||||||
[src]="getMarkdownImageSource(node.url)"
|
|
||||||
[alt]="node.alt || 'Shared image'"
|
|
||||||
class="block max-h-80 w-auto max-w-full"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
@if (isKlipyMediaUrl(node.url)) {
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
KLIPY
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</remark>
|
|
||||||
</div>
|
|
||||||
@if (getAttachments(message.id).length > 0) {
|
|
||||||
<div class="mt-2 space-y-2">
|
|
||||||
@for (att of getAttachments(message.id); track att.id) {
|
|
||||||
@if (att.isImage) {
|
|
||||||
@if (att.available && att.objectUrl) {
|
|
||||||
<!-- Available image with hover overlay -->
|
|
||||||
<div
|
|
||||||
class="relative group/img inline-block"
|
|
||||||
(contextmenu)="openImageContextMenu($event, att)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
[src]="att.objectUrl"
|
|
||||||
[alt]="att.filename"
|
|
||||||
class="rounded-md max-h-80 w-auto cursor-pointer"
|
|
||||||
(click)="openLightbox(att)"
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
|
|
||||||
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
|
|
||||||
<button
|
|
||||||
(click)="openLightbox(att); $event.stopPropagation()"
|
|
||||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
|
|
||||||
title="View full size"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideExpand"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="downloadAttachment(att); $event.stopPropagation()"
|
|
||||||
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideDownload"
|
|
||||||
class="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
|
||||||
<!-- Downloading in progress -->
|
|
||||||
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideImage"
|
|
||||||
class="w-5 h-5 text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
|
||||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
|
||||||
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- Unavailable - waiting for source -->
|
|
||||||
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideImage"
|
|
||||||
class="w-5 h-5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
|
|
||||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
|
||||||
<div
|
|
||||||
class="text-xs mt-0.5"
|
|
||||||
[class.text-destructive]="!!att.requestError"
|
|
||||||
[class.text-muted-foreground]="!att.requestError"
|
|
||||||
[class.opacity-70]="!att.requestError"
|
|
||||||
[class.italic]="!att.requestError"
|
|
||||||
>
|
|
||||||
{{ att.requestError || 'Waiting for image source…' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
(click)="retryImageRequest(att, message.id)"
|
|
||||||
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else if (isVideoAttachment(att) || isAudioAttachment(att)) {
|
|
||||||
@if (att.available && att.objectUrl) {
|
|
||||||
@if (isVideoAttachment(att)) {
|
|
||||||
<app-chat-video-player
|
|
||||||
[src]="att.objectUrl"
|
|
||||||
[filename]="att.filename"
|
|
||||||
[sizeLabel]="formatBytes(att.size)"
|
|
||||||
(downloadRequested)="downloadAttachment(att)"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<app-chat-audio-player
|
|
||||||
[src]="att.objectUrl"
|
|
||||||
[filename]="att.filename"
|
|
||||||
[sizeLabel]="formatBytes(att.size)"
|
|
||||||
(downloadRequested)="downloadAttachment(att)"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
|
||||||
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xl">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
|
||||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
|
|
||||||
(click)="cancelAttachment(att, message.id)"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
|
||||||
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
|
|
||||||
@if (att.speedBps) {
|
|
||||||
<span>{{ formatSpeed(att.speedBps) }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xl">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
|
|
||||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
|
||||||
<div
|
|
||||||
class="mt-1 text-xs leading-relaxed"
|
|
||||||
[class.text-destructive]="!!att.requestError"
|
|
||||||
[class.text-muted-foreground]="!att.requestError"
|
|
||||||
[class.opacity-80]="!att.requestError"
|
|
||||||
>
|
|
||||||
{{ getMediaAttachmentStatusText(att) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
(click)="requestAttachment(att, message.id)"
|
|
||||||
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
>
|
|
||||||
{{ getMediaAttachmentActionLabel(att) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
|
||||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
@if (!isUploader(att)) {
|
|
||||||
@if (!att.available) {
|
|
||||||
<div class="w-24 h-1.5 rounded bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-1.5 rounded bg-primary"
|
|
||||||
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
|
||||||
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
|
|
||||||
@if (att.speedBps) {
|
|
||||||
<span>• {{ formatSpeed(att.speedBps) }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (!(att.receivedBytes || 0)) {
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
|
|
||||||
(click)="requestAttachment(att, message.id)"
|
|
||||||
>
|
|
||||||
{{ att.requestError ? 'Retry' : 'Request' }}
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
|
|
||||||
(click)="cancelAttachment(att, message.id)"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
|
|
||||||
(click)="downloadAttachment(att)"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<div class="text-xs text-muted-foreground">Shared from your device</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (!att.available && att.requestError) {
|
|
||||||
<div
|
|
||||||
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
|
|
||||||
>
|
|
||||||
{{ att.requestError }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Reactions -->
|
|
||||||
@if (message.reactions.length > 0) {
|
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
|
||||||
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
|
|
||||||
<button
|
|
||||||
(click)="toggleReaction(message.id, reaction.emoji)"
|
|
||||||
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
|
|
||||||
[class.ring-1]="reaction.hasCurrentUser"
|
|
||||||
[class.ring-primary]="reaction.hasCurrentUser"
|
|
||||||
>
|
|
||||||
<span>{{ reaction.emoji }}</span>
|
|
||||||
<span class="text-muted-foreground">{{ reaction.count }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message Actions (visible on hover) -->
|
|
||||||
@if (!message.isDeleted) {
|
|
||||||
<div
|
|
||||||
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
<!-- Emoji Picker Toggle -->
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
(click)="toggleEmojiPicker(message.id)"
|
|
||||||
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideSmile"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (showEmojiPicker() === message.id) {
|
|
||||||
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
|
|
||||||
@for (emoji of commonEmojis; track emoji) {
|
|
||||||
<button
|
|
||||||
(click)="addReaction(message.id, emoji)"
|
|
||||||
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
|
|
||||||
>
|
|
||||||
{{ emoji }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reply -->
|
|
||||||
<button
|
|
||||||
(click)="setReplyTo(message)"
|
|
||||||
class="p-1.5 hover:bg-secondary transition-colors"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideReply"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Edit (own messages only) -->
|
|
||||||
@if (isOwnMessage(message)) {
|
|
||||||
<button
|
|
||||||
(click)="startEdit(message)"
|
|
||||||
class="p-1.5 hover:bg-secondary transition-colors"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideEdit"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Delete (own messages or admin) -->
|
|
||||||
@if (isOwnMessage(message) || isAdmin()) {
|
|
||||||
<button
|
|
||||||
(click)="deleteMessage(message)"
|
|
||||||
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideTrash2"
|
|
||||||
class="w-4 h-4 text-destructive"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<!-- New messages snackbar (center bottom inside container) -->
|
|
||||||
@if (showNewMessagesBar()) {
|
|
||||||
<div class="sticky bottom-4 flex justify-center pointer-events-none">
|
|
||||||
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
|
|
||||||
<span class="text-sm text-muted-foreground">New messages</span>
|
|
||||||
<button
|
|
||||||
(click)="readLatest()"
|
|
||||||
class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm"
|
|
||||||
>
|
|
||||||
Read latest
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom bar: floats over messages -->
|
|
||||||
<div
|
|
||||||
#bottomBar
|
|
||||||
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
|
|
||||||
>
|
|
||||||
<!-- Reply Preview -->
|
|
||||||
@if (replyTo()) {
|
|
||||||
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideReply"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-muted-foreground flex-1">
|
|
||||||
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
(click)="clearReply()"
|
|
||||||
class="p-1 hover:bg-secondary rounded"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideX"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Typing Indicator -->
|
|
||||||
<app-typing-indicator />
|
|
||||||
|
|
||||||
<!-- Markdown Toolbar -->
|
|
||||||
@if (toolbarVisible()) {
|
|
||||||
<div
|
|
||||||
class="pointer-events-auto"
|
|
||||||
(mousedown)="$event.preventDefault()"
|
|
||||||
(mouseenter)="onToolbarMouseEnter()"
|
|
||||||
(mouseleave)="onToolbarMouseLeave()"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyInline('**')"
|
|
||||||
>
|
|
||||||
<b>B</b>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyInline('*')"
|
|
||||||
>
|
|
||||||
<i>I</i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyInline('~~')"
|
|
||||||
>
|
|
||||||
<s>S</s>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyInline(inlineCodeToken)"
|
|
||||||
>
|
|
||||||
`
|
|
||||||
</button>
|
|
||||||
<span class="mx-1 text-muted-foreground">|</span>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyHeading(1)"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyHeading(2)"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyHeading(3)"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyPrefix('> ')"
|
|
||||||
>
|
|
||||||
Quote
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyPrefix('- ')"
|
|
||||||
>
|
|
||||||
• List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyOrderedList()"
|
|
||||||
>
|
|
||||||
1. List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyCodeBlock()"
|
|
||||||
>
|
|
||||||
Code
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyLink()"
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyImage()"
|
|
||||||
>
|
|
||||||
Image
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 text-xs hover:bg-secondary rounded"
|
|
||||||
(click)="applyHorizontalRule()"
|
|
||||||
>
|
|
||||||
HR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Message Input -->
|
|
||||||
<div class="p-4 border-border">
|
|
||||||
<div
|
|
||||||
class="chat-input-wrapper relative"
|
|
||||||
(mouseenter)="inputHovered.set(true)"
|
|
||||||
(mouseleave)="inputHovered.set(false)"
|
|
||||||
(dragenter)="onDragEnter($event)"
|
|
||||||
(dragover)="onDragOver($event)"
|
|
||||||
(dragleave)="onDragLeave($event)"
|
|
||||||
(drop)="onDrop($event)"
|
|
||||||
>
|
|
||||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
|
|
||||||
@if (klipy.isEnabled()) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="openKlipyGifPicker()"
|
|
||||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
|
||||||
[class.border-primary]="showKlipyGifPicker()"
|
|
||||||
[class.text-primary]="showKlipyGifPicker()"
|
|
||||||
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
|
|
||||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
|
||||||
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
|
|
||||||
aria-label="Search KLIPY GIFs"
|
|
||||||
title="Search KLIPY GIFs"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideImage"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<span class="hidden sm:inline">GIF</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="sendMessage()"
|
|
||||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
|
||||||
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
|
|
||||||
aria-label="Send message"
|
|
||||||
title="Send message"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideSend"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
#messageInputRef
|
|
||||||
rows="1"
|
|
||||||
[(ngModel)]="messageContent"
|
|
||||||
(focus)="onInputFocus()"
|
|
||||||
(blur)="onInputBlur()"
|
|
||||||
(keydown.enter)="onEnter($event)"
|
|
||||||
(input)="onInputChange(); autoResizeTextarea()"
|
|
||||||
(dragenter)="onDragEnter($event)"
|
|
||||||
(dragover)="onDragOver($event)"
|
|
||||||
(dragleave)="onDragLeave($event)"
|
|
||||||
(drop)="onDrop($event)"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
class="chat-textarea w-full rounded-[1.35rem] border border-border py-2 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
[class.pr-16]="!klipy.isEnabled()"
|
|
||||||
[class.pr-40]="klipy.isEnabled()"
|
|
||||||
[class.border-primary]="dragActive()"
|
|
||||||
[class.border-dashed]="dragActive()"
|
|
||||||
[class.ctrl-resize]="ctrlHeld()"
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
@if (dragActive()) {
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (pendingKlipyGif()) {
|
|
||||||
<div class="mt-2 flex">
|
|
||||||
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
|
|
||||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
|
||||||
<img
|
|
||||||
[src]="getPendingKlipyGifPreviewUrl()"
|
|
||||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
|
|
||||||
>
|
|
||||||
KLIPY
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
|
|
||||||
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
|
|
||||||
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="removePendingKlipyGif()"
|
|
||||||
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (pendingFiles.length > 0) {
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
|
||||||
@for (file of pendingFiles; track file.name) {
|
|
||||||
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
|
|
||||||
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
|
|
||||||
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
|
|
||||||
<button
|
|
||||||
(click)="removePendingFile(file)"
|
|
||||||
class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showKlipyGifPicker() && klipy.isEnabled()) {
|
|
||||||
<app-klipy-gif-picker
|
|
||||||
(gifSelected)="handleKlipyGifSelected($event)"
|
|
||||||
(closed)="closeKlipyGifPicker()"
|
|
||||||
/>
|
/>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
<!-- Image Lightbox Modal -->
|
<app-chat-message-overlays
|
||||||
@if (lightboxAttachment()) {
|
[lightboxAttachment]="lightboxAttachment()"
|
||||||
<div
|
[imageContextMenu]="imageContextMenu()"
|
||||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
(lightboxClosed)="closeLightbox()"
|
||||||
(click)="closeLightbox()"
|
(contextMenuClosed)="closeImageContextMenu()"
|
||||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
|
(downloadRequested)="downloadAttachment($event)"
|
||||||
(keydown.escape)="closeLightbox()"
|
(copyRequested)="copyImageToClipboard($event)"
|
||||||
tabindex="0"
|
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||||
#lightboxBackdrop
|
/>
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative max-w-[90vw] max-h-[90vh]"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
[src]="lightboxAttachment()!.objectUrl"
|
|
||||||
[alt]="lightboxAttachment()!.filename"
|
|
||||||
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
|
||||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
|
|
||||||
/>
|
|
||||||
<!-- Top-right action bar -->
|
|
||||||
<div class="absolute top-3 right-3 flex gap-2">
|
|
||||||
<button
|
|
||||||
(click)="downloadAttachment(lightboxAttachment()!)"
|
|
||||||
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideDownload"
|
|
||||||
class="w-5 h-5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="closeLightbox()"
|
|
||||||
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideX"
|
|
||||||
class="w-5 h-5"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Bottom info bar -->
|
|
||||||
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
|
|
||||||
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
|
|
||||||
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
|
|
||||||
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Image Context Menu -->
|
|
||||||
@if (imageContextMenu()) {
|
|
||||||
<app-context-menu
|
|
||||||
[x]="imageContextMenu()!.x"
|
|
||||||
[y]="imageContextMenu()!.y"
|
|
||||||
(closed)="closeImageContextMenu()"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
|
|
||||||
class="context-menu-item-icon"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideCopy"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
Copy Image
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
|
|
||||||
class="context-menu-item-icon"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
name="lucideDownload"
|
|
||||||
class="w-4 h-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
Save Image
|
|
||||||
</button>
|
|
||||||
</app-context-menu>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,245 +1,12 @@
|
|||||||
/* ── Chat layout: messages scroll behind input ──── */
|
|
||||||
|
|
||||||
.chat-layout {
|
.chat-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages-scroll {
|
|
||||||
/* Fallback; dynamically overridden by updateScrollPadding() */
|
|
||||||
padding-bottom: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-bottom-bar {
|
.chat-bottom-bar {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
background: hsl(var(--background) / 0.85);
|
|
||||||
/* Inset from right so the scrollbar track stays visible */
|
|
||||||
right: 8px;
|
right: 8px;
|
||||||
}
|
background: hsl(var(--background) / 0.85);
|
||||||
|
|
||||||
/* Gradient fade-in above the bottom bar — blur only, no darkening */
|
|
||||||
.chat-bottom-fade {
|
|
||||||
height: 20px;
|
|
||||||
background: transparent;
|
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Chat textarea redesign ────────────────────── */
|
|
||||||
|
|
||||||
.chat-textarea {
|
|
||||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
|
|
||||||
background: var(--textarea-bg);
|
|
||||||
|
|
||||||
/* Auto-resize: start at 62px, grow upward */
|
|
||||||
height: 62px;
|
|
||||||
min-height: 62px;
|
|
||||||
max-height: 520px;
|
|
||||||
overflow-y: hidden;
|
|
||||||
resize: none;
|
|
||||||
transition: height 0.12s ease;
|
|
||||||
|
|
||||||
/* Show manual resize handle only while Ctrl is held */
|
|
||||||
&.ctrl-resize {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Send button: hidden by default, fades in on wrapper hover */
|
|
||||||
.send-btn {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transform: scale(0.85);
|
|
||||||
transition:
|
|
||||||
opacity 0.2s ease,
|
|
||||||
transform 0.2s ease;
|
|
||||||
|
|
||||||
&.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── ngx-remark markdown styles ──────────────── */
|
|
||||||
|
|
||||||
.chat-markdown {
|
|
||||||
max-width: 100%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
|
|
||||||
::ng-deep {
|
|
||||||
remark {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.25em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: 700;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
em {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
del {
|
|
||||||
text-decoration: line-through;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0.5em 0 0.25em;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
h1 { font-size: 1.5em; }
|
|
||||||
h2 { font-size: 1.3em; }
|
|
||||||
h3 { font-size: 1.15em; }
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
margin: 0.25em 0;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
ul { list-style-type: disc; }
|
|
||||||
ol { list-style-type: decimal; }
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin: 0.125em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 3px solid hsl(var(--primary) / 0.5);
|
|
||||||
margin: 0.5em 0;
|
|
||||||
padding: 0.25em 0.75em;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--secondary) / 0.3);
|
|
||||||
border-radius: 0 var(--radius) var(--radius) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code:not([class*='language-']) {
|
|
||||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
padding: 0.15em 0.35em;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
padding: 0.75em 1em;
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
|
|
||||||
code:not([class*='language-']) {
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
white-space: pre;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pre[class*='language-'],
|
|
||||||
code[class*='language-'] {
|
|
||||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre[class*='language-'] {
|
|
||||||
padding: 0.875em 1rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre[class*='language-'] > code[class*='language-'] {
|
|
||||||
display: block;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
white-space: pre;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
margin: 0.75em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
font-size: 0.875em;
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
padding: 0.35em 0.75em;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-height: 320px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure consecutive paragraphs have minimal spacing for chat feel
|
|
||||||
p + p {
|
|
||||||
margin-top: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mermaid diagrams: prevent SVG from blocking clicks on the rest of the app
|
|
||||||
remark-mermaid {
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.isMuted.set(false);
|
||||||
this.volumePercent.set(storedVolume);
|
this.volumePercent.set(storedVolume);
|
||||||
this.lastNonZeroVolume.set(storedVolume);
|
this.lastNonZeroVolume.set(storedVolume);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const audio = this.audioRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!audio)
|
||||||
|
return;
|
||||||
|
|
||||||
|
audio.load();
|
||||||
|
this.applyAudioVolume(storedVolume);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,7 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
|||||||
this.waveformExpanded.set(nextExpanded);
|
this.waveformExpanded.set(nextExpanded);
|
||||||
|
|
||||||
if (nextExpanded) {
|
if (nextExpanded) {
|
||||||
|
this.waveformUnavailable.set(false);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
void this.ensureWaveformLoaded();
|
void this.ensureWaveformLoaded();
|
||||||
});
|
});
|
||||||
@@ -165,6 +176,12 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
|||||||
this.applyAudioVolume(this.volumePercent());
|
this.applyAudioVolume(this.volumePercent());
|
||||||
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
|
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
|
||||||
this.currentTimeSeconds.set(audio.currentTime || 0);
|
this.currentTimeSeconds.set(audio.currentTime || 0);
|
||||||
|
|
||||||
|
if (this.waveformExpanded() && !this.waveSurfer && !this.waveformLoading()) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
void this.ensureWaveformLoaded();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onTimeUpdate(): void {
|
onTimeUpdate(): void {
|
||||||
@@ -266,7 +283,7 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureWaveformLoaded(): Promise<void> {
|
private async ensureWaveformLoaded(): Promise<void> {
|
||||||
if (this.waveformLoading() || this.waveSurfer || this.waveformUnavailable())
|
if (this.waveformLoading() || this.waveSurfer)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const source = this.src();
|
const source = this.src();
|
||||||
@@ -277,8 +294,18 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
this.waveformLoading.set(true);
|
this.waveformLoading.set(true);
|
||||||
|
this.waveformUnavailable.set(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.ensureAudioMetadata(audio);
|
||||||
|
|
||||||
|
await this.waitForNextPaint();
|
||||||
|
|
||||||
|
if (!this.waveformExpanded()) {
|
||||||
|
this.waveformLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { default: WaveSurfer } = await import('wavesurfer.js');
|
const { default: WaveSurfer } = await import('wavesurfer.js');
|
||||||
|
|
||||||
this.waveSurfer = WaveSurfer.create({
|
this.waveSurfer = WaveSurfer.create({
|
||||||
@@ -309,14 +336,41 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.destroyWaveSurfer();
|
this.destroyWaveSurfer();
|
||||||
|
this.waveformLoading.set(false);
|
||||||
this.waveformUnavailable.set(true);
|
this.waveformUnavailable.set(true);
|
||||||
} finally {
|
|
||||||
if (this.waveformUnavailable()) {
|
|
||||||
this.waveformLoading.set(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureAudioMetadata(audio: HTMLAudioElement): Promise<void> {
|
||||||
|
if (audio.readyState >= HTMLMediaElement.HAVE_METADATA)
|
||||||
|
return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleLoadedMetadata = (): void => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const handleCanPlay = (): void => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const handleError = (): void => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Failed to load audio metadata'));
|
||||||
|
};
|
||||||
|
const cleanup = (): void => {
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
audio.removeEventListener('canplay', handleCanPlay);
|
||||||
|
audio.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true });
|
||||||
|
audio.addEventListener('canplay', handleCanPlay, { once: true });
|
||||||
|
audio.addEventListener('error', handleError, { once: true });
|
||||||
|
audio.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private destroyWaveSurfer(): void {
|
private destroyWaveSurfer(): void {
|
||||||
if (!this.waveSurfer)
|
if (!this.waveSurfer)
|
||||||
return;
|
return;
|
||||||
@@ -363,6 +417,14 @@ export class ChatAudioPlayerComponent implements OnDestroy {
|
|||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private waitForNextPaint(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
formatTime(seconds: number): string {
|
formatTime(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0)
|
if (!Number.isFinite(seconds) || seconds < 0)
|
||||||
return '0:00';
|
return '0:00';
|
||||||
|
|||||||
Reference in New Issue
Block a user