Files
Toju/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts
Myx 181fedc7ec
Some checks failed
Queue Release Build / prepare (push) Successful in 30s
Deploy Web Apps / deploy (push) Successful in 7m8s
Queue Release Build / build-windows (push) Successful in 28m11s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has started running
feat: Response mobile layout support v1
2026-05-18 02:25:16 +02:00

582 lines
18 KiB
TypeScript

/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
HostListener,
inject,
input,
signal,
ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import {
ChatMessageComposerSubmitEvent,
ChatMessageComposerComponent,
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageListComponent,
ChatMessageOverlaysComponent,
ChatMessageReactionEvent,
ChatMessageReplyEvent,
hasDedicatedChatEmbed,
KlipyGif,
KlipyGifPickerComponent,
KlipyService,
LinkMetadataService,
type ChatMessageEmbedRemoveEvent
} from '../../../chat';
import type {
DirectMessageStatus,
LinkMetadata,
Message,
User
} from '../../../../shared-kernel';
interface DmStatusLabel {
id: string;
status: DirectMessageStatus;
}
@Component({
selector: 'app-dm-chat',
standalone: true,
imports: [
CommonModule,
ChatMessageComposerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent,
KlipyGifPickerComponent,
BottomSheetComponent,
NgIcon,
ThemeNodeDirective,
UserAvatarComponent
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
templateUrl: './dm-chat.component.html',
host: {
class: 'block h-full'
}
})
export class DmChatComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false);
readonly conversationId = input<string | null>(null);
readonly showCallButton = input(true);
readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
readonly replyTo = signal<Message | null>(null);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly typingUsers = computed(() => {
void this.directMessages.typingEntries();
return this.directMessages.typingUsers(this.conversation()?.id);
});
readonly peerUser = computed(() => {
const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null;
});
readonly isGroupConversation = computed(() => {
const conversation = this.conversation();
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
});
readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation();
const knownUsers = this.allUsers();
if (!conversation) {
return [];
}
return conversation.participants.map((participantId) => {
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
const participant = conversation.participantProfiles[participantId];
return (
knownUser ?? {
id: participantId,
oderId: participantId,
username: participant?.username || participant?.displayName || participantId,
displayName: participant?.displayName || participant?.username || participantId,
description: participant?.description,
profileUpdatedAt: participant?.profileUpdatedAt,
avatarUrl: participant?.avatarUrl,
avatarHash: participant?.avatarHash,
avatarMime: participant?.avatarMime,
avatarUpdatedAt: participant?.avatarUpdatedAt,
status: 'disconnected',
role: 'member',
joinedAt: 0
}
);
});
});
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
if (!conversation || !currentUserId) {
return [];
}
return conversation.messages
.filter((message) => message.senderId === currentUserId)
.map((message) => ({
id: message.id,
status: message.status
}));
});
readonly chatMessages = computed<Message[]>(() => {
const conversation = this.conversation();
const metadataByMessageId = this.linkMetadataByMessageId();
if (!conversation) {
return [];
}
return conversation.messages.map((message) => {
const participant = conversation.participantProfiles[message.senderId];
const knownUser = this.participantUsers().find((user) => user.id === message.senderId || user.oderId === message.senderId);
return {
id: message.id,
roomId: conversation.id,
channelId: 'direct-message',
senderId: message.senderId,
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt,
reactions: message.reactions ?? [],
isDeleted: !!message.isDeleted,
replyToId: message.replyToId,
linkMetadata: metadataByMessageId[message.id]
};
});
});
readonly peerName = computed(() => {
const conversation = this.conversation();
const currentUserId = this.currentUserId();
if (conversation && this.isGroupConversation()) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
});
readonly peerCallIcon = computed(() => {
const conversation = this.conversation();
if (conversation && this.isGroupConversation()) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser();
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
});
readonly canCallConversation = computed(() => {
const conversation = this.conversation();
if (!conversation) {
return false;
}
if (this.isGroupConversation()) {
return conversation.participants.some((participantId) => participantId !== this.currentUserId());
}
return !!this.peerUser();
});
constructor() {
effect(() => {
const conversationId = this.effectiveConversationId();
if (!conversationId) {
this.openedConversationId = null;
return;
}
if (conversationId !== this.openedConversationId) {
this.openedConversationId = conversationId;
void this.directMessages.openConversation(conversationId);
}
});
effect(() => {
void this.effectiveConversationId();
void this.klipy.refreshAvailability(null);
});
effect(() => {
void this.refreshLinkMetadata(this.chatMessages());
});
effect(() => {
const conversation = this.conversation();
const peerUser = this.peerUser();
if (conversation && !peerUser?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
});
}
@HostListener('window:resize')
onWindowResize(): void {
if (this.showGifPicker()) {
this.syncGifPickerAnchor();
}
}
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
const conversation = this.conversation();
if (!conversation || (!event.content.trim() && event.pendingFiles.length === 0)) {
return;
}
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id).then((message) => {
this.replyTo.set(null);
if (event.pendingFiles.length > 0) {
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
}
});
}
handleTypingStarted(): void {
const conversationId = this.conversation()?.id;
if (conversationId) {
this.directMessages.sendTyping(conversationId, true);
}
}
async callConversation(): Promise<void> {
const conversation = this.conversation();
if (conversation && this.canCallConversation()) {
await this.directCalls.startConversationCall(conversation);
}
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}
clearReply(): void {
this.replyTo.set(null);
}
handleEditSaved(event: ChatMessageEditEvent): void {
const conversation = this.conversation();
if (conversation) {
void this.directMessages.editMessage(conversation.id, event.messageId, event.content);
}
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
const conversation = this.conversation();
if (conversation && message.senderId === this.currentUserId()) {
void this.directMessages.deleteMessage(conversation.id, message.id);
}
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
const conversation = this.conversation();
if (conversation) {
void this.directMessages.addReaction(conversation.id, event.messageId, event.emoji);
}
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
const conversation = this.conversation();
if (conversation) {
void this.directMessages.toggleReaction(conversation.id, event.messageId, event.emoji);
}
}
toggleGifPicker(): void {
if (!this.klipyEnabled()) {
return;
}
this.showGifPicker.update((visible) => !visible);
if (this.showGifPicker()) {
requestAnimationFrame(() => this.syncGifPickerAnchor());
}
}
closeGifPicker(): void {
this.showGifPicker.set(false);
}
handleGifSelected(gif: KlipyGif): void {
this.closeGifPicker();
this.composer?.handleKlipyGifSelected(gif);
}
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
this.linkMetadataByMessageId.update((metadataByMessageId) => ({
...metadataByMessageId,
[event.messageId]: (metadataByMessageId[event.messageId] ?? []).filter((metadata) => metadata.url !== event.url)
}));
}
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 diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to blob/browser download */
}
}
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();
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
} catch {
/* ignore */
}
}
private syncGifPickerAnchor(): void {
const triggerRect = this.composer?.getKlipyTriggerRect();
if (!triggerRect) {
this.gifPickerAnchorRight.set(16);
return;
}
const viewportWidth = window.innerWidth;
const popupWidth = viewportWidth >= 1280 ? 52 * 16 : viewportWidth >= 768 ? 42 * 16 : 34 * 16;
const preferredRight = viewportWidth - triggerRect.right;
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.gifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
}
private async refreshLinkMetadata(messages: Message[]): Promise<void> {
const metadataByMessageId = this.linkMetadataByMessageId();
for (const message of messages) {
if (metadataByMessageId[message.id]?.length) {
continue;
}
const urls = this.linkMetadata.extractUrls(message.content)
.filter((url) => !hasDedicatedChatEmbed(url))
.filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url)));
if (urls.length === 0) {
continue;
}
urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url)));
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
if (metadata.length === 0) {
continue;
}
this.linkMetadataByMessageId.update((currentMetadata) => ({
...currentMetadata,
[message.id]: metadata
}));
}
}
private metadataRequestKey(messageId: string, url: string): string {
return `${messageId}:${url}`;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || 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 peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null;
}
const currentUserId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
if (!peerId) {
return null;
}
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
}
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
}