From 2f5dd73e58aa260c5197c2cc78d0806e98cd754d Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 8 Aug 2022 23:26:21 -0400 Subject: [PATCH] Send stories to groups capability --- stylesheets/components/StoryDetailsModal.scss | 4 + ts/components/StoryCreator.tsx | 3 +- ts/jobs/helpers/sendStory.ts | 282 ++++++++++-------- ts/state/ducks/stories.ts | 3 +- ts/util/sendStoryMessage.ts | 179 +++++++++-- 5 files changed, 308 insertions(+), 163 deletions(-) diff --git a/stylesheets/components/StoryDetailsModal.scss b/stylesheets/components/StoryDetailsModal.scss index 44072690bbda..039cccfbbd52 100644 --- a/stylesheets/components/StoryDetailsModal.scss +++ b/stylesheets/components/StoryDetailsModal.scss @@ -10,6 +10,10 @@ justify-content: flex-end; } + &__debugger__container { + justify-content: flex-start; + } + &__debugger__button { color: $color-gray-25; display: block; diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index b7c7913170e6..815e5726cff6 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -36,6 +36,7 @@ export type PropsType = { onClose: () => unknown; onSend: ( listIds: Array, + conversationIds: Array, attachment: AttachmentType ) => unknown; processAttachment: ( @@ -104,7 +105,7 @@ export const StoryCreator = ({ me={me} onClose={() => setDraftAttachment(undefined)} onSend={listIds => { - onSend(listIds, draftAttachment); + onSend(listIds, [], draftAttachment); setDraftAttachment(undefined); onClose(); }} diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index b6dd92a091a2..4d77b1e0bc81 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -29,7 +29,7 @@ import { } from '../../util/getSendOptions'; import { handleMessageSend } from '../../util/handleMessageSend'; import { handleMultipleSendErrors } from './handleMultipleSendErrors'; -import { isMe } from '../../util/whatTypeOfConversation'; +import { isGroupV2, isMe } from '../../util/whatTypeOfConversation'; import { isNotNil } from '../../util/isNotNil'; import { isSent } from '../../messages/MessageSendState'; import { ourProfileKeyService } from '../../services/ourProfileKey'; @@ -66,7 +66,7 @@ export async function sendStory( const message = await getMessageById(messageId); if (!message) { log.info( - `stories.sendStory: message ${messageId} was not found, maybe because it was deleted. Giving up on sending it` + `stories.sendStory(${messageId}): message was not found, maybe because it was deleted. Giving up on sending it` ); return; } @@ -76,7 +76,7 @@ export async function sendStory( if (!attachment) { log.info( - `stories.sendStory: message ${messageId} does not have any attachments to send. Giving up on sending it` + `stories.sendStory(${messageId}): message does not have any attachments to send. Giving up on sending it` ); return; } @@ -107,15 +107,16 @@ export async function sendStory( return; } - const accSendStateByConversationId = new Map(); const canReplyUuids = new Set(); const recipientsByUuid = new Map>(); + const sentConversationIds = new Map(); + const sentUuids = new Set(); // This function is used to keep track of all the recipients so once we're // done with our send we can build up the storyMessageRecipients object for // sending in the sync message. - function processStoryMessageRecipient( - listId: string, + function addDistributionListToUuidSent( + listId: string | undefined, uuid: string, canReply?: boolean ): void { @@ -125,46 +126,17 @@ export async function sendStory( const distributionListIds = recipientsByUuid.get(uuid) || new Set(); - recipientsByUuid.set(uuid, new Set([...distributionListIds, listId])); + if (listId) { + recipientsByUuid.set(uuid, new Set([...distributionListIds, listId])); + } else { + recipientsByUuid.set(uuid, distributionListIds); + } if (canReply) { canReplyUuids.add(uuid); } } - // Since some contacts will be duplicated across lists but we won't be sending - // duplicate messages we need to ensure that sendStateByConversationId is kept - // in sync across all messages. - async function maybeUpdateMessageSendState( - message: MessageModel - ): Promise { - const oldSendStateByConversationId = - message.get('sendStateByConversationId') || {}; - - const newSendStateByConversationId = Object.keys( - oldSendStateByConversationId - ).reduce((acc, conversationId) => { - const sendState = accSendStateByConversationId.get(conversationId); - if (sendState) { - return { - ...acc, - [conversationId]: sendState, - }; - } - - return acc; - }, {} as SendStateByConversationId); - - if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) { - return; - } - - message.set('sendStateByConversationId', newSendStateByConversationId); - await window.Signal.Data.saveMessage(message.attributes, { - ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), - }); - } - let isSyncMessageUpdate = false; // Send to all distribution lists @@ -173,7 +145,7 @@ export async function sendStory( const message = await getMessageById(messageId); if (!message) { log.info( - `stories.sendStory: message ${messageId} was not found, maybe because it was deleted. Giving up on sending it` + `stories.sendStory(${messageId}): message was not found, maybe because it was deleted. Giving up on sending it` ); return; } @@ -188,29 +160,26 @@ export async function sendStory( if (message.isErased() || message.get('deletedForEveryone')) { log.info( - `stories.sendStory: message ${messageId} was erased. Giving up on sending it` + `stories.sendStory(${messageId}): message was erased. Giving up on sending it` ); return; } const listId = message.get('storyDistributionListId'); + const receiverId = isGroupV2(messageConversation.attributes) + ? messageConversation.id + : listId; - if (!listId) { + if (!receiverId) { log.info( - `stories.sendStory: message ${messageId} does not have a storyDistributionListId. Giving up on sending it` + `stories.sendStory(${messageId}): did not get a valid recipient ID for message. Giving up on sending it` ); return; } - const distributionList = - await dataInterface.getStoryDistributionWithMembers(listId); - - if (!distributionList) { - log.info( - `stories.sendStory: Distribution list ${listId} was not found. Giving up on sending message ${messageId}` - ); - return; - } + const distributionList = isGroupV2(messageConversation.attributes) + ? undefined + : await dataInterface.getStoryDistributionWithMembers(receiverId); let messageSendErrors: Array = []; @@ -230,7 +199,7 @@ export async function sendStory( if (!shouldContinue) { log.info( - `stories.sendStory: message ${messageId} ran out of time. Giving up on sending it` + `stories.sendStory(${messageId}): ran out of time. Giving up on sending it` ); await markMessageFailed(message, [ new Error('Message send ran out of time'), @@ -241,10 +210,10 @@ export async function sendStory( let originalError: Error | undefined; const { - allRecipientIdentifiers, + allRecipientIds, allowedReplyByUuid, - recipientIdentifiersWithoutMe, - sentRecipientIdentifiers, + pendingSendRecipientIds, + sentRecipientIds, untrustedUuids, } = getMessageRecipients({ log, @@ -260,39 +229,31 @@ export async function sendStory( } ); throw new Error( - `stories.sendStory: Message ${messageId} sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.` + `stories.sendStory(${messageId}): sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.` ); } - if ( - !allRecipientIdentifiers.length || - !recipientIdentifiersWithoutMe.length - ) { - log.info( - `stories.sendStory: trying to send message ${messageId} but it looks like it was already sent to everyone.` - ); - sentRecipientIdentifiers.forEach(uuid => - processStoryMessageRecipient( + if (!pendingSendRecipientIds.length) { + allRecipientIds.forEach(uuid => + addDistributionListToUuidSent( listId, uuid, allowedReplyByUuid.get(uuid) ) ); - await maybeUpdateMessageSendState(message); return; } const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - const recipientsSet = new Set(recipientIdentifiersWithoutMe); + const recipientsSet = new Set(pendingSendRecipientIds); const sendOptions = await getSendOptionsForRecipients( - recipientIdentifiersWithoutMe + pendingSendRecipientIds ); log.info( - 'stories.sendStory: sending story to distribution list', - listId + `stories.sendStory(${messageId}): sending story to ${receiverId}` ); const storyMessage = new Proto.StoryMessage(); @@ -300,7 +261,29 @@ export async function sendStory( storyMessage.fileAttachment = originalStoryMessage.fileAttachment; storyMessage.textAttachment = originalStoryMessage.textAttachment; storyMessage.group = originalStoryMessage.group; - storyMessage.allowsReplies = Boolean(distributionList.allowsReplies); + storyMessage.allowsReplies = + isGroupV2(messageConversation.attributes) || + Boolean(distributionList?.allowsReplies); + + const sendTarget = distributionList + ? { + getGroupId: () => undefined, + getMembers: () => + pendingSendRecipientIds + .map(uuid => window.ConversationController.get(uuid)) + .filter(isNotNil), + hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid), + idForLogging: () => `dl(${receiverId})`, + isGroupV2: () => true, + isValid: () => true, + getSenderKeyInfo: () => distributionList.senderKeyInfo, + saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => + dataInterface.modifyStoryDistribution({ + ...distributionList, + senderKeyInfo, + }), + } + : conversation.toSenderKeyTarget(); const contentMessage = new Proto.Content(); contentMessage.storyMessage = storyMessage; @@ -310,25 +293,9 @@ export async function sendStory( contentMessage, isPartialSend: false, messageId: undefined, - recipients: recipientIdentifiersWithoutMe, + recipients: pendingSendRecipientIds, sendOptions, - sendTarget: { - getGroupId: () => undefined, - getMembers: () => - recipientIdentifiersWithoutMe - .map(uuid => window.ConversationController.get(uuid)) - .filter(isNotNil), - hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid), - idForLogging: () => `dl(${listId})`, - isGroupV2: () => true, - isValid: () => true, - getSenderKeyInfo: () => distributionList.senderKeyInfo, - saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => - dataInterface.modifyStoryDistribution({ - ...distributionList, - senderKeyInfo, - }), - }, + sendTarget, sendType: 'story', timestamp, urgent: false, @@ -369,17 +336,31 @@ export async function sendStory( message.get('sendStateByConversationId') || {}; Object.entries(sendStateByConversationId).forEach( ([recipientConversationId, sendState]) => { - if (accSendStateByConversationId.has(recipientConversationId)) { + if (!isSent(sendState.status)) { return; } - accSendStateByConversationId.set( - recipientConversationId, - sendState + sentConversationIds.set(recipientConversationId, sendState); + + const recipient = window.ConversationController.get( + recipientConversationId ); + const uuid = recipient?.get('uuid'); + if (!uuid) { + return; + } + sentUuids.add(uuid); } ); + allRecipientIds.forEach(uuid => { + addDistributionListToUuidSent( + listId, + uuid, + allowedReplyByUuid.get(uuid) + ); + }); + const didFullySend = !messageSendErrors.length || didSendToEveryone(message); if (!didFullySend) { @@ -400,21 +381,59 @@ export async function sendStory( toThrow: originalError || thrownError, }); } finally { - recipientIdentifiersWithoutMe.forEach(uuid => - processStoryMessageRecipient( - listId, - uuid, - allowedReplyByUuid.get(uuid) - ) - ); - // Greater than 1 because our own conversation will always count as "sent" - isSyncMessageUpdate = sentRecipientIdentifiers.length > 1; - await maybeUpdateMessageSendState(message); + isSyncMessageUpdate = sentRecipientIds.length > 0; } }) ); - // Send the sync message + // Some contacts are duplicated across lists and we don't send duplicate + // messages but we still want to make sure that the sendStateByConversationId + // is kept in sync across all messages. + await Promise.all( + messageIds.map(async messageId => { + const message = await getMessageById(messageId); + if (!message) { + return; + } + + const oldSendStateByConversationId = + message.get('sendStateByConversationId') || {}; + + const newSendStateByConversationId = Object.keys( + oldSendStateByConversationId + ).reduce((acc, conversationId) => { + const sendState = sentConversationIds.get(conversationId); + if (sendState) { + return { + ...acc, + [conversationId]: sendState, + }; + } + + return acc; + }, {} as SendStateByConversationId); + + if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) { + return; + } + + message.set('sendStateByConversationId', newSendStateByConversationId); + return window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + }) + ); + + // Remove any unsent recipients + recipientsByUuid.forEach((_value, uuid) => { + if (sentUuids.has(uuid)) { + return; + } + + recipientsByUuid.delete(uuid); + }); + + // Build up the sync message's storyMessageRecipients and send it const storyMessageRecipients: Array<{ destinationUuid: string; distributionListIds: Array; @@ -452,24 +471,20 @@ function getMessageRecipients({ log: LoggerType; message: MessageModel; }>): { - allRecipientIdentifiers: Array; + allRecipientIds: Array; allowedReplyByUuid: Map; - recipientIdentifiersWithoutMe: Array; - sentRecipientIdentifiers: Array; + pendingSendRecipientIds: Array; + sentRecipientIds: Array; untrustedUuids: Array; } { - const allRecipientIdentifiers: Array = []; - const recipientIdentifiersWithoutMe: Array = []; - const untrustedUuids: Array = []; - const sentRecipientIdentifiers: Array = []; + const allRecipientIds: Array = []; const allowedReplyByUuid = new Map(); + const pendingSendRecipientIds: Array = []; + const sentRecipientIds: Array = []; + const untrustedUuids: Array = []; Object.entries(message.get('sendStateByConversationId') || {}).forEach( ([recipientConversationId, sendState]) => { - if (sendState.isAlreadyIncludedInAnotherDistributionList) { - return; - } - const recipient = window.ConversationController.get( recipientConversationId ); @@ -478,6 +493,9 @@ function getMessageRecipients({ } const isRecipientMe = isMe(recipient.attributes); + if (isRecipientMe) { + return; + } if (recipient.isUntrusted()) { const uuid = recipient.get('uuid'); @@ -494,33 +512,35 @@ function getMessageRecipients({ return; } - const recipientIdentifier = recipient.getSendTarget(); - if (!recipientIdentifier) { + const recipientSendTarget = recipient.getSendTarget(); + if (!recipientSendTarget) { return; } allowedReplyByUuid.set( - recipientIdentifier, + recipientSendTarget, Boolean(sendState.isAllowedToReplyToStory) ); + allRecipientIds.push(recipientSendTarget); - if (isSent(sendState.status)) { - sentRecipientIdentifiers.push(recipientIdentifier); + if (sendState.isAlreadyIncludedInAnotherDistributionList) { return; } - allRecipientIdentifiers.push(recipientIdentifier); - if (!isRecipientMe) { - recipientIdentifiersWithoutMe.push(recipientIdentifier); + if (isSent(sendState.status)) { + sentRecipientIds.push(recipientSendTarget); + return; } + + pendingSendRecipientIds.push(recipientSendTarget); } ); return { - allRecipientIdentifiers, + allRecipientIds, allowedReplyByUuid, - recipientIdentifiersWithoutMe, - sentRecipientIdentifiers, + pendingSendRecipientIds, + sentRecipientIds, untrustedUuids, }; } @@ -539,7 +559,9 @@ async function markMessageFailed( function didSendToEveryone(message: Readonly): boolean { const sendStateByConversationId = message.get('sendStateByConversationId') || {}; - return Object.values(sendStateByConversationId).every(sendState => - isSent(sendState.status) + return Object.values(sendStateByConversationId).every( + sendState => + sendState.isAlreadyIncludedInAnotherDistributionList || + isSent(sendState.status) ); } diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index c1d4d2ca3dd1..315a9a5b79ea 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -559,10 +559,11 @@ function replyToStory( function sendStoryMessage( listIds: Array, + conversationIds: Array, attachment: AttachmentType ): ThunkAction { return async dispatch => { - await doSendStoryMessage(listIds, attachment); + await doSendStoryMessage(listIds, conversationIds, attachment); dispatch({ type: 'NOOP', diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index bc99296872f9..f41ab2ded3f9 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -18,12 +18,15 @@ import { conversationQueueJobEnum, } from '../jobs/conversationJobQueue'; import { formatJobForInsert } from '../jobs/formatJobForInsert'; +import { getRecipients } from './getRecipients'; import { getSignalConnections } from './getSignalConnections'; import { incrementMessageCounter } from './incrementMessageCounter'; +import { isGroupV2 } from './whatTypeOfConversation'; import { isNotNil } from './isNotNil'; export async function sendStoryMessage( listIds: Array, + conversationIds: Array, attachment: AttachmentType ): Promise { const { messaging } = window.textsecure; @@ -41,9 +44,9 @@ export async function sendStoryMessage( ) ).filter(isNotNil); - if (!distributionLists.length) { - log.info( - 'stories.sendStoryMessage: no distribution lists found for', + if (!distributionLists.length && !conversationIds.length) { + log.warn( + 'stories.sendStoryMessage: Dropping send. no conversations to send to and no distribution lists found for', listIds ); return; @@ -127,43 +130,115 @@ export async function sendStoryMessage( // * Gather all the job data we'll be sending to the sendStory job // * Create the message for each distribution list - const messagesToSave: Array = await Promise.all( - distributionLists.map(async distributionList => { - const sendStateByConversationId = sendStateByListId.get( - distributionList.id - ); - - if (!sendStateByConversationId) { - log.warn( - 'stories.sendStoryMessage: No sendStateByConversationId for distribution list', + const distributionListMessages: Array = + await Promise.all( + distributionLists.map(async distributionList => { + const sendStateByConversationId = sendStateByListId.get( distributionList.id ); + + if (!sendStateByConversationId) { + log.warn( + 'stories.sendStoryMessage: No sendStateByConversationId for distribution list', + distributionList.id + ); + } + + return window.Signal.Migrations.upgradeMessageSchema({ + attachments, + conversationId: ourConversation.id, + expireTimer: DAY / SECOND, + id: UUID.generate().toString(), + readStatus: ReadStatus.Read, + received_at: incrementMessageCounter(), + received_at_ms: timestamp, + seenStatus: SeenStatus.NotApplicable, + sendStateByConversationId, + sent_at: timestamp, + source: window.textsecure.storage.user.getNumber(), + sourceUuid: window.textsecure.storage.user.getUuid()?.toString(), + storyDistributionListId: distributionList.id, + timestamp, + type: 'story', + }); + }) + ); + + const groupV2MessagesByConversationId = new Map< + string, + MessageAttributesType + >(); + + await Promise.all( + conversationIds.map(async conversationId => { + const group = window.ConversationController.get(conversationId); + + if (!group) { + log.warn( + 'stories.sendStoryMessage: No group found for id', + conversationId + ); + return; } - return window.Signal.Migrations.upgradeMessageSchema({ - attachments, - conversationId: ourConversation.id, - expireTimer: DAY / SECOND, - id: UUID.generate().toString(), - readStatus: ReadStatus.Read, - received_at: incrementMessageCounter(), - received_at_ms: timestamp, - seenStatus: SeenStatus.NotApplicable, - sendStateByConversationId, - sent_at: timestamp, - source: window.textsecure.storage.user.getNumber(), - sourceUuid: window.textsecure.storage.user.getUuid()?.toString(), - storyDistributionListId: distributionList.id, - timestamp, - type: 'story', - }); + if (!isGroupV2(group.attributes)) { + log.warn( + 'stories.sendStoryMessage: Conversation we tried to send to is not a groupV2', + conversationId + ); + return; + } + + const myId = window.ConversationController.getOurConversationIdOrThrow(); + const sendState = { + status: SendStatus.Pending, + updatedAt: timestamp, + }; + + const sendStateByConversationId = getRecipients(group.attributes).reduce( + (acc, id) => { + const conversation = window.ConversationController.get(id); + if (!conversation) { + return acc; + } + + return { + ...acc, + [conversation.id]: sendState, + }; + }, + { + [myId]: sendState, + } + ); + + const messageAttributes = + await window.Signal.Migrations.upgradeMessageSchema({ + attachments, + conversationId, + expireTimer: DAY / SECOND, + id: UUID.generate().toString(), + readStatus: ReadStatus.Read, + received_at: incrementMessageCounter(), + received_at_ms: timestamp, + seenStatus: SeenStatus.NotApplicable, + sendStateByConversationId, + sent_at: timestamp, + source: window.textsecure.storage.user.getNumber(), + sourceUuid: window.textsecure.storage.user.getUuid()?.toString(), + timestamp, + type: 'story', + }); + + groupV2MessagesByConversationId.set(conversationId, messageAttributes); }) ); + // For distribution lists: // * Save the message model // * Add the message to the conversation await Promise.all( - messagesToSave.map(messageAttributes => { + distributionListMessages.map(messageAttributes => { const model = new window.Whisper.Message(messageAttributes); const message = window.MessageController.register(model.id, model); @@ -177,13 +252,14 @@ export async function sendStoryMessage( }) ); + // * Send to the distribution lists // * Place into job queue // * Save the job await conversationJobQueue.add( { type: conversationQueueJobEnum.enum.Story, conversationId: ourConversation.id, - messageIds: messagesToSave.map(m => m.id), + messageIds: distributionListMessages.map(m => m.id), timestamp, }, async jobToInsert => { @@ -191,4 +267,45 @@ export async function sendStoryMessage( await dataInterface.insertJob(formatJobForInsert(jobToInsert)); } ); + + // * Send to groups + // * Save the message models + // * Add message to group conversation + await Promise.all( + conversationIds.map(conversationId => { + const messageAttributes = + groupV2MessagesByConversationId.get(conversationId); + + if (!messageAttributes) { + log.warn( + 'stories.sendStoryMessage: Trying to send a group story but it did not exist? This is unexpected. Not sending.', + conversationId + ); + return; + } + + return conversationJobQueue.add( + { + type: conversationQueueJobEnum.enum.Story, + conversationId, + messageIds: [messageAttributes.id], + timestamp, + }, + async jobToInsert => { + const model = new window.Whisper.Message(messageAttributes); + const message = window.MessageController.register(model.id, model); + + const conversation = message.getConversation(); + conversation?.addSingleMessage(model, { isJustSent: true }); + + log.info(`stories.sendStoryMessage: saving message ${message.id}`); + await dataInterface.saveMessage(message.attributes, { + forceSave: true, + jobToInsert, + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + } + ); + }) + ); }