diff --git a/ts/background.ts b/ts/background.ts index 6ba99361a..f0c4be392 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -154,6 +154,7 @@ import { conversationJobQueue } from './jobs/conversationJobQueue'; import { SeenStatus } from './MessageSeenStatus'; import MessageSender from './textsecure/SendMessage'; import type AccountManager from './textsecure/AccountManager'; +import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate'; import { validateConversation } from './util/validateConversation'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -398,6 +399,10 @@ export async function startApp(): Promise { 'pniIdentity', queuedEventListener(onPNIIdentitySync) ); + messageReceiver.addEventListener( + 'storyRecipientUpdate', + queuedEventListener(onStoryRecipientUpdate, false) + ); }); ourProfileKeyService.initialize(window.storage); diff --git a/ts/messageModifiers/Deletes.ts b/ts/messageModifiers/Deletes.ts index c8e74fb6c..439c4e069 100644 --- a/ts/messageModifiers/Deletes.ts +++ b/ts/messageModifiers/Deletes.ts @@ -7,6 +7,7 @@ import { Collection, Model } from 'backbone'; import type { MessageModel } from '../models/messages'; import { getContactId } from '../messages/helpers'; import * as log from '../logging/log'; +import { deleteForEveryone } from '../util/deleteForEveryone'; export type DeleteAttributesType = { targetSentTimestamp: number; @@ -73,7 +74,7 @@ export class Deletes extends Collection { ); const targetMessage = messages.find( - m => del.get('fromId') === getContactId(m) + m => del.get('fromId') === getContactId(m) && !m.deletedForEveryone ); if (!targetMessage) { @@ -91,7 +92,7 @@ export class Deletes extends Collection { targetMessage ); - await window.Signal.Util.deleteForEveryone(message, del); + await deleteForEveryone(message, del); this.remove(del); }); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index a901db1f6..ee152e784 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -17,6 +17,7 @@ import { repeat, zipObject, } from '../util/iterables'; +import type { DeleteModel } from '../messageModifiers/Deletes'; import type { SentEventData } from '../textsecure/messageReceiverEvents'; import { isNotNil } from '../util/isNotNil'; import { isNormalNumber } from '../util/isNormalNumber'; @@ -160,7 +161,6 @@ import { isNewReactionReplacingPrevious } from '../reactions/util'; import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; import { GiftBadgeStates } from '../components/conversation/Message'; import { downloadAttachment } from '../util/downloadAttachment'; -import type { DeleteModel } from '../messageModifiers/Deletes'; import type { StickerWithHydratedData } from '../types/Stickers'; /* eslint-disable more/no-then */ diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index d7e0bacad..b0592bb60 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; -import { isEqual, pick } from 'lodash'; +import { isEqual, noop, pick } from 'lodash'; import type { AttachmentType } from '../../types/Attachment'; import type { BodyRangeType } from '../../types/Util'; import type { MessageAttributesType } from '../../model-types.d'; @@ -19,6 +19,7 @@ import dataInterface from '../../sql/Client'; import { DAY } from '../../util/durations'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories'; +import { StoryRecipientUpdateEvent } from '../../textsecure/messageReceiverEvents'; import { ToastReactionFailed } from '../../components/ToastReactionFailed'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { getMessageById } from '../../messages/getMessageById'; @@ -33,8 +34,10 @@ import { isDownloading, } from '../../types/Attachment'; import { getConversationSelector } from '../selectors/conversations'; +import { getSendOptions } from '../../util/getSendOptions'; import { getStories } from '../selectors/stories'; import { isGroup } from '../../util/whatTypeOfConversation'; +import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate'; import { useBoundActions } from '../../hooks/useBoundActions'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; @@ -154,7 +157,7 @@ export type StoriesActionType = function deleteStoryForEveryone( story: StoryViewType ): ThunkAction { - return (dispatch, getState) => { + return async (dispatch, getState) => { if (!story.sendState) { return; } @@ -162,25 +165,79 @@ function deleteStoryForEveryone( const conversationIds = new Set( story.sendState.map(({ recipient }) => recipient.id) ); + const updatedStoryRecipients = new Map< + string, + { + distributionListIds: Set; + isAllowedToReply: boolean; + } + >(); + + const ourConversation = + window.ConversationController.getOurConversationOrThrow(); + + // Remove ourselves from the DOE. + conversationIds.delete(ourConversation.id); // Find stories that were sent to other distribution lists so that we don't // send a DOE request to the members of those lists. const { stories } = getState().stories; stories.forEach(item => { - if (item.timestamp !== story.timestamp) { + const { sendStateByConversationId } = item; + // We only want matching timestamp stories which are stories that were + // sent to multi distribution lists. + // We don't want the story we just passed in. + // Don't need to check for stories that have already been deleted. + // And only for sent stories, not incoming. + if ( + item.timestamp !== story.timestamp || + item.messageId === story.messageId || + item.deletedForEveryone || + !sendStateByConversationId + ) { return; } - if (!item.sendStateByConversationId) { - return; - } + Object.keys(sendStateByConversationId).forEach(conversationId => { + if (conversationId === ourConversation.id) { + return; + } - Object.keys(item.sendStateByConversationId).forEach(conversationId => { + const destinationUuid = + window.ConversationController.get(conversationId)?.get('uuid'); + + if (!destinationUuid) { + return; + } + + const distributionListIds = + updatedStoryRecipients.get(destinationUuid)?.distributionListIds || + new Set(); + + // These are the remaining distribution list ids that the user has + // access to. + updatedStoryRecipients.set(destinationUuid, { + distributionListIds: item.storyDistributionListId + ? new Set([...distributionListIds, item.storyDistributionListId]) + : distributionListIds, + isAllowedToReply: + sendStateByConversationId[conversationId] + .isAllowedToReplyToStory !== false, + }); + + // Remove this conversationId so we don't send the DOE to those that + // still have access. conversationIds.delete(conversationId); }); }); + // Send the DOE conversationIds.forEach(cid => { + // Don't DOE yourself! + if (cid === ourConversation.id) { + return; + } + const conversation = window.ConversationController.get(cid); if (!conversation) { @@ -194,6 +251,81 @@ function deleteStoryForEveryone( }); }); + // If it's the last story sent to a distribution list we don't have to send + // the sync message, but to be consistent let's build up the updated + // storyMessageRecipients and send the sync message. + if (!updatedStoryRecipients.size) { + story.sendState.forEach(item => { + if (item.recipient.id === ourConversation.id) { + return; + } + + const destinationUuid = window.ConversationController.get( + item.recipient.id + )?.get('uuid'); + + if (!destinationUuid) { + return; + } + + updatedStoryRecipients.set(destinationUuid, { + distributionListIds: new Set(), + isAllowedToReply: item.isAllowedToReplyToStory !== false, + }); + }); + } + + // Send the sync message with the updated storyMessageRecipients list + const sender = window.textsecure.messaging; + if (sender) { + const options = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); + + const storyMessageRecipients: Array<{ + destinationUuid: string; + distributionListIds: Array; + isAllowedToReply: boolean; + }> = []; + + updatedStoryRecipients.forEach((recipientData, destinationUuid) => { + storyMessageRecipients.push({ + destinationUuid, + distributionListIds: Array.from(recipientData.distributionListIds), + isAllowedToReply: recipientData.isAllowedToReply, + }); + }); + + const destinationUuid = ourConversation.get('uuid'); + + if (!destinationUuid) { + return; + } + + // Sync message for other devices + sender.sendSyncMessage({ + destination: undefined, + destinationUuid, + storyMessageRecipients, + expirationStartTimestamp: null, + isUpdate: true, + options, + timestamp: story.timestamp, + urgent: false, + }); + + // Sync message for Desktop + const ev = new StoryRecipientUpdateEvent( + { + destinationUuid, + timestamp: story.timestamp, + storyMessageRecipients, + }, + noop + ); + onStoryRecipientUpdate(ev); + } + dispatch({ type: DOE_STORY, payload: story.messageId, diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index d54cb42b6..89577dca3 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -109,6 +109,7 @@ import { ContactSyncEvent, GroupEvent, GroupSyncEvent, + StoryRecipientUpdateEvent, } from './messageReceiverEvents'; import * as log from '../logging/log'; import * as durations from '../util/durations'; @@ -579,6 +580,11 @@ export default class MessageReceiver handler: (ev: EnvelopeEvent) => void ): void; + public override addEventListener( + name: 'storyRecipientUpdate', + handler: (ev: StoryRecipientUpdateEvent) => void + ): void; + public override addEventListener(name: string, handler: EventHandler): void { return super.addEventListener(name, handler); } @@ -1821,7 +1827,8 @@ export default class MessageReceiver const ev = new SentEvent( { destination: dropNull(destination), - destinationUuid: dropNull(destinationUuid), + destinationUuid: + dropNull(destinationUuid) || envelope.destinationUuid.toString(), timestamp: timestamp?.toNumber(), serverTimestamp: envelope.serverTimestamp, device: envelope.sourceDevice, @@ -1931,7 +1938,7 @@ export default class MessageReceiver isAllowedToReply.set( destinationUuid, - Boolean(recipient.isAllowedToReply) + recipient.isAllowedToReply !== false ); }); @@ -2572,6 +2579,18 @@ export default class MessageReceiver return; } + if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) { + const ev = new StoryRecipientUpdateEvent( + { + destinationUuid: envelope.destinationUuid.toString(), + timestamp: envelope.timestamp, + storyMessageRecipients: sentMessage.storyMessageRecipients, + }, + this.removeFromCache.bind(this, envelope) + ); + return this.dispatchAndWait(ev); + } + if (!sentMessage || !sentMessage.message) { throw new Error( 'MessageReceiver.handleSyncMessage: sync sent message was missing message' diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index b84a5e312..e3a5f6cbe 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1232,8 +1232,9 @@ export default class MessageSender { isUpdate, urgent, options, + storyMessageRecipients, }: Readonly<{ - encodedDataMessage: Uint8Array; + encodedDataMessage?: Uint8Array; timestamp: number; destination: string | undefined; destinationUuid: string | null | undefined; @@ -1243,13 +1244,21 @@ export default class MessageSender { isUpdate?: boolean; urgent: boolean; options?: SendOptionsType; + storyMessageRecipients?: Array<{ + destinationUuid: string; + distributionListIds: Array; + isAllowedToReply: boolean; + }>; }>): Promise { const myUuid = window.textsecure.storage.user.getCheckedUuid(); - const dataMessage = Proto.DataMessage.decode(encodedDataMessage); const sentMessage = new Proto.SyncMessage.Sent(); sentMessage.timestamp = Long.fromNumber(timestamp); - sentMessage.message = dataMessage; + + if (encodedDataMessage) { + const dataMessage = Proto.DataMessage.decode(encodedDataMessage); + sentMessage.message = dataMessage; + } if (destination) { sentMessage.destination = destination; } @@ -1261,6 +1270,19 @@ export default class MessageSender { expirationStartTimestamp ); } + if (storyMessageRecipients) { + sentMessage.storyMessageRecipients = storyMessageRecipients.map( + recipient => { + const storyMessageRecipient = + new Proto.SyncMessage.Sent.StoryMessageRecipient(); + storyMessageRecipient.destinationUuid = recipient.destinationUuid; + storyMessageRecipient.distributionListIds = + recipient.distributionListIds; + storyMessageRecipient.isAllowedToReply = recipient.isAllowedToReply; + return storyMessageRecipient; + } + ); + } if (isUpdate) { sentMessage.isRecipientUpdate = true; diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 35e793ea6..70ed3966c 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -418,3 +418,18 @@ export class ViewSyncEvent extends ConfirmableEvent { super('viewSync', confirm); } } + +export type StoryRecipientUpdateData = Readonly<{ + destinationUuid: string; + storyMessageRecipients: Array; + timestamp: number; +}>; + +export class StoryRecipientUpdateEvent extends ConfirmableEvent { + constructor( + public readonly data: StoryRecipientUpdateData, + confirm: ConfirmCallback + ) { + super('storyRecipientUpdate', confirm); + } +} diff --git a/ts/util/onStoryRecipientUpdate.ts b/ts/util/onStoryRecipientUpdate.ts new file mode 100644 index 000000000..0bf75f3a2 --- /dev/null +++ b/ts/util/onStoryRecipientUpdate.ts @@ -0,0 +1,172 @@ +// 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 { Deletes } from '../messageModifiers/Deletes'; +import { SendStatus } from '../messages/MessageSendState'; +import { deleteForEveryone } from './deleteForEveryone'; +import { + getConversationIdForLogging, + getMessageIdForLogging, +} from './idForLogging'; +import { isStory } from '../state/selectors/message'; +import { queueUpdateMessage } from './messageBatcher'; + +export async function onStoryRecipientUpdate( + event: StoryRecipientUpdateEvent +): Promise { + const { data, confirm } = event; + + const { destinationUuid, timestamp } = data; + + const conversation = window.ConversationController.get(destinationUuid); + + if (!conversation) { + return; + } + + const targetConversation = + await window.ConversationController.getConversationForTargetMessage( + conversation.id, + timestamp + ); + + if (!targetConversation) { + log.info('onStoryRecipientUpdate !targetConversation', { + destinationUuid, + timestamp, + }); + + return; + } + + targetConversation.queueJob('onStoryRecipientUpdate', async () => { + log.info('onStoryRecipientUpdate updating', timestamp); + + // Build up some maps for fast/easy lookups + const isAllowedToReply = new Map(); + const conversationIdToDistributionListIds = new Map>(); + data.storyMessageRecipients.forEach(item => { + const convo = window.ConversationController.get(item.destinationUuid); + + if (!convo || !item.distributionListIds) { + return; + } + + conversationIdToDistributionListIds.set( + convo.id, + new Set(item.distributionListIds) + ); + 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 + messages.forEach(item => { + if (!isStory(item)) { + return; + } + + const { sendStateByConversationId, storyDistributionListId } = item; + + if (!sendStateByConversationId || !storyDistributionListId) { + return; + } + + const nextSendStateByConversationId = { + ...sendStateByConversationId, + }; + + conversationIdToDistributionListIds.forEach( + (distributionListIds, conversationId) => { + const hasDistributionListId = distributionListIds.has( + storyDistributionListId + ); + + const recipient = window.ConversationController.get(conversationId); + const conversationIdForLogging = recipient + ? getConversationIdForLogging(recipient.attributes) + : conversationId; + + if ( + hasDistributionListId && + !sendStateByConversationId[conversationId] + ) { + log.info('onStoryRecipientUpdate adding', { + conversationId: conversationIdForLogging, + messageId: getMessageIdForLogging(item), + storyDistributionListId, + }); + nextSendStateByConversationId[conversationId] = { + isAllowedToReplyToStory: Boolean( + isAllowedToReply.get(conversationId) + ), + status: SendStatus.Sent, + updatedAt: now, + }; + } else if ( + sendStateByConversationId[conversationId] && + !hasDistributionListId + ) { + log.info('onStoryRecipientUpdate removing', { + conversationId: conversationIdForLogging, + messageId: getMessageIdForLogging(item), + storyDistributionListId, + }); + delete nextSendStateByConversationId[conversationId]; + } + } + ); + + if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) { + log.info( + 'onStoryRecipientUpdate: sendStateByConversationId does not need update' + ); + return; + } + + 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('onStoryRecipientUpdate DOE', { + messageId: getMessageIdForLogging(item), + storyDistributionListId, + }); + const delAttributes: DeleteAttributesType = { + fromId: ourConversationId, + serverTimestamp: Number(item.serverTimestamp), + targetSentTimestamp: item.timestamp, + }; + const doe = Deletes.getSingleton().add(delAttributes); + // There are no longer any remaining members for this message so lets + // run it through deleteForEveryone which marks the message as + // deletedForEveryone locally. + deleteForEveryone(message, doe); + } else { + message.set({ + sendStateByConversationId: nextSendStateByConversationId, + }); + queueUpdateMessage(message.attributes); + } + }); + + window.Whisper.events.trigger('incrementProgress'); + confirm(); + }); +}