Send stories to groups capability

This commit is contained in:
Josh Perez 2022-08-08 23:26:21 -04:00 committed by GitHub
parent 62962e4950
commit 2f5dd73e58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 308 additions and 163 deletions

View File

@ -10,6 +10,10 @@
justify-content: flex-end;
}
&__debugger__container {
justify-content: flex-start;
}
&__debugger__button {
color: $color-gray-25;
display: block;

View File

@ -36,6 +36,7 @@ export type PropsType = {
onClose: () => unknown;
onSend: (
listIds: Array<UUIDStringType>,
conversationIds: Array<string>,
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();
}}

View File

@ -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<string, SendState>();
const canReplyUuids = new Set<string>();
const recipientsByUuid = new Map<string, Set<string>>();
const sentConversationIds = new Map<string, SendState>();
const sentUuids = new Set<string>();
// 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<string>();
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<void> {
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<Error> = [];
@ -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<string>;
@ -452,24 +471,20 @@ function getMessageRecipients({
log: LoggerType;
message: MessageModel;
}>): {
allRecipientIdentifiers: Array<string>;
allRecipientIds: Array<string>;
allowedReplyByUuid: Map<string, boolean>;
recipientIdentifiersWithoutMe: Array<string>;
sentRecipientIdentifiers: Array<string>;
pendingSendRecipientIds: Array<string>;
sentRecipientIds: Array<string>;
untrustedUuids: Array<string>;
} {
const allRecipientIdentifiers: Array<string> = [];
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedUuids: Array<string> = [];
const sentRecipientIdentifiers: Array<string> = [];
const allRecipientIds: Array<string> = [];
const allowedReplyByUuid = new Map<string, boolean>();
const pendingSendRecipientIds: Array<string> = [];
const sentRecipientIds: Array<string> = [];
const untrustedUuids: Array<string> = [];
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<MessageModel>): 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)
);
}

View File

@ -559,10 +559,11 @@ function replyToStory(
function sendStoryMessage(
listIds: Array<UUIDStringType>,
conversationIds: Array<string>,
attachment: AttachmentType
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
await doSendStoryMessage(listIds, attachment);
await doSendStoryMessage(listIds, conversationIds, attachment);
dispatch({
type: 'NOOP',

View File

@ -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<string>,
conversationIds: Array<string>,
attachment: AttachmentType
): Promise<void> {
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<MessageAttributesType> = 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<MessageAttributesType> =
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(),
});
}
);
})
);
}