diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 41db0e642511..555c2445ea7a 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 000000000000..4a4c3bf67b43
--- /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 1e831630a1e3..4fcc7b2e185d 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 f4b941cbe658..9059d3fa682d 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 000000000000..d7acaf615d25
--- /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 000000000000..3e28f9b6f839
--- /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(', ')}
+
+
+
+ );
+};
diff --git a/ts/components/StoriesSettingsModal.stories.tsx b/ts/components/StoriesSettingsModal.stories.tsx
index e93ef666d214..651d62f736ed 100644
--- a/ts/components/StoriesSettingsModal.stories.tsx
+++ b/ts/components/StoriesSettingsModal.stories.tsx
@@ -6,11 +6,13 @@ import React from 'react';
import type { PropsType } from './StoriesSettingsModal';
import enMessages from '../../_locales/en/messages.json';
-import { MY_STORIES_ID } from '../types/Stories';
import { StoriesSettingsModal } from './StoriesSettingsModal';
-import { UUID } from '../types/UUID';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
+import {
+ getMyStories,
+ getFakeDistributionList,
+} from '../test-both/helpers/getFakeDistributionLists';
const i18n = setupI18n('en', enMessages);
@@ -46,60 +48,59 @@ export default {
const Template: Story = args => ;
export const MyStories = Template.bind({});
-MyStories.args = {
- distributionLists: [
- {
- allowsReplies: true,
- id: MY_STORIES_ID,
- isBlockList: false,
- members: [],
- name: MY_STORIES_ID,
- },
- ],
-};
+{
+ const myStories = getMyStories();
+ MyStories.args = {
+ distributionLists: [
+ {
+ ...myStories,
+ members: [],
+ },
+ ],
+ };
+}
export const MyStoriesBlockList = Template.bind({});
-MyStoriesBlockList.args = {
- distributionLists: [
- {
- allowsReplies: true,
- id: MY_STORIES_ID,
- isBlockList: true,
- members: Array.from(Array(2), () => getDefaultConversation()),
- name: MY_STORIES_ID,
- },
- ],
-};
+{
+ const myStories = getMyStories();
+ MyStoriesBlockList.args = {
+ distributionLists: [
+ {
+ ...myStories,
+ members: Array.from(Array(2), () => getDefaultConversation()),
+ },
+ ],
+ };
+}
export const MyStoriesExclusive = Template.bind({});
-MyStoriesExclusive.args = {
- distributionLists: [
- {
- allowsReplies: false,
- id: MY_STORIES_ID,
- isBlockList: false,
- members: Array.from(Array(11), () => getDefaultConversation()),
- name: MY_STORIES_ID,
- },
- ],
-};
+{
+ const myStories = getMyStories();
+ MyStoriesExclusive.args = {
+ distributionLists: [
+ {
+ ...myStories,
+ isBlockList: false,
+ members: Array.from(Array(11), () => getDefaultConversation()),
+ },
+ ],
+ };
+}
export const SingleList = Template.bind({});
-SingleList.args = {
- distributionLists: [
- {
- allowsReplies: true,
- id: MY_STORIES_ID,
- isBlockList: false,
- members: [],
- name: MY_STORIES_ID,
- },
- {
- allowsReplies: true,
- id: UUID.generate().toString(),
- isBlockList: false,
- members: Array.from(Array(4), () => getDefaultConversation()),
- name: 'Thailand 2021',
- },
- ],
-};
+{
+ const myStories = getMyStories();
+ const fakeDistroList = getFakeDistributionList();
+ SingleList.args = {
+ distributionLists: [
+ {
+ ...myStories,
+ members: [],
+ },
+ {
+ ...fakeDistroList,
+ members: fakeDistroList.memberUuids.map(() => getDefaultConversation()),
+ },
+ ],
+ };
+}
diff --git a/ts/components/StoryCreator.stories.tsx b/ts/components/StoryCreator.stories.tsx
index cdce09770c5a..23a16349a39c 100644
--- a/ts/components/StoryCreator.stories.tsx
+++ b/ts/components/StoryCreator.stories.tsx
@@ -3,12 +3,13 @@
import type { Meta, Story } from '@storybook/react';
import React from 'react';
-import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryCreator';
import enMessages from '../../_locales/en/messages.json';
import { StoryCreator } from './StoryCreator';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
+import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
+import { getFakeDistributionLists } from '../test-both/helpers/getFakeDistributionLists';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
@@ -16,26 +17,34 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryCreator',
component: StoryCreator,
+ argTypes: {
+ debouncedMaybeGrabLinkPreview: { action: true },
+ distributionLists: { defaultValue: getFakeDistributionLists() },
+ linkPreview: {
+ defaultValue: undefined,
+ },
+ i18n: { defaultValue: i18n },
+ me: {
+ defaultValue: getDefaultConversation(),
+ },
+ onClose: { action: true },
+ onSend: { action: true },
+ signalConnections: {
+ defaultValue: Array.from(Array(42), getDefaultConversation),
+ },
+ },
} as Meta;
-const getDefaultProps = (): PropsType => ({
- debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'),
- i18n,
- onClose: action('onClose'),
- onNext: action('onNext'),
-});
-
const Template: Story = args => ;
export const Default = Template.bind({});
-Default.args = getDefaultProps();
+Default.args = {};
Default.story = {
name: 'w/o Link Preview available',
};
export const LinkPreview = Template.bind({});
LinkPreview.args = {
- ...getDefaultProps(),
linkPreview: {
domain: 'www.catsandkittens.lolcats',
image: fakeAttachment({
diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx
index 3673c186f072..a775d689cf23 100644
--- a/ts/components/StoryCreator.tsx
+++ b/ts/components/StoryCreator.tsx
@@ -7,9 +7,12 @@ import classNames from 'classnames';
import { get, has } from 'lodash';
import { usePopper } from 'react-popper';
+import type { ConversationType } from '../state/ducks/conversations';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { LocalizerType } from '../types/Util';
+import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import type { TextAttachmentType } from '../types/Attachment';
+import type { UUIDStringType } from '../types/UUID';
import { Button, ButtonVariant } from './Button';
import { ContextMenu } from './ContextMenu';
@@ -17,6 +20,7 @@ import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview';
import { Input } from './Input';
import { Slider } from './Slider';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
+import { SendStoryModal } from './SendStoryModal';
import { TextAttachment } from './TextAttachment';
import { Theme, themeClassName } from '../util/theme';
import { getRGBA, getRGBANumber } from '../mediaEditor/util/color';
@@ -32,10 +36,16 @@ export type PropsType = {
message: string,
source: LinkPreviewSourceType
) => unknown;
+ distributionLists: Array;
i18n: LocalizerType;
linkPreview?: LinkPreviewType;
+ me: ConversationType;
onClose: () => unknown;
- onNext: () => unknown;
+ onSend: (
+ listIds: Array,
+ textAttachment: TextAttachmentType
+ ) => unknown;
+ signalConnections: Array;
};
enum TextStyle {
@@ -92,10 +102,13 @@ function getBackground(
export const StoryCreator = ({
debouncedMaybeGrabLinkPreview,
+ distributionLists,
i18n,
linkPreview,
+ me,
onClose,
- onNext,
+ onSend,
+ signalConnections,
}: PropsType): JSX.Element => {
const [isEditingText, setIsEditingText] = useState(false);
const [selectedBackground, setSelectedBackground] =
@@ -106,6 +119,7 @@ export const StoryCreator = ({
);
const [sliderValue, setSliderValue] = useState(100);
const [text, setText] = useState('');
+ const [hasSendToModal, setHasSendToModal] = useState(false);
const textEditorRef = useRef(null);
@@ -229,266 +243,289 @@ export const StoryCreator = ({
textForegroundColor = COLOR_WHITE_INT;
}
+ const textAttachment: TextAttachmentType = {
+ ...getBackground(selectedBackground),
+ text,
+ textStyle,
+ textForegroundColor,
+ textBackgroundColor,
+ preview: hasLinkPreviewApplied ? linkPreview : undefined,
+ };
+
+ const hasChanges = Boolean(text || hasLinkPreviewApplied);
+
return (
-
-
-
- {
- if (!isEditingText) {
- setIsEditingText(true);
- }
- }}
- onRemoveLinkPreview={() => {
- setHasLinkPreviewApplied(false);
- }}
- textAttachment={{
- ...getBackground(selectedBackground),
- text,
- textStyle,
- textForegroundColor,
- textBackgroundColor,
- preview: hasLinkPreviewApplied ? linkPreview : undefined,
- }}
- />
-
-
- {isEditingText ? (
-
-
- setTextStyle(TextStyle.Regular),
- value: TextStyle.Regular,
- },
- {
- icon: 'StoryCreator__icon--font-bold',
- label: i18n('StoryCreator__text--bold'),
- onClick: () => setTextStyle(TextStyle.Bold),
- value: TextStyle.Bold,
- },
- {
- icon: 'StoryCreator__icon--font-serif',
- label: i18n('StoryCreator__text--serif'),
- onClick: () => setTextStyle(TextStyle.Serif),
- value: TextStyle.Serif,
- },
- {
- icon: 'StoryCreator__icon--font-script',
- label: i18n('StoryCreator__text--script'),
- onClick: () => setTextStyle(TextStyle.Script),
- value: TextStyle.Script,
- },
- {
- icon: 'StoryCreator__icon--font-condensed',
- label: i18n('StoryCreator__text--condensed'),
- onClick: () => setTextStyle(TextStyle.Condensed),
- value: TextStyle.Condensed,
- },
- ]}
- moduleClassName={classNames('StoryCreator__tools__tool', {
- 'StoryCreator__tools__button--font-regular':
- textStyle === TextStyle.Regular,
- 'StoryCreator__tools__button--font-bold':
- textStyle === TextStyle.Bold,
- 'StoryCreator__tools__button--font-serif':
- textStyle === TextStyle.Serif,
- 'StoryCreator__tools__button--font-script':
- textStyle === TextStyle.Script,
- 'StoryCreator__tools__button--font-condensed':
- textStyle === TextStyle.Condensed,
- })}
- theme={Theme.Dark}
- value={textStyle}
- />
-
- ) : (
-
- )}
-
-
-
-
-
+
+ >
);
};
diff --git a/ts/jobs/conversationJobQueue.ts b/ts/jobs/conversationJobQueue.ts
index fff320a0a077..e43091e4afd4 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 000000000000..1e9b1013db2e
--- /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 912a3d304bff..44f517b7e2b9 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 193957bd93f1..86494db477e0 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 e69a683bbaa5..f553e496a5a4 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 4604628f6e44..caf2abf8ac18 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 8aeb0a23d45e..304d7e743621 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 000000000000..ce99effe550c
--- /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 ca538d9cab92..4fccb6ecf6c5 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 ff3f9bc6c0d4..b5db66263d08 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 6ad989498894..15712892f984 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 000000000000..b7a910a4874a
--- /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 000000000000..d99952433483
--- /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));
+ }
+ );
+}