diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 41db0e642..555c2445e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7365,6 +7365,14 @@ "message": "Story settings", "description": "Button label to get to story settings" }, + "SendStoryModal__title": { + "message": "Send to", + "description": "Title for the send story modal" + }, + "SendStoryModal__send": { + "message": "Send story", + "description": "aria-label for the send story button" + }, "Stories__settings-toggle--title": { "message": "Share & View Stories", "description": "Select box title for the stories on/off toggle" @@ -7517,6 +7525,14 @@ "message": "Condensed", "description": "Label for font" }, + "StoryCreator__control--text": { + "message": "Add story text", + "description": "aria-label for edit text button" + }, + "StoryCreator__control--link": { + "message": "Add a link", + "description": "aria-label for adding a link preview" + }, "StoryCreator__link-preview-placeholder": { "message": "Type or paste a URL", "description": "Placeholder for the URL input for link previews" diff --git a/stylesheets/components/SendStoryModal.scss b/stylesheets/components/SendStoryModal.scss new file mode 100644 index 000000000..4a4c3bf67 --- /dev/null +++ b/stylesheets/components/SendStoryModal.scss @@ -0,0 +1,67 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.SendStoryModal { + &__distribution-list { + &__container { + justify-content: space-between; + margin: 8px 0; + user-select: none; + width: 100%; + } + + &__info { + margin-left: 8px; + } + + &__label { + align-items: center; + display: flex; + justify-content: flex-start; + flex: 1; + } + + &__name { + @include font-body-1-bold; + } + + &__description { + @include font-body-2; + color: $color-gray-60; + } + } + + &__button-footer { + align-items: center; + justify-content: space-between; + } + + &__selected-lists { + @include font-body-2; + color: $color-gray-60; + max-width: 280px; + user-select: none; + } + + &__send { + @include button-reset; + @include rounded-corners; + align-items: center; + background: $color-ultramarine; + display: flex; + height: 40px; + justify-content: center; + width: 40px; + + &::disabled { + background: $color-gray-60; + } + + &::after { + @include color-svg('../images/icons/v2/send-24.svg', $color-white); + content: ''; + height: 24px; + width: 24px; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 1e831630a..4fcc7b2e1 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -103,6 +103,7 @@ @import './components/SearchResultsLoadingFakeHeader.scss'; @import './components/SearchResultsLoadingFakeRow.scss'; @import './components/Select.scss'; +@import './components/SendStoryModal.scss'; @import './components/SignalConnectionsModal.scss'; @import './components/Slider.scss'; @import './components/StagedLinkPreview.scss'; diff --git a/ts/components/Checkbox.tsx b/ts/components/Checkbox.tsx index f4b941cbe..9059d3fa6 100644 --- a/ts/components/Checkbox.tsx +++ b/ts/components/Checkbox.tsx @@ -8,6 +8,11 @@ import { getClassNamesFor } from '../util/getClassNamesFor'; export type PropsType = { checked?: boolean; + children?: (childrenOpts: { + id: string; + checkboxNode: JSX.Element; + labelNode: JSX.Element; + }) => JSX.Element; description?: string; disabled?: boolean; isRadio?: boolean; @@ -20,6 +25,7 @@ export type PropsType = { export const Checkbox = ({ checked, + children, description, disabled, isRadio, @@ -31,26 +37,41 @@ export const Checkbox = ({ }: PropsType): JSX.Element => { const getClassName = getClassNamesFor('Checkbox', moduleClassName); const id = useMemo(() => `${name}::${uuid()}`, [name]); + + const checkboxNode = ( +
+ onChange(ev.target.checked)} + onClick={onClick} + type={isRadio ? 'radio' : 'checkbox'} + /> +
+ ); + + const labelNode = ( +
+ +
+ ); + return (
-
- onChange(ev.target.checked)} - onClick={onClick} - type={isRadio ? 'radio' : 'checkbox'} - /> -
-
- -
+ {children ? ( + children({ id, checkboxNode, labelNode }) + ) : ( + <> + {checkboxNode} + {labelNode} + + )}
); diff --git a/ts/components/SendStoryModal.stories.tsx b/ts/components/SendStoryModal.stories.tsx new file mode 100644 index 000000000..d7acaf615 --- /dev/null +++ b/ts/components/SendStoryModal.stories.tsx @@ -0,0 +1,45 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Meta, Story } from '@storybook/react'; +import React from 'react'; + +import type { PropsType } from './SendStoryModal'; +import enMessages from '../../_locales/en/messages.json'; +import { SendStoryModal } from './SendStoryModal'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { setupI18n } from '../util/setupI18n'; +import { + getMyStories, + getFakeDistributionLists, +} from '../test-both/helpers/getFakeDistributionLists'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/SendStoryModal', + component: SendStoryModal, + argTypes: { + distributionLists: { + defaultValue: [getMyStories()], + }, + i18n: { + defaultValue: i18n, + }, + me: { + defaultValue: getDefaultConversation(), + }, + onClose: { action: true }, + onSend: { action: true }, + signalConnections: { + defaultValue: Array.from(Array(42), getDefaultConversation), + }, + }, +} as Meta; + +const Template: Story = args => ; + +export const Modal = Template.bind({}); +Modal.args = { + distributionLists: getFakeDistributionLists(), +}; diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx new file mode 100644 index 000000000..3e28f9b6f --- /dev/null +++ b/ts/components/SendStoryModal.tsx @@ -0,0 +1,153 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useMemo, useState } from 'react'; + +import type { ConversationType } from '../state/ducks/conversations'; +import type { LocalizerType } from '../types/Util'; +import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; +import type { UUIDStringType } from '../types/UUID'; +import { Avatar, AvatarSize } from './Avatar'; +import { Checkbox } from './Checkbox'; +import { MY_STORIES_ID, getStoryDistributionListName } from '../types/Stories'; +import { Modal } from './Modal'; +import { StoryDistributionListName } from './StoryDistributionListName'; + +export type PropsType = { + distributionLists: Array; + i18n: LocalizerType; + me: ConversationType; + onClose: () => unknown; + onSend: (listIds: Array) => unknown; + signalConnections: Array; +}; + +function getListViewers( + list: StoryDistributionListDataType, + i18n: LocalizerType, + signalConnections: Array +): string { + let memberCount = list.memberUuids.length; + + if (list.id === MY_STORIES_ID && list.isBlockList) { + memberCount = list.isBlockList + ? signalConnections.length - list.memberUuids.length + : signalConnections.length; + } + + return memberCount === 1 + ? i18n('StoriesSettingsModal__list__viewers--singular', ['1']) + : i18n('StoriesSettings__viewers--plural', [String(memberCount)]); +} + +export const SendStoryModal = ({ + distributionLists, + i18n, + me, + onClose, + onSend, + signalConnections, +}: PropsType): JSX.Element => { + const [selectedListIds, setSelectedListIds] = useState>( + new Set() + ); + const selectedListNames = useMemo( + () => + distributionLists + .filter(list => selectedListIds.has(list.id)) + .map(list => list.name), + [distributionLists, selectedListIds] + ); + + return ( + + {distributionLists.map(list => ( + { + if (value) { + setSelectedListIds(listIds => { + listIds.add(list.id); + return new Set([...listIds]); + }); + } else { + setSelectedListIds(listIds => { + listIds.delete(list.id); + return new Set([...listIds]); + }); + } + }} + > + {({ id, checkboxNode }) => ( + <> + + {checkboxNode} + + )} + + ))} + + +
+ {selectedListNames + .map(listName => + getStoryDistributionListName(i18n, listName, listName) + ) + .join(', ')} +
+ -
-
- )} - +
+ - - ) : ( -
-
- {i18n('StoryCreator__link-preview-empty')} -
+ type="button" + style={{ + background: getBackgroundColor( + getBackground(backgroundValue) + ), + }} + /> + ) )}
-
- )} + )} + + + ) : ( +
+
+ {i18n('StoryCreator__link-preview-empty')} +
+ )} +
+ + )} + + - - - + + ); }; diff --git a/ts/jobs/conversationJobQueue.ts b/ts/jobs/conversationJobQueue.ts index fff320a0a..e43091e4a 100644 --- a/ts/jobs/conversationJobQueue.ts +++ b/ts/jobs/conversationJobQueue.ts @@ -18,6 +18,7 @@ import { sendGroupUpdate } from './helpers/sendGroupUpdate'; import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone'; import { sendProfileKey } from './helpers/sendProfileKey'; import { sendReaction } from './helpers/sendReaction'; +import { sendStory } from './helpers/sendStory'; import type { LoggerType } from '../types/Logging'; import { ConversationVerificationState } from '../state/ducks/conversationsEnums'; @@ -44,6 +45,7 @@ export const conversationQueueJobEnum = z.enum([ 'NormalMessage', 'ProfileKey', 'Reaction', + 'Story', ]); const deleteForEveryoneJobDataSchema = z.object({ @@ -105,6 +107,17 @@ const reactionJobDataSchema = z.object({ }); export type ReactionJobData = z.infer; +const storyJobDataSchema = z.object({ + type: z.literal(conversationQueueJobEnum.enum.Story), + conversationId: z.string(), + // Note: recipients are baked into the message itself + messageIds: z.string().array(), + textAttachment: z.any(), // TODO TextAttachmentType + timestamp: z.number(), + revision: z.number().optional(), +}); +export type StoryJobData = z.infer; + export const conversationQueueJobDataSchema = z.union([ deleteForEveryoneJobDataSchema, expirationTimerUpdateJobDataSchema, @@ -112,6 +125,7 @@ export const conversationQueueJobDataSchema = z.union([ normalMessageSendJobDataSchema, profileKeyJobDataSchema, reactionJobDataSchema, + storyJobDataSchema, ]); export type ConversationQueueJobData = z.infer< typeof conversationQueueJobDataSchema @@ -332,6 +346,9 @@ export class ConversationJobQueue extends JobQueue { case jobSet.Reaction: await sendReaction(conversation, jobBundle, data); break; + case jobSet.Story: + await sendStory(conversation, jobBundle, data); + break; default: { // Note: This should never happen, because the zod call in parseData wouldn't // accept data that doesn't look like our type specification. diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts new file mode 100644 index 000000000..1e9b1013d --- /dev/null +++ b/ts/jobs/helpers/sendStory.ts @@ -0,0 +1,498 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isEqual } from 'lodash'; +import type { ConversationModel } from '../../models/conversations'; +import type { + ConversationQueueJobBundle, + StoryJobData, +} from '../conversationJobQueue'; +import type { LoggerType } from '../../types/Logging'; +import type { MessageModel } from '../../models/messages'; +import type { SenderKeyInfoType } from '../../model-types.d'; +import type { + SendState, + SendStateByConversationId, +} from '../../messages/MessageSendState'; +import type { UUIDStringType } from '../../types/UUID'; +import * as Errors from '../../types/errors'; +import dataInterface from '../../sql/Client'; +import { SignalService as Proto } from '../../protobuf'; +import { getMessageById } from '../../messages/getMessageById'; +import { + getSendOptions, + getSendOptionsForRecipients, +} from '../../util/getSendOptions'; +import { handleMessageSend } from '../../util/handleMessageSend'; +import { handleMultipleSendErrors } from './handleMultipleSendErrors'; +import { isMe } from '../../util/whatTypeOfConversation'; +import { isNotNil } from '../../util/isNotNil'; +import { isSent } from '../../messages/MessageSendState'; +import { ourProfileKeyService } from '../../services/ourProfileKey'; +import { sendContentMessageToGroup } from '../../util/sendToGroup'; + +export async function sendStory( + conversation: ConversationModel, + { + isFinalAttempt, + messaging, + shouldContinue, + timeRemaining, + log, + }: ConversationQueueJobBundle, + data: StoryJobData +): Promise { + const { messageIds, textAttachment, timestamp } = data; + + const profileKey = await ourProfileKeyService.get(); + + if (!profileKey) { + log.info('stories.sendStory: no profile key cannot send'); + return; + } + + // Some distribution lists need allowsReplies false, some need it set to true + // we create this proto (for the sync message) and also to re-use some of the + // attributes inside it. + const originalStoryMessage = await messaging.getStoryMessage({ + allowsReplies: true, + textAttachment, + profileKey, + }); + + const accSendStateByConversationId = new Map(); + const canReplyUuids = new Set(); + const recipientsByUuid = new Map>(); + + // 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, + uuid: string, + canReply?: boolean + ): void { + if (conversation.get('uuid') === uuid) { + return; + } + + const distributionListIds = recipientsByUuid.get(uuid) || new Set(); + + recipientsByUuid.set(uuid, new Set([...distributionListIds, listId])); + + 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 + await Promise.all( + messageIds.map(async messageId => { + 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` + ); + return; + } + + const messageConversation = message.getConversation(); + if (messageConversation !== conversation) { + log.error( + `stories.sendStory: Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}` + ); + return; + } + + if (message.isErased() || message.get('deletedForEveryone')) { + log.info( + `stories.sendStory: message ${messageId} was erased. Giving up on sending it` + ); + return; + } + + const listId = message.get('storyDistributionListId'); + + if (!listId) { + log.info( + `stories.sendStory: message ${messageId} does not have a storyDistributionListId. 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; + } + + let messageSendErrors: Array = []; + + // We don't want to save errors on messages unless we're giving up. If it's our + // final attempt, we know upfront that we want to give up. However, we might also + // want to give up if (1) we get a 508 from the server, asking us to please stop + // (2) we get a 428 from the server, flagging the message for spam (3) some other + // reason not known at the time of this writing. + // + // This awkward callback lets us hold onto errors we might want to save, so we can + // decide whether to save them later on. + const saveErrors = isFinalAttempt + ? undefined + : (errors: Array) => { + messageSendErrors = errors; + }; + + if (!shouldContinue) { + log.info( + `stories.sendStory: message ${messageId} ran out of time. Giving up on sending it` + ); + await markMessageFailed(message, [ + new Error('Message send ran out of time'), + ]); + return; + } + + let originalError: Error | undefined; + + const { + allRecipientIdentifiers, + allowedReplyByUuid, + recipientIdentifiersWithoutMe, + sentRecipientIdentifiers, + untrustedUuids, + } = getMessageRecipients({ + log, + message, + }); + + try { + if (untrustedUuids.length) { + window.reduxActions.conversations.conversationStoppedByMissingVerification( + { + conversationId: conversation.id, + untrustedUuids, + } + ); + throw new Error( + `stories.sendStory: Message ${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( + listId, + uuid, + allowedReplyByUuid.get(uuid) + ) + ); + await maybeUpdateMessageSendState(message); + return; + } + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + const recipientsSet = new Set(recipientIdentifiersWithoutMe); + + const sendOptions = await getSendOptionsForRecipients( + recipientIdentifiersWithoutMe + ); + + log.info( + 'stories.sendStory: sending story to distribution list', + listId + ); + + const storyMessage = new Proto.StoryMessage(); + storyMessage.profileKey = originalStoryMessage.profileKey; + storyMessage.fileAttachment = originalStoryMessage.fileAttachment; + storyMessage.textAttachment = originalStoryMessage.textAttachment; + storyMessage.group = originalStoryMessage.group; + storyMessage.allowsReplies = Boolean(distributionList.allowsReplies); + + const contentMessage = new Proto.Content(); + contentMessage.storyMessage = storyMessage; + + const innerPromise = sendContentMessageToGroup({ + contentHint: ContentHint.IMPLICIT, + contentMessage, + isPartialSend: false, + messageId: undefined, + recipients: recipientIdentifiersWithoutMe, + 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, + }), + }, + sendType: 'story', + timestamp, + urgent: false, + }); + + message.doNotSendSyncMessage = true; + + const messageSendPromise = message.send( + handleMessageSend(innerPromise, { + messageIds: [messageId], + sendType: 'story', + }), + saveErrors + ); + + // Because message.send swallows and processes errors, we'll await the + // inner promise to get the SendMessageProtoError, which gives us + // information upstream processors need to detect certain kinds of situations. + try { + await innerPromise; + } catch (error) { + if (error instanceof Error) { + originalError = error; + } else { + log.error( + `promiseForError threw something other than an error: ${Errors.toLogFormat( + error + )}` + ); + } + } + + await messageSendPromise; + + // Track sendState across message sends so that we can update all + // subsequent messages. + const sendStateByConversationId = + message.get('sendStateByConversationId') || {}; + Object.entries(sendStateByConversationId).forEach( + ([recipientConversationId, sendState]) => { + if (accSendStateByConversationId.has(recipientConversationId)) { + return; + } + + accSendStateByConversationId.set( + recipientConversationId, + sendState + ); + } + ); + + const didFullySend = + !messageSendErrors.length || didSendToEveryone(message); + if (!didFullySend) { + throw new Error('message did not fully send'); + } + } catch (thrownError: unknown) { + const errors = [thrownError, ...messageSendErrors]; + await handleMultipleSendErrors({ + errors, + isFinalAttempt, + log, + markFailed: () => markMessageFailed(message, messageSendErrors), + timeRemaining, + // In the case of a failed group send thrownError will not be + // SentMessageProtoError, but we should have been able to harvest + // the original error. In the Note to Self send case, thrownError + // will be the error we care about, and we won't have an originalError. + 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); + } + }) + ); + + // Send the sync message + const storyMessageRecipients: Array<{ + destinationUuid: string; + distributionListIds: Array; + isAllowedToReply: boolean; + }> = []; + recipientsByUuid.forEach((distributionListIds, destinationUuid) => { + storyMessageRecipients.push({ + destinationUuid, + distributionListIds: Array.from(distributionListIds), + isAllowedToReply: canReplyUuids.has(destinationUuid), + }); + }); + + const options = await getSendOptions(conversation.attributes, { + syncMessage: true, + }); + + messaging.sendSyncMessage({ + destination: conversation.get('e164'), + destinationUuid: conversation.get('uuid'), + storyMessage: originalStoryMessage, + storyMessageRecipients, + expirationStartTimestamp: null, + isUpdate: isSyncMessageUpdate, + options, + timestamp, + urgent: false, + }); +} + +function getMessageRecipients({ + log, + message, +}: Readonly<{ + log: LoggerType; + message: MessageModel; +}>): { + allRecipientIdentifiers: Array; + allowedReplyByUuid: Map; + recipientIdentifiersWithoutMe: Array; + sentRecipientIdentifiers: Array; + untrustedUuids: Array; +} { + const allRecipientIdentifiers: Array = []; + const recipientIdentifiersWithoutMe: Array = []; + const untrustedUuids: Array = []; + const sentRecipientIdentifiers: Array = []; + const allowedReplyByUuid = new Map(); + + Object.entries(message.get('sendStateByConversationId') || {}).forEach( + ([recipientConversationId, sendState]) => { + if (sendState.isAlreadyIncludedInAnotherDistributionList) { + return; + } + + const recipient = window.ConversationController.get( + recipientConversationId + ); + if (!recipient) { + return; + } + + const isRecipientMe = isMe(recipient.attributes); + + if (recipient.isUntrusted()) { + const uuid = recipient.get('uuid'); + if (!uuid) { + log.error( + `stories.sendStory/getMessageRecipients: Untrusted conversation ${recipient.idForLogging()} missing UUID.` + ); + return; + } + untrustedUuids.push(uuid); + return; + } + if (recipient.isUnregistered()) { + return; + } + + const recipientIdentifier = recipient.getSendTarget(); + if (!recipientIdentifier) { + return; + } + + allowedReplyByUuid.set( + recipientIdentifier, + Boolean(sendState.isAllowedToReplyToStory) + ); + + if (isSent(sendState.status)) { + sentRecipientIdentifiers.push(recipientIdentifier); + return; + } + + allRecipientIdentifiers.push(recipientIdentifier); + if (!isRecipientMe) { + recipientIdentifiersWithoutMe.push(recipientIdentifier); + } + } + ); + + return { + allRecipientIdentifiers, + allowedReplyByUuid, + recipientIdentifiersWithoutMe, + sentRecipientIdentifiers, + untrustedUuids, + }; +} + +async function markMessageFailed( + message: MessageModel, + errors: Array +): Promise { + message.markFailed(); + message.saveErrors(errors, { skipSave: true }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); +} + +function didSendToEveryone(message: Readonly): boolean { + const sendStateByConversationId = + message.get('sendStateByConversationId') || {}; + return Object.values(sendStateByConversationId).every(sendState => + isSent(sendState.status) + ); +} diff --git a/ts/messages/MessageSendState.ts b/ts/messages/MessageSendState.ts index 912a3d304..44f517b7e 100644 --- a/ts/messages/MessageSendState.ts +++ b/ts/messages/MessageSendState.ts @@ -69,6 +69,10 @@ export const isFailed = (status: SendStatus): boolean => * The timestamp may be undefined if reading old data, which did not store a timestamp. */ export type SendState = Readonly<{ + // When sending a story to multiple distribution lists at once, we need to + // de-duplicate the recipients. The story should only be sent once to each + // recipient in the list so the recipient only sees it rendered once. + isAlreadyIncludedInAnotherDistributionList?: boolean; isAllowedToReplyToStory?: boolean; status: | SendStatus.Pending diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 193957bd9..86494db47 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -194,6 +194,9 @@ export class MessageModel extends window.Backbone.Model { // Set when sending some sync messages, so we get the functionality of // send(), without zombie messages going into the database. doNotSave?: boolean; + // Set when sending stories, so we get the functionality of send() but we are + // able to send the sync message elsewhere. + doNotSendSyncMessage?: boolean; INITIAL_PROTOCOL_VERSION?: number; @@ -1575,7 +1578,7 @@ export class MessageModel extends window.Backbone.Model { updateLeftPane(); - if (sentToAtLeastOneRecipient) { + if (sentToAtLeastOneRecipient && !this.doNotSendSyncMessage) { promises.push(this.sendSyncMessage()); } diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index e69a683bb..f553e496a 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -3,17 +3,22 @@ import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { isEqual, noop, pick } from 'lodash'; -import type { AttachmentType } from '../../types/Attachment'; +import type { + AttachmentType, + TextAttachmentType, +} from '../../types/Attachment'; import type { BodyRangeType } from '../../types/Util'; import type { MessageAttributesType } from '../../model-types.d'; import type { MessageChangedActionType, MessageDeletedActionType, + MessagesAddedActionType, } from './conversations'; import type { NoopActionType } from './noop'; import type { StateType as RootStateType } from '../reducer'; import type { StoryViewType } from '../../types/Stories'; import type { SyncType } from '../../jobs/helpers/syncHelpers'; +import type { UUIDStringType } from '../../types/UUID'; import * as log from '../../logging/log'; import dataInterface from '../../sql/Client'; import { DAY } from '../../util/durations'; @@ -36,8 +41,12 @@ import { import { getConversationSelector } from '../selectors/conversations'; import { getSendOptions } from '../../util/getSendOptions'; import { getStories } from '../selectors/stories'; +import { getStoryDataFromMessageAttributes } from '../../services/storyLoader'; import { isGroup } from '../../util/whatTypeOfConversation'; +import { isNotNil } from '../../util/isNotNil'; +import { isStory } from '../../messages/helpers'; import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate'; +import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage'; import { useBoundActions } from '../../hooks/useBoundActions'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; @@ -147,6 +156,7 @@ export type StoriesActionType = | MarkStoryReadActionType | MessageChangedActionType | MessageDeletedActionType + | MessagesAddedActionType | ReplyToStoryActionType | ResolveAttachmentUrlActionType | StoryChangedActionType @@ -542,6 +552,20 @@ function replyToStory( }; } +function sendStoryMessage( + listIds: Array, + textAttachment: TextAttachmentType +): ThunkAction { + return async dispatch => { + await doSendStoryMessage(listIds, textAttachment); + + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + function storyChanged(story: StoryDataType): StoryChangedActionType { return { type: STORY_CHANGED, @@ -896,6 +920,7 @@ export const actions = { queueStoryDownload, reactToStory, replyToStory, + sendStoryMessage, storyChanged, toggleStoriesView, viewUserStories, @@ -1046,6 +1071,26 @@ export function reducer( }; } + if (action.type === 'MESSAGES_ADDED' && action.payload.isJustSent) { + const stories = action.payload.messages.filter(isStory); + if (!stories.length) { + return state; + } + + const newStories = stories + .map(messageAttrs => getStoryDataFromMessageAttributes(messageAttrs)) + .filter(isNotNil); + + if (!newStories.length) { + return state; + } + + return { + ...state, + stories: [...state.stories, ...newStories], + }; + } + // For live updating of the story replies if ( action.type === 'MESSAGE_CHANGED' && diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 4604628f6..caf2abf8a 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -37,6 +37,7 @@ import { ContactNameColors } from '../../types/Colors'; import type { AvatarDataType } from '../../types/Avatar'; import type { UUIDStringType } from '../../types/UUID'; import { isInSystemContacts } from '../../util/isInSystemContacts'; +import { isSignalConnection } from '../../util/getSignalConnections'; import { sortByTitle } from '../../util/sortByTitle'; import { isDirectConversation, @@ -127,6 +128,12 @@ export const getAllConversations = createSelector( (lookup): Array => Object.values(lookup) ); +export const getAllSignalConnections = createSelector( + getAllConversations, + (conversations): Array => + conversations.filter(isSignalConnection) +); + export const getConversationsByTitleSelector = createSelector( getAllConversations, (conversations): ((title: string) => Array) => diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index 8aeb0a23d..304d7e743 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -3,15 +3,17 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { noop } from 'lodash'; import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; import { LinkPreviewSourceType } from '../../types/LinkPreview'; import { StoryCreator } from '../../components/StoryCreator'; +import { getDistributionLists } from '../selectors/storyDistributionLists'; import { getIntl } from '../selectors/user'; import { getLinkPreview } from '../selectors/linkPreviews'; +import { getAllSignalConnections, getMe } from '../selectors/conversations'; import { useLinkPreviewActions } from '../ducks/linkPreviews'; +import { useStoriesActions } from '../ducks/stories'; export type PropsType = { onClose: () => unknown; @@ -19,17 +21,24 @@ export type PropsType = { export function SmartStoryCreator({ onClose }: PropsType): JSX.Element | null { const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions(); + const { sendStoryMessage } = useStoriesActions(); const i18n = useSelector(getIntl); const linkPreviewForSource = useSelector(getLinkPreview); + const distributionLists = useSelector(getDistributionLists); + const me = useSelector(getMe); + const signalConnections = useSelector(getAllSignalConnections); return ( ); } diff --git a/ts/test-both/helpers/getFakeDistributionLists.ts b/ts/test-both/helpers/getFakeDistributionLists.ts new file mode 100644 index 000000000..ce99effe5 --- /dev/null +++ b/ts/test-both/helpers/getFakeDistributionLists.ts @@ -0,0 +1,37 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import casual from 'casual'; + +import type { StoryDistributionListDataType } from '../../state/ducks/storyDistributionLists'; +import { MY_STORIES_ID } from '../../types/Stories'; +import { UUID } from '../../types/UUID'; + +export function getFakeDistributionLists(): Array { + return [ + getMyStories(), + ...Array.from(Array(casual.integer(2, 8)), getFakeDistributionList), + ]; +} + +export function getFakeDistributionList(): StoryDistributionListDataType { + return { + allowsReplies: Boolean(casual.coin_flip), + id: UUID.generate().toString(), + isBlockList: false, + memberUuids: Array.from(Array(casual.integer(3, 12)), () => + UUID.generate().toString() + ), + name: casual.title, + }; +} + +export function getMyStories(): StoryDistributionListDataType { + return { + allowsReplies: true, + id: MY_STORIES_ID, + isBlockList: true, + memberUuids: [], + name: MY_STORIES_ID, + }; +} diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index ca538d9ca..4fccb6ecf 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -114,7 +114,7 @@ import * as log from '../logging/log'; import * as durations from '../util/durations'; import { areArraysMatchingSets } from '../util/areArraysMatchingSets'; import { generateBlurHash } from '../util/generateBlurHash'; -import { APPLICATION_OCTET_STREAM } from '../types/MIME'; +import { TEXT_ATTACHMENT } from '../types/MIME'; import type { SendTypesType } from '../util/handleMessageSend'; const GROUPV1_ID_LENGTH = 16; @@ -1884,7 +1884,7 @@ export default class MessageReceiver // TODO DESKTOP-3714 we should download the story link preview image attachments.push({ size: text.length, - contentType: APPLICATION_OCTET_STREAM, + contentType: TEXT_ATTACHMENT, textAttachment: msg.textAttachment, blurHash: generateBlurHash( (msg.textAttachment.color || diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index ff3f9bc6c..b5db66263 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -835,6 +835,53 @@ export default class MessageSender { // Proto assembly + async getTextAttachmentProto( + attachmentAttrs: Attachment.TextAttachmentType + ): Promise { + const textAttachment = new Proto.TextAttachment(); + + if (attachmentAttrs.text) { + textAttachment.text = attachmentAttrs.text; + } + + textAttachment.textStyle = attachmentAttrs.textStyle + ? Number(attachmentAttrs.textStyle) + : 0; + + if (attachmentAttrs.textForegroundColor) { + textAttachment.textForegroundColor = attachmentAttrs.textForegroundColor; + } + + if (attachmentAttrs.textBackgroundColor) { + textAttachment.textBackgroundColor = attachmentAttrs.textBackgroundColor; + } + + if (attachmentAttrs.preview) { + const previewImage = attachmentAttrs.preview.image; + // This cast is OK because we're ensuring that previewImage.data is truthy + const image = + previewImage && previewImage.data + ? await this.makeAttachmentPointer(previewImage as AttachmentType) + : undefined; + + textAttachment.preview = { + image, + title: attachmentAttrs.preview.title, + url: attachmentAttrs.preview.url, + }; + } + + if (attachmentAttrs.gradient) { + textAttachment.gradient = attachmentAttrs.gradient; + textAttachment.background = 'gradient'; + } else { + textAttachment.color = attachmentAttrs.color; + textAttachment.background = 'color'; + } + + return textAttachment; + } + async getDataMessage( options: Readonly ): Promise { @@ -842,6 +889,60 @@ export default class MessageSender { return message.encode(); } + async getStoryMessage({ + allowsReplies, + fileAttachment, + groupV2, + profileKey, + textAttachment, + }: { + allowsReplies?: boolean; + fileAttachment?: AttachmentType; + groupV2?: GroupV2InfoType; + profileKey: Uint8Array; + textAttachment?: Attachment.TextAttachmentType; + }): Promise { + const storyMessage = new Proto.StoryMessage(); + storyMessage.profileKey = profileKey; + + if (fileAttachment) { + try { + const attachmentPointer = await this.makeAttachmentPointer( + fileAttachment + ); + storyMessage.fileAttachment = attachmentPointer; + } catch (error) { + if (error instanceof HTTPError) { + throw new MessageError(message, error); + } else { + throw error; + } + } + } + + if (textAttachment) { + storyMessage.textAttachment = await this.getTextAttachmentProto( + textAttachment + ); + } + + if (groupV2) { + const groupV2Context = new Proto.GroupContextV2(); + groupV2Context.masterKey = groupV2.masterKey; + groupV2Context.revision = groupV2.revision; + + if (groupV2.groupChange) { + groupV2Context.groupChange = groupV2.groupChange; + } + + storyMessage.group = groupV2Context; + } + + storyMessage.allowsReplies = Boolean(allowsReplies); + + return storyMessage; + } + async getContentMessage( options: Readonly ): Promise { @@ -1232,6 +1333,7 @@ export default class MessageSender { isUpdate, urgent, options, + storyMessage, storyMessageRecipients, }: Readonly<{ encodedDataMessage?: Uint8Array; @@ -1244,6 +1346,7 @@ export default class MessageSender { isUpdate?: boolean; urgent: boolean; options?: SendOptionsType; + storyMessage?: Proto.StoryMessage; storyMessageRecipients?: Array<{ destinationUuid: string; distributionListIds: Array; @@ -1270,6 +1373,9 @@ export default class MessageSender { expirationStartTimestamp ); } + if (storyMessage) { + sentMessage.storyMessage = storyMessage; + } if (storyMessageRecipients) { sentMessage.storyMessageRecipients = storyMessageRecipients.map( recipient => { diff --git a/ts/types/MIME.ts b/ts/types/MIME.ts index 6ad989498..15712892f 100644 --- a/ts/types/MIME.ts +++ b/ts/types/MIME.ts @@ -25,6 +25,7 @@ export const IMAGE_BMP = stringToMIMEType('image/bmp'); export const VIDEO_MP4 = stringToMIMEType('video/mp4'); export const VIDEO_QUICKTIME = stringToMIMEType('video/quicktime'); export const LONG_MESSAGE = stringToMIMEType('text/x-signal-plain'); +export const TEXT_ATTACHMENT = stringToMIMEType('text/x-signal-story'); export const isHeic = (value: string, fileName: string): boolean => value === 'image/heic' || diff --git a/ts/util/getSignalConnections.ts b/ts/util/getSignalConnections.ts new file mode 100644 index 000000000..b7a910a48 --- /dev/null +++ b/ts/util/getSignalConnections.ts @@ -0,0 +1,19 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationAttributesType } from '../model-types.d'; +import type { ConversationModel } from '../models/conversations'; +import type { ConversationType } from '../state/ducks/conversations'; +import { isInSystemContacts } from './isInSystemContacts'; + +export function isSignalConnection( + conversation: ConversationType | ConversationAttributesType +): boolean { + return conversation.profileSharing || isInSystemContacts(conversation); +} + +export function getSignalConnections(): Array { + return window + .getConversations() + .filter(conversation => isSignalConnection(conversation.attributes)); +} diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts new file mode 100644 index 000000000..d99952433 --- /dev/null +++ b/ts/util/sendStoryMessage.ts @@ -0,0 +1,200 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types.d'; +import type { SendStateByConversationId } from '../messages/MessageSendState'; +import type { TextAttachmentType } from '../types/Attachment'; +import type { UUIDStringType } from '../types/UUID'; +import * as log from '../logging/log'; +import dataInterface from '../sql/Client'; +import { DAY, SECOND } from './durations'; +import { MY_STORIES_ID } from '../types/Stories'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { SeenStatus } from '../MessageSeenStatus'; +import { SendStatus } from '../messages/MessageSendState'; +import { TEXT_ATTACHMENT } from '../types/MIME'; +import { UUID } from '../types/UUID'; +import { + conversationJobQueue, + conversationQueueJobEnum, +} from '../jobs/conversationJobQueue'; +import { formatJobForInsert } from '../jobs/formatJobForInsert'; +import { getSignalConnections } from './getSignalConnections'; +import { incrementMessageCounter } from './incrementMessageCounter'; +import { isNotNil } from './isNotNil'; + +export async function sendStoryMessage( + listIds: Array, + textAttachment: TextAttachmentType +): Promise { + const { messaging } = window.textsecure; + + if (!messaging) { + log.warn('stories.sendStoryMessage: messaging not available'); + return; + } + + const distributionLists = ( + await Promise.all( + listIds.map(listId => + dataInterface.getStoryDistributionWithMembers(listId) + ) + ) + ).filter(isNotNil); + + if (!distributionLists.length) { + log.info( + 'stories.sendStoryMessage: no distribution lists found for', + listIds + ); + return; + } + + const ourConversation = + window.ConversationController.getOurConversationOrThrow(); + + const timestamp = Date.now(); + + const sendStateByListId = new Map< + UUIDStringType, + SendStateByConversationId + >(); + + const recipientsAlreadySentTo = new Map(); + + // * Create the custom sendStateByConversationId for each distribution list + // * De-dupe members to make sure they're only sent to once + // * Figure out who can reply/who can't + distributionLists + .sort(list => (list.allowsReplies ? -1 : 1)) + .forEach(distributionList => { + const sendStateByConversationId: SendStateByConversationId = {}; + + let distributionListMembers: Array = []; + + if ( + distributionList.id === MY_STORIES_ID && + distributionList.isBlockList + ) { + const inBlockList = new Set(distributionList.members); + distributionListMembers = getSignalConnections().reduce( + (acc, convo) => { + const id = convo.get('uuid'); + if (!id) { + return acc; + } + + const uuid = UUID.fromString(id); + if (inBlockList.has(uuid)) { + return acc; + } + + acc.push(uuid); + return acc; + }, + [] as Array + ); + } else { + distributionListMembers = distributionList.members; + } + + distributionListMembers.forEach(destinationUuid => { + const conversation = window.ConversationController.get(destinationUuid); + if (!conversation) { + return; + } + sendStateByConversationId[conversation.id] = { + isAllowedToReplyToStory: + recipientsAlreadySentTo.get(destinationUuid) || + distributionList.allowsReplies, + isAlreadyIncludedInAnotherDistributionList: + recipientsAlreadySentTo.has(destinationUuid), + status: SendStatus.Pending, + updatedAt: timestamp, + }; + + if (!recipientsAlreadySentTo.has(destinationUuid)) { + recipientsAlreadySentTo.set( + destinationUuid, + distributionList.allowsReplies + ); + } + }); + + sendStateByListId.set(distributionList.id, sendStateByConversationId); + }); + + // * 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', + distributionList.id + ); + } + + return window.Signal.Migrations.upgradeMessageSchema({ + attachments: [ + { + contentType: TEXT_ATTACHMENT, + textAttachment, + size: textAttachment.text?.length || 0, + }, + ], + 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', + }); + }) + ); + + // * Save the message model + // * Add the message to the conversation + await Promise.all( + messagesToSave.map(messageAttributes => { + const model = new window.Whisper.Message(messageAttributes); + const message = window.MessageController.register(model.id, model); + + ourConversation.addSingleMessage(model, { isJustSent: true }); + + log.info(`stories.sendStoryMessage: saving message ${message.id}`); + return dataInterface.saveMessage(message.attributes, { + forceSave: true, + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + }) + ); + + // * Place into job queue + // * Save the job + await conversationJobQueue.add( + { + type: conversationQueueJobEnum.enum.Story, + conversationId: ourConversation.id, + messageIds: messagesToSave.map(m => m.id), + textAttachment, + timestamp, + }, + async jobToInsert => { + log.info(`stories.sendStoryMessage: saving job ${jobToInsert.id}`); + await dataInterface.insertJob(formatJobForInsert(jobToInsert)); + } + ); +}