Route small images through in-memory receive instead of serialized disk chunk-acks, and improve gallery hydration for local copies and pending downloads so thumbnails display without minutes-long progress stalls. Co-authored-by: Cursor <cursoragent@cursor.com>
496 lines
15 KiB
TypeScript
496 lines
15 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
Component,
|
|
HostListener,
|
|
ViewChild,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal
|
|
} from '@angular/core';
|
|
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
import { switchMap } from 'rxjs/operators';
|
|
import { Store } from '@ngrx/store';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { ViewportService } from '../../../../core/platform';
|
|
import { BottomSheetComponent } from '../../../../shared';
|
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
import {
|
|
Attachment,
|
|
AttachmentDownloadService,
|
|
AttachmentFacade
|
|
} from '../../../attachment';
|
|
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
|
import {
|
|
selectActiveChannelMessages,
|
|
selectCurrentRoomMessages,
|
|
selectConversationExhausted,
|
|
selectMessagesLoading,
|
|
selectMessagesLoadingOlder,
|
|
selectMessagesSyncing
|
|
} from '../../../../store/messages/messages.selectors';
|
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
|
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
|
import { Message } from '../../../../shared-kernel';
|
|
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|
import { ThemeNodeDirective } from '../../../theme';
|
|
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
|
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
|
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
|
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
|
|
import { stepLightboxIndex } from '../../domain/rules/chat-message-lightbox.rules';
|
|
import { planChatMessageSend } from '../../domain/rules/chat-message-send.rules';
|
|
import {
|
|
ChatLightboxState,
|
|
ChatMessageComposerSubmitEvent,
|
|
ChatMessageDeleteEvent,
|
|
ChatMessageEditEvent,
|
|
ChatMessageEmbedRemoveEvent,
|
|
ChatMessageAttachmentEvent,
|
|
ChatMessageImageContextMenuEvent,
|
|
ChatMessageImageLightboxEvent,
|
|
ChatMessageReactionEvent,
|
|
ChatMessageReplyEvent
|
|
} from './models/chat-messages.model';
|
|
|
|
@Component({
|
|
selector: 'app-chat-messages',
|
|
standalone: true,
|
|
imports: [
|
|
ChatMessageComposerComponent,
|
|
KlipyGifPickerComponent,
|
|
ChatMessageListComponent,
|
|
ChatMessageOverlaysComponent,
|
|
BottomSheetComponent,
|
|
ThemeNodeDirective,
|
|
...APP_TRANSLATE_IMPORTS
|
|
],
|
|
templateUrl: './chat-messages.component.html',
|
|
styleUrl: './chat-messages.component.scss'
|
|
})
|
|
export class ChatMessagesComponent {
|
|
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
|
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
|
|
|
|
private readonly store = inject(Store);
|
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
|
private readonly attachmentDownload = inject(AttachmentDownloadService);
|
|
private readonly klipy = inject(KlipyService);
|
|
private readonly viewport = inject(ViewportService);
|
|
|
|
readonly isMobile = this.viewport.isMobile;
|
|
|
|
readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
|
readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages);
|
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
|
|
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
|
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
|
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
|
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
|
|
|
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
|
readonly conversationExhausted = toSignal(
|
|
toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))),
|
|
{ initialValue: false }
|
|
);
|
|
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
|
readonly composerBottomPadding = signal(140);
|
|
readonly klipyGifPickerAnchorRight = signal(16);
|
|
readonly replyTo = signal<Message | null>(null);
|
|
readonly showKlipyGifPicker = signal(false);
|
|
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
|
readonly galleryMessageId = signal<string | null>(null);
|
|
readonly galleryAttachmentOrder = signal<readonly string[]>([]);
|
|
readonly galleryAttachments = computed(() => {
|
|
const messageId = this.galleryMessageId();
|
|
const attachmentIds = this.galleryAttachmentOrder();
|
|
|
|
void this.attachmentsSvc.updated;
|
|
|
|
if (!messageId || attachmentIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const attachmentsById = new Map(
|
|
this.attachmentsSvc.getForMessage(messageId).map((attachment) => [attachment.id, attachment])
|
|
);
|
|
|
|
return attachmentIds
|
|
.map((attachmentId) => attachmentsById.get(attachmentId))
|
|
.filter((attachment): attachment is Attachment => !!attachment);
|
|
});
|
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
void this.klipy.refreshAvailability(this.currentRoom());
|
|
});
|
|
}
|
|
|
|
@HostListener('window:resize')
|
|
onWindowResize(): void {
|
|
if (this.showKlipyGifPicker()) {
|
|
this.syncKlipyGifPickerAnchor();
|
|
}
|
|
}
|
|
|
|
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
|
this.messageList?.scrollToBottomAfterLocalSend();
|
|
|
|
const plan = planChatMessageSend({
|
|
generateId: uuidv4,
|
|
content: event.content,
|
|
pendingFiles: event.pendingFiles,
|
|
replyToId: this.replyTo()?.id,
|
|
channelId: this.activeChannelId()
|
|
});
|
|
|
|
this.store.dispatch(MessagesActions.sendMessage(plan.action));
|
|
|
|
this.clearReply();
|
|
|
|
if (plan.attachmentBinding) {
|
|
this.attachFilesToMessage(plan.attachmentBinding.messageId, plan.attachmentBinding.files);
|
|
}
|
|
}
|
|
|
|
handleTypingStarted(): void {
|
|
const roomId = this.currentRoom()?.id;
|
|
|
|
if (!roomId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.webrtc.sendRawMessage({
|
|
type: 'typing',
|
|
serverId: roomId,
|
|
channelId: this.activeChannelId() ?? 'general'
|
|
});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
setReplyTo(message: ChatMessageReplyEvent): void {
|
|
this.replyTo.set(message);
|
|
}
|
|
|
|
clearReply(): void {
|
|
this.replyTo.set(null);
|
|
}
|
|
|
|
handleEditSaved(event: ChatMessageEditEvent): void {
|
|
this.store.dispatch(
|
|
MessagesActions.editMessage({
|
|
messageId: event.messageId,
|
|
content: event.content
|
|
})
|
|
);
|
|
}
|
|
|
|
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
|
|
if (this.isOwnMessage(message)) {
|
|
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
|
} else if (this.isAdmin()) {
|
|
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
|
|
}
|
|
}
|
|
|
|
handleReactionAdded(event: ChatMessageReactionEvent): void {
|
|
this.store.dispatch(
|
|
MessagesActions.addReaction({
|
|
messageId: event.messageId,
|
|
emoji: event.emoji
|
|
})
|
|
);
|
|
}
|
|
|
|
handleReactionToggled(event: ChatMessageReactionEvent): void {
|
|
const message = this.channelMessages().find((entry) => entry.id === event.messageId);
|
|
const currentUserId = this.currentUser()?.id;
|
|
|
|
if (!message || !currentUserId)
|
|
return;
|
|
|
|
const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
|
|
|
|
if (hasReacted) {
|
|
this.store.dispatch(
|
|
MessagesActions.removeReaction({
|
|
messageId: event.messageId,
|
|
emoji: event.emoji
|
|
})
|
|
);
|
|
} else {
|
|
this.store.dispatch(
|
|
MessagesActions.addReaction({
|
|
messageId: event.messageId,
|
|
emoji: event.emoji
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
handleComposerHeightChanged(height: number): void {
|
|
this.composerBottomPadding.set(height + 20);
|
|
}
|
|
|
|
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
|
this.store.dispatch(
|
|
MessagesActions.removeLinkEmbed({
|
|
messageId: event.messageId,
|
|
url: event.url
|
|
})
|
|
);
|
|
}
|
|
|
|
handleLoadOlderRequested(event: { beforeTimestamp: number; limit: number }): void {
|
|
const roomId = this.currentRoom()?.id;
|
|
|
|
if (!roomId)
|
|
return;
|
|
|
|
this.store.dispatch(
|
|
MessagesActions.loadOlderMessages({
|
|
roomId,
|
|
channelId: this.activeChannelId() ?? 'general',
|
|
beforeTimestamp: event.beforeTimestamp,
|
|
limit: event.limit
|
|
})
|
|
);
|
|
}
|
|
|
|
toggleKlipyGifPicker(): void {
|
|
const nextState = !this.showKlipyGifPicker();
|
|
|
|
this.showKlipyGifPicker.set(nextState);
|
|
|
|
if (nextState) {
|
|
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
|
|
}
|
|
}
|
|
|
|
closeKlipyGifPicker(): void {
|
|
this.showKlipyGifPicker.set(false);
|
|
}
|
|
|
|
handleKlipyGifSelected(gif: KlipyGif): void {
|
|
this.closeKlipyGifPicker();
|
|
this.composer?.handleKlipyGifSelected(gif);
|
|
}
|
|
|
|
private syncKlipyGifPickerAnchor(): void {
|
|
const triggerRect = this.composer?.getKlipyTriggerRect();
|
|
|
|
if (!triggerRect) {
|
|
this.klipyGifPickerAnchorRight.set(16);
|
|
return;
|
|
}
|
|
|
|
const viewportWidth = window.innerWidth;
|
|
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
|
|
const preferredRight = viewportWidth - triggerRect.right;
|
|
const minRight = 16;
|
|
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
|
|
|
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
|
}
|
|
|
|
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
|
if (viewportWidth >= 1280)
|
|
return 52 * 16;
|
|
|
|
if (viewportWidth >= 768)
|
|
return 42 * 16;
|
|
|
|
if (viewportWidth >= 640)
|
|
return 34 * 16;
|
|
|
|
return Math.max(0, viewportWidth - 32);
|
|
}
|
|
|
|
openLightbox(event: ChatMessageImageLightboxEvent): void {
|
|
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
|
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
|
|
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
|
|
this.attachmentsSvc.pinDisplayBlobs(attachments);
|
|
this.lightboxState.set({
|
|
attachments,
|
|
index
|
|
});
|
|
}
|
|
|
|
closeLightbox(): void {
|
|
const state = this.lightboxState();
|
|
|
|
if (state) {
|
|
this.attachmentsSvc.unpinDisplayBlobs(state.attachments);
|
|
}
|
|
|
|
this.lightboxState.set(null);
|
|
}
|
|
|
|
stepLightbox(delta: number): void {
|
|
const state = this.lightboxState();
|
|
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = stepLightboxIndex(state.index, delta, state.attachments.length);
|
|
|
|
if (nextIndex === null) {
|
|
return;
|
|
}
|
|
|
|
this.lightboxState.set({
|
|
attachments: state.attachments,
|
|
index: nextIndex
|
|
});
|
|
}
|
|
|
|
openImageGallery(attachments: Attachment[]): void {
|
|
if (attachments.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const messageId = attachments[0]?.messageId;
|
|
|
|
if (!messageId) {
|
|
return;
|
|
}
|
|
|
|
const displayableImages = attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
|
|
|
if (displayableImages.length > 0) {
|
|
this.attachmentsSvc.pinDisplayBlobs(displayableImages);
|
|
}
|
|
|
|
this.galleryMessageId.set(messageId);
|
|
this.galleryAttachmentOrder.set(attachments.map((attachment) => attachment.id));
|
|
}
|
|
|
|
closeImageGallery(): void {
|
|
const gallery = this.galleryAttachments();
|
|
|
|
if (gallery) {
|
|
this.attachmentsSvc.unpinDisplayBlobs(gallery);
|
|
}
|
|
|
|
this.galleryMessageId.set(null);
|
|
this.galleryAttachmentOrder.set([]);
|
|
}
|
|
|
|
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
|
|
this.imageContextMenu.set(event);
|
|
}
|
|
|
|
closeImageContextMenu(): void {
|
|
this.imageContextMenu.set(null);
|
|
}
|
|
|
|
async downloadAttachment(attachment: Attachment): Promise<void> {
|
|
await this.attachmentDownload.downloadToUserLocation(attachment);
|
|
}
|
|
|
|
retryGalleryImage(event: ChatMessageAttachmentEvent): void {
|
|
const { messageId, attachment } = event;
|
|
const liveAttachment = this.attachmentsSvc.getForMessage(messageId).find((entry) => entry.id === attachment.id)
|
|
?? attachment;
|
|
|
|
if ((liveAttachment.receivedBytes ?? 0) > 0 || this.attachmentsSvc.hasPendingRequest(messageId, liveAttachment.id)) {
|
|
this.attachmentsSvc.cancelRequest(messageId, liveAttachment);
|
|
}
|
|
|
|
void this.attachmentsSvc.requestImageFromAnyPeer(messageId, liveAttachment);
|
|
}
|
|
|
|
cancelGalleryImage(event: ChatMessageAttachmentEvent): void {
|
|
this.attachmentsSvc.cancelRequest(event.messageId, event.attachment);
|
|
}
|
|
|
|
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
|
this.closeImageContextMenu();
|
|
|
|
if (!attachment.objectUrl)
|
|
return;
|
|
|
|
try {
|
|
const response = await fetch(attachment.objectUrl);
|
|
const blob = await response.blob();
|
|
const pngBlob = await this.convertToPng(blob);
|
|
|
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
private isOwnMessage(message: Message): boolean {
|
|
return message.senderId === this.currentUser()?.id;
|
|
}
|
|
|
|
private convertToPng(blob: Blob): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
if (blob.type === 'image/png') {
|
|
resolve(blob);
|
|
return;
|
|
}
|
|
|
|
const image = new Image();
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
image.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
|
|
canvas.width = image.naturalWidth;
|
|
canvas.height = image.naturalHeight;
|
|
const context = canvas.getContext('2d');
|
|
|
|
if (!context) {
|
|
reject(new Error('Canvas not supported'));
|
|
return;
|
|
}
|
|
|
|
context.drawImage(image, 0, 0);
|
|
canvas.toBlob((pngBlob) => {
|
|
URL.revokeObjectURL(url);
|
|
|
|
if (pngBlob)
|
|
resolve(pngBlob);
|
|
else
|
|
reject(new Error('PNG conversion failed'));
|
|
}, 'image/png');
|
|
};
|
|
|
|
image.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error('Image load failed'));
|
|
};
|
|
|
|
image.src = url;
|
|
});
|
|
}
|
|
|
|
private attachFilesToMessage(messageId: string, pendingFiles: File[]): void {
|
|
const currentUserId = this.currentUser()?.id;
|
|
const roomId = this.currentRoom()?.id;
|
|
|
|
if (roomId) {
|
|
this.attachmentsSvc.rememberMessageRoom(messageId, roomId);
|
|
}
|
|
|
|
this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined);
|
|
}
|
|
}
|