import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { v4 as uuidv4 } from 'uuid'; import { DirectMessageRepository } from '../../infrastructure/direct-message.repository'; import { OfflineMessageQueueService } from './offline-message-queue.service'; import { PeerDeliveryService } from './peer-delivery.service'; import { AttachmentFacade } from '../../../attachment'; import { CustomEmojiService } from '../../../custom-emoji'; import { NotificationsFacade } from '../../../notifications'; import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service'; import { advanceDirectMessageStatus, createDirectConversation, createGroupConversation, createDirectCallStartedMessage, directMessageConversationIncludesUser, directMessageEventIncludesUser, directMessageSyncIncludesUser, isGroupDirectConversation, updateMessageStatusInConversation, upsertDirectMessage } from '../../domain/logic/direct-message.logic'; import { buildDirectParticipantAliasIndex, canonicalizeDirectConversationId, canonicalizeDirectParticipantId, collectDirectMessageSelfUserIds, getCanonicalDirectConversationId, isSelfDirectMessageSender, mergeAliasDirectConversations, type DirectParticipantAliasIndex } from '../../domain/logic/direct-message-identity.rules'; import { DirectMessage, DirectMessageConversation, DirectMessageEventPayload, DirectMessageMutationEventPayload, DirectMessageParticipant, DirectMessageSyncEventPayload, DirectMessageSyncRequestEventPayload, DirectMessageStatus, DirectMessageStatusEventPayload, DirectMessageTypingEventPayload, toDirectMessageParticipant } from '../../domain/models/direct-message.model'; import type { ChatEvent, Reaction, User } from '../../../../shared-kernel'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; const DIRECT_MESSAGE_SYNC_LIMIT = 1000; const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000; const DIRECT_MESSAGE_TYPING_TTL_MS = 3000; const DIRECT_MESSAGE_TYPING_PURGE_MS = 1000; const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:'; interface DirectMessageTypingEntry { conversationId: string; userId: string; displayName: string; expiresAt: number; } @Injectable({ providedIn: 'root' }) export class DirectMessageService { private readonly repository = inject(DirectMessageRepository); private readonly offlineQueue = inject(OfflineMessageQueueService); private readonly delivery = inject(PeerDeliveryService); private readonly attachments = inject(AttachmentFacade); private readonly customEmoji = inject(CustomEmojiService); private readonly credentialStore = inject(SignalServerCredentialStoreService); private readonly store = inject(Store); private readonly router = inject(Router); private readonly notifications = inject(NotificationsFacade); private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly users = this.store.selectSignal(selectAllUsers); private readonly conversationsSignal = signal([]); private readonly selectedConversationIdSignal = signal(null); private readonly typingEntriesSignal = signal([]); private readonly lastSyncRequestAt = new Map(); private loadedOwnerId: string | null = null; readonly conversations = computed(() => [...this.conversationsSignal()].sort( (firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt )); readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly(); readonly selectedConversation = computed(() => { const selectedId = this.selectedConversationIdSignal(); return selectedId ? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null : null; }); readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce( (total, conversation) => total + conversation.unreadCount, 0 )); readonly typingEntries = this.typingEntriesSignal.asReadonly(); constructor() { effect(() => { const ownerId = this.getCurrentUserId(); void this.loadForOwner(ownerId); }); this.delivery.directMessageEvents$.subscribe((event) => { void this.handlePeerEvent(event); }); this.delivery.peerConnected$.subscribe(() => { void this.retryPending(); void this.requestOpenConversationSync(); }); this.delivery.networkRestored$.subscribe(() => { void this.retryPending(); void this.requestOpenConversationSync(); }); window.setInterval(() => this.purgeExpiredTypingEntries(), DIRECT_MESSAGE_TYPING_PURGE_MS); } async createConversation(user: User): Promise { const currentUser = this.requireCurrentUser(); const ownerId = this.getCurrentUserIdOrThrow(); await this.loadForOwner(ownerId); const aliasIndex = this.participantAliasIndex(); const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser)); const peerParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(user)); const conversationId = getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, peerParticipant.userId); const existingConversation = await this.collapseAliasConversations(ownerId, conversationId); if (existingConversation) { this.selectedConversationIdSignal.set(existingConversation.id); return existingConversation; } const conversation = createDirectConversation(currentParticipant, peerParticipant, Date.now()); await this.persistConversation(ownerId, conversation); this.selectedConversationIdSignal.set(conversation.id); return conversation; } async createGroupConversation( participants: DirectMessageParticipant[], title?: string, conversationId = `dm-group-${uuidv4()}` ): Promise { const currentUser = this.requireCurrentUser(); const ownerId = this.getCurrentUserIdOrThrow(); const currentParticipant = toDirectMessageParticipant(currentUser); const allParticipants = this.uniqueParticipants([currentParticipant, ...participants]); await this.loadForOwner(ownerId); const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId) ?? await this.repository.getConversation(ownerId, conversationId); if (existingConversation) { const mergedConversation = this.mergeConversationParticipants({ ...existingConversation, kind: 'group', title: existingConversation.title || title }, allParticipants); await this.persistConversation(ownerId, mergedConversation); this.selectedConversationIdSignal.set(mergedConversation.id); return mergedConversation; } const conversation = createGroupConversation(conversationId, allParticipants, Date.now(), title); await this.persistConversation(ownerId, conversation); this.selectedConversationIdSignal.set(conversation.id); return conversation; } async openConversation(conversationId: string): Promise { const ownerId = this.getCurrentUserIdOrThrow(); await this.loadForOwner(ownerId); this.selectedConversationIdSignal.set(conversationId); await this.markRead(conversationId); this.requestConversationSync(conversationId); } closeConversationView(conversationId?: string | null): void { if (!conversationId || this.selectedConversationIdSignal() === conversationId) { this.selectedConversationIdSignal.set(null); } } async forgetConversation(conversationId: string): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const conversation = await this.repository.getConversation(ownerId, conversationId); if (!conversation) { return; } await this.repository.deleteConversation(ownerId, conversationId); for (const message of conversation.messages) { await this.offlineQueue.markDelivered(ownerId, message.id); } this.conversationsSignal.update((conversations) => conversations.filter((entry) => entry.id !== conversationId)); if (this.selectedConversationIdSignal() === conversationId) { this.selectedConversationIdSignal.set(null); } } async sendMessage( conversationId: string, content: string, replyToId?: string, id?: string ): Promise { const normalizedContent = content.trim(); if (!normalizedContent) { throw new Error('Cannot send an empty direct message.'); } const currentUser = this.requireCurrentUser(); const ownerId = this.getCurrentUserIdOrThrow(); const conversation = await this.requireConversation(ownerId, conversationId); const senderId = currentUser.oderId || currentUser.id; const recipientIds = this.recipientIdsFor(conversation, senderId); const recipientId = recipientIds[0]; if (!recipientId) { throw new Error('Direct message conversation has no recipients.'); } const message: DirectMessage = { id: id ?? uuidv4(), conversationId, senderId, recipientId, recipientIds, content: normalizedContent, timestamp: Date.now(), status: 'QUEUED', reactions: [], isDeleted: false, replyToId }; await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false)); await this.attemptDelivery(ownerId, message, conversation); return message; } async recordCallStarted( rawConversationId: string, caller: DirectMessageParticipant, participants: DirectMessageParticipant[], timestamp = Date.now() ): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const currentUser = this.requireCurrentUser(); const aliasIndex = this.participantAliasIndex(); const conversationId = canonicalizeDirectConversationId(aliasIndex, rawConversationId); const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser)); const canonicalCaller = this.canonicalParticipant(aliasIndex, caller); const allParticipants = this.uniqueParticipants([ currentParticipant, canonicalCaller, ...participants.map((participant) => this.canonicalParticipant(aliasIndex, participant)) ]); await this.loadForOwner(ownerId); const existingConversation = await this.collapseAliasConversations(ownerId, conversationId) ?? this.createConversationForSystemEvent(conversationId, currentParticipant, canonicalCaller, allParticipants, timestamp); const conversation = this.mergeConversationParticipants(existingConversation, allParticipants); const message = createDirectCallStartedMessage( conversation.id, canonicalCaller, conversation.participants.filter((participantId) => participantId !== canonicalCaller.userId), timestamp ); await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false)); } async editMessage(conversationId: string, messageId: string, content: string): Promise { const normalizedContent = content.trim(); if (!normalizedContent) { return; } await this.applyAndSendMutation(conversationId, { conversationId, messageId, type: 'edit', content: normalizedContent, editedAt: Date.now(), updatedAt: Date.now() }); } async deleteMessage(conversationId: string, messageId: string): Promise { await this.applyAndSendMutation(conversationId, { conversationId, messageId, type: 'delete', updatedAt: Date.now() }); } async addReaction(conversationId: string, messageId: string, emoji: string): Promise { const userId = this.getCurrentUserIdOrThrow(); const reaction: Reaction = { id: uuidv4(), messageId, oderId: userId, userId, emoji, timestamp: Date.now() }; await this.applyAndSendMutation(conversationId, { conversationId, messageId, type: 'reaction-add', reaction, updatedAt: reaction.timestamp }); } async toggleReaction(conversationId: string, messageId: string, emoji: string): Promise { const userId = this.getCurrentUserIdOrThrow(); const conversation = await this.requireConversation(userId, conversationId); const message = conversation.messages.find((entry) => entry.id === messageId); const existingReaction = message?.reactions?.find((reaction) => reaction.emoji === emoji && (reaction.userId === userId || reaction.oderId === userId) ); if (existingReaction) { await this.applyAndSendMutation(conversationId, { conversationId, messageId, type: 'reaction-remove', oderId: userId, emoji, updatedAt: Date.now() }); return; } await this.addReaction(conversationId, messageId, emoji); } requestPeerAvatarSync(conversationId: string): void { const currentUserId = this.getCurrentUserId(); const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId); for (const peerId of this.recipientIdsFor(conversation, currentUserId)) { this.delivery.requestUserAvatar(peerId); } } currentUserId(): string | null { return this.getCurrentUserId(); } async updateStatus(messageId: string, status: DirectMessageStatus): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const conversation = this.conversationsSignal().find((entry) => entry.messages.some((message) => message.id === messageId)); if (!conversation) { return; } await this.persistConversation(ownerId, updateMessageStatusInConversation(conversation, messageId, status)); } async receiveMessage(message: DirectMessage, sender: User): Promise { await this.handleIncomingMessage({ message, sender: toDirectMessageParticipant(sender) }); } async markRead(conversationId: string): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const currentUserId = this.getCurrentUserIdOrThrow(); const conversation = await this.requireConversation(ownerId, conversationId); const updatedConversation = { ...conversation, unreadCount: 0 }; await this.persistConversation(ownerId, updatedConversation); await this.repository.markRead(ownerId, conversationId); for (const message of updatedConversation.messages) { if (message.recipientId !== currentUserId || message.status === 'ACKNOWLEDGED') { continue; } const nextStatus = advanceDirectMessageStatus(message.status, 'ACKNOWLEDGED'); if (nextStatus !== message.status) { await this.persistConversation(ownerId, updateMessageStatusInConversation(updatedConversation, message.id, nextStatus)); } this.sendStatusUpdate(message.senderId, { conversationId, messageId: message.id, status: 'ACKNOWLEDGED', updatedAt: Date.now() }); } } async retryPending(): Promise { const ownerId = this.getCurrentUserId(); if (!ownerId) { return; } await this.loadForOwner(ownerId); const pendingMessageIds = await this.offlineQueue.retryPending(ownerId); const messages = this.conversationsSignal().flatMap((conversation) => conversation.messages); for (const messageId of pendingMessageIds) { const message = messages.find((entry) => entry.id === messageId); const conversation = message ? this.conversationsSignal().find((entry) => entry.id === message.conversationId) : null; if (message && conversation) { await this.attemptDelivery(ownerId, message, conversation); } } } typingUsers(conversationId: string | null | undefined): string[] { if (!conversationId) { return []; } const now = Date.now(); return this.typingEntriesSignal() .filter((entry) => entry.conversationId === conversationId && entry.expiresAt > now) .map((entry) => entry.displayName); } sendTyping(conversationId: string, isTyping = true): void { const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId); const currentUser = this.currentUser(); const currentUserId = this.getCurrentUserId(); const recipientIds = this.recipientIdsFor(conversation, currentUserId); if (!conversation || !currentUser || recipientIds.length === 0) { return; } for (const recipientId of recipientIds) { this.delivery.sendViaWebRTC(recipientId, { type: 'direct-message-typing', directMessageTyping: { conversationId, sender: toDirectMessageParticipant(currentUser), isTyping, updatedAt: Date.now() } }); } } private async handlePeerEvent(event: ChatEvent): Promise { if (event.type === 'direct-message' && event.directMessage) { await this.handleIncomingMessage(event.directMessage); return; } if (event.type === 'direct-message-status' && event.directMessageStatus) { await this.handleIncomingStatus(event.directMessageStatus); return; } if (event.type === 'direct-message-mutation' && event.directMessageMutation) { await this.handleIncomingMutation(event.directMessageMutation); return; } if (event.type === 'direct-message-typing' && event.directMessageTyping) { this.handleIncomingTyping(event.directMessageTyping); return; } if (event.type === 'direct-message-sync-request' && event.directMessageSyncRequest) { await this.handleIncomingSyncRequest(event.directMessageSyncRequest); return; } if (event.type === 'direct-message-sync' && event.directMessageSync) { await this.handleIncomingSync(event.directMessageSync); } } private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const currentUser = this.requireCurrentUser(); const selfUserIds = this.getSelfUserIds(); if (!directMessageEventIncludesUser(payload, selfUserIds) || isSelfDirectMessageSender(payload, selfUserIds)) { return; } const aliasIndex = this.participantAliasIndex(); const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser)); const sender = this.canonicalParticipant(aliasIndex, payload.sender); const conversationId = payload.message.conversationId ? canonicalizeDirectConversationId(aliasIndex, payload.message.conversationId) : getCanonicalDirectConversationId(aliasIndex, currentParticipant.userId, sender.userId); const participants = this.uniqueParticipants([ currentParticipant, sender, ...(payload.participants ?? []).map((participant) => this.canonicalParticipant(aliasIndex, participant)) ]); const existingConversation = await this.collapseAliasConversations(ownerId, conversationId) ?? (payload.conversationKind === 'group' || participants.length > 2 ? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle) : { ...createDirectConversation(currentParticipant, sender, payload.message.timestamp), id: conversationId }); const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants); const incomingMessage: DirectMessage = { ...payload.message, conversationId, senderId: canonicalizeDirectParticipantId(aliasIndex, payload.message.senderId), recipientId: canonicalizeDirectParticipantId(aliasIndex, payload.message.recipientId), recipientIds: payload.message.recipientIds?.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId)), status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED') }; const shouldIncrementUnread = !this.isConversationVisible(conversationId); await this.persistConversation(ownerId, upsertDirectMessage(conversationWithParticipants, incomingMessage, shouldIncrementUnread)); this.sendStatusUpdate(incomingMessage.senderId, { conversationId, messageId: incomingMessage.id, status: 'DELIVERED', updatedAt: Date.now() }); if (incomingMessage.kind !== 'system' && !incomingMessage.isDeleted) { void this.notifications.handleIncomingDirectMessage({ id: incomingMessage.id, senderName: sender.displayName || sender.username || sender.userId, content: incomingMessage.content, conversationVisible: !shouldIncrementUnread }); } if (!shouldIncrementUnread) { await this.markRead(conversationId); } } private async handleIncomingStatus(payload: DirectMessageStatusEventPayload): Promise { await this.updateStatus(payload.messageId, payload.status); if (payload.status === 'DELIVERED' || payload.status === 'ACKNOWLEDGED') { await this.offlineQueue.markDelivered(this.getCurrentUserIdOrThrow(), payload.messageId); } } private isConversationVisible(conversationId: string): boolean { const currentUrl = this.router.url.split(/[?#]/, 1)[0]; if (!currentUrl.startsWith('/dm/') && !currentUrl.startsWith('/pm/')) { if (currentUrl.startsWith('/call/')) { return this.selectedConversationIdSignal() === conversationId; } return false; } const prefix = currentUrl.startsWith('/pm/') ? '/pm/' : '/dm/'; try { return decodeURIComponent(currentUrl.slice(prefix.length)) === conversationId; } catch { return currentUrl.slice(prefix.length) === conversationId; } } private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const conversation = await this.findConversation( ownerId, canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId) ); const selfUserIds = this.getSelfUserIds(); if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) { return; } await this.persistConversation(ownerId, this.applyMutation(conversation, payload)); } private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void { const selfUserIds = this.getSelfUserIds(); if (selfUserIds.size === 0 || selfUserIds.has(payload.sender.userId)) { return; } const aliasIndex = this.participantAliasIndex(); const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId); const senderId = canonicalizeDirectParticipantId(aliasIndex, payload.sender.userId); const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId); if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds) || !directMessageConversationIncludesUser(conversation, senderId)) { return; } if (!payload.isTyping) { this.typingEntriesSignal.update((entries) => entries.filter((entry) => !(entry.conversationId === conversationId && entry.userId === senderId) )); return; } const nextEntry: DirectMessageTypingEntry = { conversationId, userId: senderId, displayName: payload.sender.displayName, expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS }; this.typingEntriesSignal.update((entries) => [ ...entries.filter((entry) => !(entry.conversationId === nextEntry.conversationId && entry.userId === nextEntry.userId) ), nextEntry ]); } private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const currentUser = this.requireCurrentUser(); const conversation = await this.findConversation( ownerId, canonicalizeDirectConversationId(this.participantAliasIndex(), payload.conversationId) ); const selfUserIds = this.getSelfUserIds(); if (!conversation || selfUserIds.has(payload.sender.userId) || !directMessageConversationIncludesUser(conversation, selfUserIds) || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { return; } this.delivery.sendViaWebRTC(payload.sender.userId, { type: 'direct-message-sync', directMessageSync: { conversationId: conversation.id, sender: toDirectMessageParticipant(currentUser), participants: Object.values(conversation.participantProfiles), conversationKind: this.conversationKind(conversation), conversationTitle: conversation.title, messages: conversation.messages.slice(-DIRECT_MESSAGE_SYNC_LIMIT), syncedAt: Date.now() } }); } private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const currentUser = this.requireCurrentUser(); const aliasIndex = this.participantAliasIndex(); const currentParticipant = this.canonicalParticipant(aliasIndex, toDirectMessageParticipant(currentUser)); const sender = this.canonicalParticipant(aliasIndex, payload.sender); const selfUserIds = this.getSelfUserIds(); if (selfUserIds.has(payload.sender.userId)) { return; } if (!directMessageSyncIncludesUser(payload, selfUserIds) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) { return; } const conversationId = canonicalizeDirectConversationId(aliasIndex, payload.conversationId); const syncParticipants = payload.participants.map((participant) => this.canonicalParticipant(aliasIndex, participant)); const existingConversation = await this.collapseAliasConversations(ownerId, conversationId) ?? (payload.conversationKind === 'group' || payload.participants.length > 2 ? createGroupConversation(conversationId, [currentParticipant, ...syncParticipants], payload.syncedAt, payload.conversationTitle) : { ...createDirectConversation(currentParticipant, sender, payload.syncedAt), id: conversationId }); const participantProfiles = { ...existingConversation.participantProfiles, ...Object.fromEntries(syncParticipants.map((participant) => [participant.userId, participant])), [currentParticipant.userId]: currentParticipant }; const syncBaseConversation: DirectMessageConversation = { ...existingConversation, kind: payload.conversationKind ?? existingConversation.kind, title: payload.conversationTitle ?? existingConversation.title, participants: Object.keys(participantProfiles).sort(), participantProfiles }; const mergedConversation = payload.messages.reduce( (conversation, message) => upsertDirectMessage(conversation, { ...message, conversationId, senderId: canonicalizeDirectParticipantId(aliasIndex, message.senderId), recipientId: canonicalizeDirectParticipantId(aliasIndex, message.recipientId), recipientIds: message.recipientIds?.map((recipientId) => canonicalizeDirectParticipantId(aliasIndex, recipientId)) }, false), syncBaseConversation ); await this.persistConversation(ownerId, mergedConversation); if (this.selectedConversationIdSignal() === conversationId) { await this.markRead(conversationId); } } private async applyAndSendMutation( conversationId: string, payload: DirectMessageMutationEventPayload ): Promise { const ownerId = this.getCurrentUserIdOrThrow(); const conversation = await this.requireConversation(ownerId, conversationId); const updatedConversation = this.applyMutation(conversation, payload); const recipientIds = this.recipientIdsFor(conversation, ownerId); await this.persistConversation(ownerId, updatedConversation); if (payload.type === 'edit' && payload.content) { this.customEmoji.pushEmojisInContent(payload.content); } else if (payload.type === 'reaction-add' && payload.reaction?.emoji) { this.customEmoji.pushEmojisInContent(payload.reaction.emoji); } for (const recipientId of recipientIds) { this.delivery.sendViaWebRTC(recipientId, { type: 'direct-message-mutation', directMessageMutation: payload }); } } private applyMutation( conversation: DirectMessageConversation, payload: DirectMessageMutationEventPayload ): DirectMessageConversation { const messages = conversation.messages.map((message) => { if (message.id !== payload.messageId) { return message; } if (payload.type === 'edit' && payload.content) { return { ...message, content: payload.content, editedAt: payload.editedAt ?? payload.updatedAt, isDeleted: false }; } if (payload.type === 'delete') { return { ...message, content: '', isDeleted: true, editedAt: payload.updatedAt }; } if (payload.type === 'reaction-add' && payload.reaction) { const reactions = (message.reactions ?? []).filter((reaction) => !(reaction.emoji === payload.reaction?.emoji && reaction.userId === payload.reaction.userId) ); return { ...message, reactions: [...reactions, payload.reaction] }; } if (payload.type === 'reaction-remove' && payload.oderId && payload.emoji) { return { ...message, reactions: (message.reactions ?? []).filter((reaction) => !(reaction.emoji === payload.emoji && (reaction.userId === payload.oderId || reaction.oderId === payload.oderId)) ) }; } return message; }); return { ...conversation, messages }; } private async attemptDelivery(ownerId: string, message: DirectMessage, conversation: DirectMessageConversation): Promise { const currentUser = this.requireCurrentUser(); const recipientIds = this.recipientIdsFor(conversation, ownerId); let sentCount = 0; this.customEmoji.pushEmojisInContent(message.content); for (const recipientId of recipientIds) { if (this.delivery.sendViaWebRTC(recipientId, { type: 'direct-message', directMessage: { message, sender: toDirectMessageParticipant(currentUser), participants: Object.values(conversation.participantProfiles), conversationKind: this.conversationKind(conversation), conversationTitle: conversation.title } })) { sentCount += 1; } } if (sentCount < recipientIds.length) { await this.offlineQueue.enqueue(ownerId, message.id); } if (sentCount > 0) { await this.updateStatus(message.id, 'SENT'); } if (sentCount === recipientIds.length) { await this.offlineQueue.markDelivered(ownerId, message.id); } } private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void { this.delivery.handleAck(recipientId, { type: 'direct-message-status', directMessageStatus: payload }); } private requestOpenConversationSync(): void { const conversationId = this.selectedConversationIdSignal(); if (conversationId) { this.requestConversationSync(conversationId); } } private requestConversationSync(conversationId: string): void { const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId); const currentUser = this.currentUser(); const currentUserId = this.getCurrentUserId(); const recipientIds = this.recipientIdsFor(conversation, currentUserId); if (!conversation || !currentUser || recipientIds.length === 0) { return; } const now = Date.now(); for (const recipientId of recipientIds) { const syncKey = `${conversationId}:${recipientId}`; if (now - (this.lastSyncRequestAt.get(syncKey) ?? 0) < DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS) { continue; } this.lastSyncRequestAt.set(syncKey, now); this.delivery.sendViaWebRTC(recipientId, { type: 'direct-message-sync-request', directMessageSyncRequest: { conversationId, sender: toDirectMessageParticipant(currentUser), requestedAt: Date.now() } }); } } private purgeExpiredTypingEntries(): void { const now = Date.now(); this.typingEntriesSignal.update((entries) => entries.filter((entry) => entry.expiresAt > now)); } private async loadForOwner(ownerId: string | null): Promise { if (!ownerId) { this.loadedOwnerId = null; this.conversationsSignal.set([]); return; } if (this.loadedOwnerId === ownerId) { return; } this.loadedOwnerId = ownerId; const conversations = await this.repository.loadConversations(ownerId); conversations.forEach((conversation) => this.rememberConversationAttachmentStorage(conversation)); this.conversationsSignal.set(conversations); } private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise { this.rememberConversationAttachmentStorage(conversation); await this.repository.saveConversation(ownerId, conversation); this.conversationsSignal.update((conversations) => { const nextConversations = conversations.filter((entry) => entry.id !== conversation.id); nextConversations.push(conversation); return nextConversations; }); } private rememberConversationAttachmentStorage(conversation: DirectMessageConversation): void { const storageContainer = `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${conversation.id}`; for (const message of conversation.messages) { this.attachments.rememberMessageRoom(message.id, storageContainer); } } /** * One human can be addressed by several ids: their home id and one * provisioned actor id per foreign signal server. Threads are stored under * the home id, so every inbound id is resolved through this index first. */ private participantAliasIndex(): DirectParticipantAliasIndex { const currentUser = this.currentUser(); const peerGroups = this.users().map((user) => ({ canonicalId: user.oderId || user.id, aliasIds: [ user.id, user.oderId, user.peerId ].filter((aliasId): aliasId is string => !!aliasId) })); const selfGroups = currentUser ? [ { canonicalId: currentUser.oderId || currentUser.id, aliasIds: [...this.getSelfUserIds()] } ] : []; return buildDirectParticipantAliasIndex([...peerGroups, ...selfGroups]); } private canonicalParticipant( aliasIndex: DirectParticipantAliasIndex, participant: DirectMessageParticipant ): DirectMessageParticipant { const canonicalId = canonicalizeDirectParticipantId(aliasIndex, participant.userId); return canonicalId === participant.userId ? participant : { ...participant, userId: canonicalId }; } /** * Fold any thread that resolves to `canonicalId` into a single stored * conversation. Returns null when this human pair has no thread yet. */ private async collapseAliasConversations( ownerId: string, canonicalId: string ): Promise { await this.loadForOwner(ownerId); const aliasIndex = this.participantAliasIndex(); const matching = this.conversationsSignal().filter((conversation) => canonicalizeDirectConversationId(aliasIndex, conversation.id) === canonicalId); if (matching.length === 0) { return null; } if (matching.length === 1 && matching[0].id === canonicalId) { return matching[0]; } const merged = mergeAliasDirectConversations(aliasIndex, matching); const staleIds = matching.map((conversation) => conversation.id).filter((id) => id !== merged.id); await this.persistConversation(ownerId, merged); for (const staleId of staleIds) { await this.repository.deleteConversation(ownerId, staleId); } this.conversationsSignal.update((conversations) => conversations.filter((conversation) => !staleIds.includes(conversation.id))); if (staleIds.includes(this.selectedConversationIdSignal() ?? '')) { this.selectedConversationIdSignal.set(merged.id); } return merged; } private mergeConversationParticipants( conversation: DirectMessageConversation, participants: DirectMessageParticipant[] ): DirectMessageConversation { const participantProfiles = { ...conversation.participantProfiles, ...Object.fromEntries(participants.map((participant) => [participant.userId, participant])) }; return { ...conversation, participants: Object.keys(participantProfiles).sort(), participantProfiles }; } private createConversationForSystemEvent( conversationId: string, currentParticipant: DirectMessageParticipant, caller: DirectMessageParticipant, participants: DirectMessageParticipant[], timestamp: number ): DirectMessageConversation { if (participants.length > 2) { return createGroupConversation(conversationId, participants, timestamp); } const peer = participants.find((participant) => participant.userId !== currentParticipant.userId) ?? caller; return { ...createDirectConversation(currentParticipant, peer, timestamp), id: conversationId }; } private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] { if (!conversation || !currentUserId) { return []; } const selfUserIds = this.getSelfUserIds(); return conversation.participants.filter((participantId) => !selfUserIds.has(participantId)); } private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' { return isGroupDirectConversation(conversation) ? 'group' : 'direct'; } private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] { const seen = new Set(); return participants.filter((participant) => { if (!participant.userId || seen.has(participant.userId)) { return false; } seen.add(participant.userId); return true; }); } private async requireConversation(ownerId: string, conversationId: string): Promise { const conversation = await this.findConversation(ownerId, conversationId); if (!conversation) { throw new Error('Direct message conversation not found.'); } return conversation; } private async findConversation(ownerId: string, conversationId: string): Promise { await this.loadForOwner(ownerId); return this.conversationsSignal().find((entry) => entry.id === conversationId) ?? await this.repository.getConversation(ownerId, conversationId); } private requireCurrentUser(): User { const currentUser = this.currentUser(); if (!currentUser) { throw new Error('Cannot use direct messages without a current user.'); } return currentUser; } private getCurrentUserId(): string | null { const user = this.currentUser(); return user?.oderId || user?.id || null; } private getCurrentUserIdOrThrow(): string { const ownerId = this.getCurrentUserId(); if (!ownerId) { throw new Error('Cannot use direct messages without a current user.'); } return ownerId; } private getSelfUserIds(): ReadonlySet { const currentUser = this.currentUser(); if (!currentUser) { return new Set(); } const actorUserIds = this.credentialStore.listValidCredentials().map((credential) => credential.userId); return collectDirectMessageSelfUserIds(currentUser, actorUserIds); } }