Files
Toju/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts
T
myxeliumandCursor b13f71d2d3 fix: Bug - Sending files and attachment issues (gallery load and speed)
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>
2026-06-14 13:19:12 +02:00

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