Files
Toju/src/app/features/chat/chat-messages/chat-messages.component.ts
Myx 778e75bef5 fix: [Experimental hotfix 1] Fix Signaling issues Toju App
1. Server: WebSocket ping/pong heartbeat (index.ts)
Added a 30-second ping interval that pings all connected clients
Connections without a pong response for 45 seconds are terminated and cleaned up
Extracted removeDeadConnection() to deduplicate the cleanup logic between close events and dead connection reaping
2. Server: Fixed sendServerUsers filter bug (handler.ts:13)
Removed && cu.displayName from the filter — users who joined a server before their identify message was processed were silently invisible to everyone. This was the direct cause of "can't see each other" in session 2.
3. Client: Typing message now includes serverId
Added serverId: this.webrtc.currentServerId to the typing payload
Added a currentServerId getter on WebRTCService
2026-03-12 23:53:10 +01:00

411 lines
12 KiB
TypeScript

/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
import { KlipyGif } from '../../../core/services/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 '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
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 store = inject(Store);
private readonly webrtc = inject(WebRTCService);
private readonly attachmentsSvc = inject(AttachmentService);
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 electronWindow = window as Window & {
electronAPI?: {
saveFileAs?: (
defaultFileName: string,
data: string
) => Promise<{ saved: boolean; cancelled: boolean }>;
};
};
const electronApi = electronWindow.electronAPI;
if (electronApi?.saveFileAs) {
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);
}
}