// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { isEqual } from 'lodash'; import type { DeleteAttributesType } from '../messageModifiers/Deletes'; import type { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents'; import * as log from '../logging/log'; import { DeleteModel } from '../messageModifiers/Deletes'; import { SendStatus } from '../messages/MessageSendState'; import { getConversationIdForLogging } from './idForLogging'; import { isStory } from '../state/selectors/message'; import { normalizeUuid } from './normalizeUuid'; import { queueUpdateMessage } from './messageBatcher'; import { isMe } from './whatTypeOfConversation'; import { drop } from './drop'; export async function onStoryRecipientUpdate( event: StoryRecipientUpdateEvent ): Promise { const { data, confirm } = event; const { destinationUuid, timestamp } = data; const conversation = window.ConversationController.get(destinationUuid); const logId = `onStoryRecipientUpdate(${destinationUuid}, ${timestamp})`; if (!conversation) { log.warn(`${logId}: no conversation`); return; } if (!isMe(conversation.attributes)) { log.warn(`${logId}: story recipient update on invalid conversation`); return; } const targetConversation = await window.ConversationController.getConversationForTargetMessage( conversation.id, timestamp ); if (!targetConversation) { log.info(`${logId}: no targetConversation`); return; } drop( targetConversation.queueJob(logId, async () => { log.info(`${logId}: updating`); // Build up some maps for fast/easy lookups const isAllowedToReply = new Map(); const distributionListIdToConversationIds = new Map< string, Set >(); data.storyMessageRecipients.forEach(item => { const { destinationAci, destinationPni } = item; const destinationId = destinationAci || destinationPni; if (!destinationId) { return; } const convo = window.ConversationController.get( normalizeUuid(destinationId, `${logId}.destinationId`) ); if (!convo || !item.distributionListIds) { return; } for (const rawUuid of item.distributionListIds) { const uuid = normalizeUuid(rawUuid, `${logId}.distributionListId`); const existing = distributionListIdToConversationIds.get(uuid); if (existing === undefined) { distributionListIdToConversationIds.set(uuid, new Set([convo.id])); } else { existing.add(convo.id); } } isAllowedToReply.set(convo.id, item.isAllowedToReply !== false); }); const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); const now = Date.now(); const messages = await window.Signal.Data.getMessagesBySentAt(timestamp); // Now we figure out who needs to be added and who needs to removed const handledMessages = messages.filter(item => { if (!isStory(item)) { return false; } const { sendStateByConversationId, storyDistributionListId } = item; if (!sendStateByConversationId || !storyDistributionListId) { return false; } const newConversationIds = distributionListIdToConversationIds.get(storyDistributionListId) ?? new Set(); const nextSendStateByConversationId = { ...sendStateByConversationId, }; // Find conversation ids present in the local send state, but missing // in the remote state, and remove them from the local state. for (const oldId of Object.keys(sendStateByConversationId)) { if (!newConversationIds.has(oldId)) { const recipient = window.ConversationController.get(oldId); const recipientLogId = recipient ? getConversationIdForLogging(recipient.attributes) : oldId; log.info(`${logId}: removing`, { recipient: recipientLogId, messageId: item.id, storyDistributionListId, }); delete nextSendStateByConversationId[oldId]; } } // Find conversation ids present in the remote send state, but missing in // the local send state, and add them to the local state. for (const newId of newConversationIds) { if (sendStateByConversationId[newId] === undefined) { const recipient = window.ConversationController.get(newId); const recipientLogId = recipient ? getConversationIdForLogging(recipient.attributes) : newId; log.info(`${logId}: adding`, { recipient: recipientLogId, messageId: item.id, storyDistributionListId, }); nextSendStateByConversationId[newId] = { isAllowedToReplyToStory: Boolean(isAllowedToReply.get(newId)), status: SendStatus.Sent, updatedAt: now, }; } } if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) { log.info(`${logId}: sendStateByConversationId does not need update`, { messageId: item.id, }); return true; } const message = window.MessageController.register(item.id, item); const sendStateConversationIds = new Set( Object.keys(nextSendStateByConversationId) ); if ( sendStateConversationIds.size === 0 || (sendStateConversationIds.size === 1 && sendStateConversationIds.has(ourConversationId)) ) { log.info(`${logId} DOE`, { messageId: item.id, storyDistributionListId, }); const delAttributes: DeleteAttributesType = { fromId: ourConversationId, serverTimestamp: Number(item.serverTimestamp), targetSentTimestamp: item.timestamp, }; const doe = new DeleteModel(delAttributes); // There are no longer any remaining members for this message so lets // run it through deleteForEveryone which marks the message as // deletedForEveryone locally. // // NOTE: We don't call `Deletes.onDelete()` so the message lookup by // sent timestamp doesn't happen (it would return all copies of the // story, not just the one we want to delete). void message.handleDeleteForEveryone(doe); } else { message.set({ sendStateByConversationId: nextSendStateByConversationId, }); queueUpdateMessage(message.attributes); } return true; }); if (handledMessages.length) { window.Whisper.events.trigger('incrementProgress'); confirm(); } }) ); }