DOE for stories

This commit is contained in:
Josh Perez 2022-07-13 19:09:18 -04:00 committed by GitHub
parent d7307934bc
commit 5639c1adea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 381 additions and 15 deletions

View file

@ -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<void> {
'pniIdentity',
queuedEventListener(onPNIIdentitySync)
);
messageReceiver.addEventListener(
'storyRecipientUpdate',
queuedEventListener(onStoryRecipientUpdate, false)
);
});
ourProfileKeyService.initialize(window.storage);

View file

@ -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<DeleteModel> {
);
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<DeleteModel> {
targetMessage
);
await window.Signal.Util.deleteForEveryone(message, del);
await deleteForEveryone(message, del);
this.remove(del);
});

View file

@ -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 */

View file

@ -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<void, RootStateType, unknown, DOEStoryActionType> {
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<string>;
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<string>;
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,

View file

@ -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'

View file

@ -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<string>;
isAllowedToReply: boolean;
}>;
}>): Promise<CallbackResultType> {
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;

View file

@ -418,3 +418,18 @@ export class ViewSyncEvent extends ConfirmableEvent {
super('viewSync', confirm);
}
}
export type StoryRecipientUpdateData = Readonly<{
destinationUuid: string;
storyMessageRecipients: Array<Proto.SyncMessage.Sent.IStoryMessageRecipient>;
timestamp: number;
}>;
export class StoryRecipientUpdateEvent extends ConfirmableEvent {
constructor(
public readonly data: StoryRecipientUpdateData,
confirm: ConfirmCallback
) {
super('storyRecipientUpdate', confirm);
}
}

View file

@ -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<void> {
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<string, boolean>();
const conversationIdToDistributionListIds = new Map<string, Set<string>>();
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();
});
}