405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
Component,
|
|
HostListener,
|
|
ViewChild,
|
|
computed,
|
|
inject,
|
|
signal
|
|
} from '@angular/core';
|
|
import { Store } from '@ngrx/store';
|
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
import { Attachment, AttachmentFacade } from '../../../attachment';
|
|
import { KlipyGif } from '../../application/klipy.service';
|
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
|
import {
|
|
selectAllMessages,
|
|
selectMessagesLoading,
|
|
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 { 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 {
|
|
ChatMessageComposerSubmitEvent,
|
|
ChatMessageDeleteEvent,
|
|
ChatMessageEditEvent,
|
|
ChatMessageImageContextMenuEvent,
|
|
ChatMessageReactionEvent,
|
|
ChatMessageReplyEvent
|
|
} from './models/chat-messages.models';
|
|
|
|
@Component({
|
|
selector: 'app-chat-messages',
|
|
standalone: true,
|
|
imports: [
|
|
ChatMessageComposerComponent,
|
|
KlipyGifPickerComponent,
|
|
ChatMessageListComponent,
|
|
ChatMessageOverlaysComponent
|
|
],
|
|
templateUrl: './chat-messages.component.html',
|
|
styleUrl: './chat-messages.component.scss'
|
|
})
|
|
export class ChatMessagesComponent {
|
|
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
|
|
|
private readonly electronBridge = inject(ElectronBridgeService);
|
|
private readonly store = inject(Store);
|
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
|
|
|
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
|
|
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
|
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
|
|
|
readonly channelMessages = computed(() => {
|
|
const channelId = this.activeChannelId();
|
|
const roomId = this.currentRoom()?.id;
|
|
|
|
return this.allMessages().filter(
|
|
(message) =>
|
|
message.roomId === roomId &&
|
|
(message.channelId || 'general') === channelId
|
|
);
|
|
});
|
|
|
|
readonly conversationKey = computed(
|
|
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
|
);
|
|
readonly composerBottomPadding = signal(140);
|
|
readonly klipyGifPickerAnchorRight = signal(16);
|
|
readonly replyTo = signal<Message | null>(null);
|
|
readonly showKlipyGifPicker = signal(false);
|
|
readonly lightboxAttachment = signal<Attachment | null>(null);
|
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
|
|
|
@HostListener('window:resize')
|
|
onWindowResize(): void {
|
|
if (this.showKlipyGifPicker()) {
|
|
this.syncKlipyGifPickerAnchor();
|
|
}
|
|
}
|
|
|
|
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
|
this.store.dispatch(
|
|
MessagesActions.sendMessage({
|
|
content: event.content,
|
|
replyToId: this.replyTo()?.id,
|
|
channelId: this.activeChannelId()
|
|
})
|
|
);
|
|
|
|
this.clearReply();
|
|
|
|
if (event.pendingFiles.length > 0) {
|
|
setTimeout(() => this.attachFilesToLastOwnMessage(event.content, event.pendingFiles), 100);
|
|
}
|
|
}
|
|
|
|
handleTypingStarted(): void {
|
|
try {
|
|
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
|
|
} 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);
|
|
}
|
|
|
|
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(attachment: Attachment): void {
|
|
if (attachment.available && attachment.objectUrl) {
|
|
this.lightboxAttachment.set(attachment);
|
|
}
|
|
}
|
|
|
|
closeLightbox(): void {
|
|
this.lightboxAttachment.set(null);
|
|
}
|
|
|
|
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
|
|
this.imageContextMenu.set(event);
|
|
}
|
|
|
|
closeImageContextMenu(): void {
|
|
this.imageContextMenu.set(null);
|
|
}
|
|
|
|
async downloadAttachment(attachment: Attachment): Promise<void> {
|
|
if (!attachment.available || !attachment.objectUrl)
|
|
return;
|
|
|
|
const electronApi = this.electronBridge.getApi();
|
|
|
|
if (electronApi) {
|
|
const blob = await this.getAttachmentBlob(attachment);
|
|
|
|
if (blob) {
|
|
try {
|
|
const result = await electronApi.saveFileAs(
|
|
attachment.filename,
|
|
await this.blobToBase64(blob)
|
|
);
|
|
|
|
if (result.saved || result.cancelled)
|
|
return;
|
|
} catch {
|
|
/* fall back to browser download */
|
|
}
|
|
}
|
|
}
|
|
|
|
const link = document.createElement('a');
|
|
|
|
link.href = attachment.objectUrl;
|
|
link.download = attachment.filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
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 async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
|
if (!attachment.objectUrl)
|
|
return null;
|
|
|
|
try {
|
|
const response = await fetch(attachment.objectUrl);
|
|
|
|
return await response.blob();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private blobToBase64(blob: Blob): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = () => {
|
|
if (typeof reader.result !== 'string') {
|
|
reject(new Error('Failed to encode attachment'));
|
|
return;
|
|
}
|
|
|
|
const [, base64 = ''] = reader.result.split(',', 2);
|
|
|
|
resolve(base64);
|
|
};
|
|
|
|
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
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 attachFilesToLastOwnMessage(content: string, pendingFiles: File[]): void {
|
|
const currentUserId = this.currentUser()?.id;
|
|
|
|
if (!currentUserId)
|
|
return;
|
|
|
|
const message = [...this.channelMessages()]
|
|
.reverse()
|
|
.find(
|
|
(entry) =>
|
|
entry.senderId === currentUserId &&
|
|
entry.content === content &&
|
|
!entry.isDeleted
|
|
);
|
|
|
|
if (!message) {
|
|
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
|
return;
|
|
}
|
|
|
|
this.attachmentsSvc.publishAttachments(message.id, pendingFiles, currentUserId || undefined);
|
|
}
|
|
}
|