Send text attachment stories
This commit is contained in:
parent
0340f4ee1d
commit
9eff67446f
22 changed files with 1635 additions and 339 deletions
|
@ -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"
|
||||
|
|
67
stylesheets/components/SendStoryModal.scss
Normal file
67
stylesheets/components/SendStoryModal.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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 = (
|
||||
<div className={getClassName('__checkbox')}>
|
||||
<input
|
||||
checked={Boolean(checked)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name={name}
|
||||
onChange={ev => onChange(ev.target.checked)}
|
||||
onClick={onClick}
|
||||
type={isRadio ? 'radio' : 'checkbox'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const labelNode = (
|
||||
<div>
|
||||
<label htmlFor={id}>
|
||||
<div>{label}</div>
|
||||
<div className={getClassName('__description')}>{description}</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={getClassName('')}>
|
||||
<div className={getClassName('__container')}>
|
||||
<div className={getClassName('__checkbox')}>
|
||||
<input
|
||||
checked={Boolean(checked)}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name={name}
|
||||
onChange={ev => onChange(ev.target.checked)}
|
||||
onClick={onClick}
|
||||
type={isRadio ? 'radio' : 'checkbox'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={id}>
|
||||
<div>{label}</div>
|
||||
<div className={getClassName('__description')}>{description}</div>
|
||||
</label>
|
||||
</div>
|
||||
{children ? (
|
||||
children({ id, checkboxNode, labelNode })
|
||||
) : (
|
||||
<>
|
||||
{checkboxNode}
|
||||
{labelNode}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
45
ts/components/SendStoryModal.stories.tsx
Normal file
45
ts/components/SendStoryModal.stories.tsx
Normal file
|
@ -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<PropsType> = args => <SendStoryModal {...args} />;
|
||||
|
||||
export const Modal = Template.bind({});
|
||||
Modal.args = {
|
||||
distributionLists: getFakeDistributionLists(),
|
||||
};
|
153
ts/components/SendStoryModal.tsx
Normal file
153
ts/components/SendStoryModal.tsx
Normal file
|
@ -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<StoryDistributionListDataType>;
|
||||
i18n: LocalizerType;
|
||||
me: ConversationType;
|
||||
onClose: () => unknown;
|
||||
onSend: (listIds: Array<UUIDStringType>) => unknown;
|
||||
signalConnections: Array<ConversationType>;
|
||||
};
|
||||
|
||||
function getListViewers(
|
||||
list: StoryDistributionListDataType,
|
||||
i18n: LocalizerType,
|
||||
signalConnections: Array<ConversationType>
|
||||
): 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<Set<UUIDStringType>>(
|
||||
new Set()
|
||||
);
|
||||
const selectedListNames = useMemo(
|
||||
() =>
|
||||
distributionLists
|
||||
.filter(list => selectedListIds.has(list.id))
|
||||
.map(list => list.name),
|
||||
[distributionLists, selectedListIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
title={i18n('SendStoryModal__title')}
|
||||
>
|
||||
{distributionLists.map(list => (
|
||||
<Checkbox
|
||||
checked={selectedListIds.has(list.id)}
|
||||
key={list.id}
|
||||
label={getStoryDistributionListName(i18n, list.id, list.name)}
|
||||
moduleClassName="SendStoryModal__distribution-list"
|
||||
name="SendStoryModal__distribution-list"
|
||||
onChange={(value: boolean) => {
|
||||
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 }) => (
|
||||
<>
|
||||
<label
|
||||
className="SendStoryModal__distribution-list__label"
|
||||
htmlFor={id}
|
||||
>
|
||||
{list.id === MY_STORIES_ID ? (
|
||||
<Avatar
|
||||
acceptedMessageRequest={me.acceptedMessageRequest}
|
||||
avatarPath={me.avatarPath}
|
||||
badge={undefined}
|
||||
color={me.color}
|
||||
conversationType={me.type}
|
||||
i18n={i18n}
|
||||
isMe
|
||||
sharedGroupNames={me.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
title={me.title}
|
||||
/>
|
||||
) : (
|
||||
<span className="StoriesSettingsModal__list__avatar--private" />
|
||||
)}
|
||||
|
||||
<div className="SendStoryModal__distribution-list__info">
|
||||
<div className="SendStoryModal__distribution-list__name">
|
||||
<StoryDistributionListName
|
||||
i18n={i18n}
|
||||
id={list.id}
|
||||
name={list.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="SendStoryModal__distribution-list__description">
|
||||
{getListViewers(list, i18n, signalConnections)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{checkboxNode}
|
||||
</>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
|
||||
<Modal.ButtonFooter moduleClassName="SendStoryModal">
|
||||
<div className="SendStoryModal__selected-lists">
|
||||
{selectedListNames
|
||||
.map(listName =>
|
||||
getStoryDistributionListName(i18n, listName, listName)
|
||||
)
|
||||
.join(', ')}
|
||||
</div>
|
||||
<button
|
||||
aria-label="SendStoryModal__send"
|
||||
className="SendStoryModal__send"
|
||||
disabled={!selectedListIds.size}
|
||||
onClick={() => {
|
||||
onSend(Array.from(selectedListIds));
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</Modal.ButtonFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -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<PropsType> = args => <StoriesSettingsModal {...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()),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<PropsType> = args => <StoryCreator {...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({
|
||||
|
|
|
@ -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<StoryDistributionListDataType>;
|
||||
i18n: LocalizerType;
|
||||
linkPreview?: LinkPreviewType;
|
||||
me: ConversationType;
|
||||
onClose: () => unknown;
|
||||
onNext: () => unknown;
|
||||
onSend: (
|
||||
listIds: Array<UUIDStringType>,
|
||||
textAttachment: TextAttachmentType
|
||||
) => unknown;
|
||||
signalConnections: Array<ConversationType>;
|
||||
};
|
||||
|
||||
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<number>(100);
|
||||
const [text, setText] = useState<string>('');
|
||||
const [hasSendToModal, setHasSendToModal] = useState(false);
|
||||
|
||||
const textEditorRef = useRef<HTMLInputElement | null>(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 (
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="StoryCreator">
|
||||
<div className="StoryCreator__container">
|
||||
<TextAttachment
|
||||
disableLinkPreviewPopup
|
||||
i18n={i18n}
|
||||
isEditingText={isEditingText}
|
||||
onChange={setText}
|
||||
onClick={() => {
|
||||
if (!isEditingText) {
|
||||
setIsEditingText(true);
|
||||
}
|
||||
}}
|
||||
onRemoveLinkPreview={() => {
|
||||
setHasLinkPreviewApplied(false);
|
||||
}}
|
||||
textAttachment={{
|
||||
...getBackground(selectedBackground),
|
||||
text,
|
||||
textStyle,
|
||||
textForegroundColor,
|
||||
textBackgroundColor,
|
||||
preview: hasLinkPreviewApplied ? linkPreview : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="StoryCreator__toolbar">
|
||||
{isEditingText ? (
|
||||
<div className="StoryCreator__tools">
|
||||
<Slider
|
||||
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="HueSlider StoryCreator__tools__tool"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-regular',
|
||||
label: i18n('StoryCreator__text--regular'),
|
||||
onClick: () => 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}
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__text-bg')}
|
||||
className={classNames('StoryCreator__tools__tool', {
|
||||
'StoryCreator__tools__button--bg-none':
|
||||
textBackground === TextBackground.None,
|
||||
'StoryCreator__tools__button--bg':
|
||||
textBackground === TextBackground.Background,
|
||||
'StoryCreator__tools__button--bg-inverse':
|
||||
textBackground === TextBackground.Inverse,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (textBackground === TextBackground.None) {
|
||||
setTextBackground(TextBackground.Background);
|
||||
} else if (textBackground === TextBackground.Background) {
|
||||
setTextBackground(TextBackground.Inverse);
|
||||
} else {
|
||||
setTextBackground(TextBackground.None);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="StoryCreator__toolbar--space" />
|
||||
)}
|
||||
<div className="StoryCreator__toolbar--buttons">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('discard')}
|
||||
</Button>
|
||||
<div className="StoryCreator__controls">
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__story-bg')}
|
||||
className={classNames({
|
||||
StoryCreator__control: true,
|
||||
'StoryCreator__control--bg': true,
|
||||
'StoryCreator__control--bg--selected': isColorPickerShowing,
|
||||
})}
|
||||
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
|
||||
ref={setColorPickerPopperButtonRef}
|
||||
style={{
|
||||
background: getBackgroundColor(
|
||||
getBackground(selectedBackground)
|
||||
),
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
{isColorPickerShowing && (
|
||||
<div
|
||||
className="StoryCreator__popper"
|
||||
ref={setColorPickerPopperRef}
|
||||
style={colorPickerPopper.styles.popper}
|
||||
{...colorPickerPopper.attributes.popper}
|
||||
>
|
||||
<div
|
||||
data-popper-arrow
|
||||
className="StoryCreator__popper__arrow"
|
||||
/>
|
||||
{objectMap<BackgroundStyleType>(
|
||||
BackgroundStyle,
|
||||
(bg, backgroundValue) => (
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__story-bg')}
|
||||
className={classNames({
|
||||
StoryCreator__bg: true,
|
||||
'StoryCreator__bg--selected':
|
||||
selectedBackground === backgroundValue,
|
||||
})}
|
||||
key={String(bg)}
|
||||
onClick={() => {
|
||||
setSelectedBackground(backgroundValue);
|
||||
setIsColorPickerShowing(false);
|
||||
}}
|
||||
type="button"
|
||||
style={{
|
||||
background: getBackgroundColor(
|
||||
getBackground(backgroundValue)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__control--draw')}
|
||||
className={classNames({
|
||||
StoryCreator__control: true,
|
||||
'StoryCreator__control--text': true,
|
||||
'StoryCreator__control--selected': isEditingText,
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsEditingText(!isEditingText);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__control--link')}
|
||||
className="StoryCreator__control StoryCreator__control--link"
|
||||
onClick={() =>
|
||||
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
|
||||
<>
|
||||
{hasSendToModal && (
|
||||
<SendStoryModal
|
||||
distributionLists={distributionLists}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
onClose={() => setHasSendToModal(false)}
|
||||
onSend={listIds => {
|
||||
onSend(listIds, textAttachment);
|
||||
setHasSendToModal(false);
|
||||
onClose();
|
||||
}}
|
||||
signalConnections={signalConnections}
|
||||
/>
|
||||
)}
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="StoryCreator">
|
||||
<div className="StoryCreator__container">
|
||||
<TextAttachment
|
||||
disableLinkPreviewPopup
|
||||
i18n={i18n}
|
||||
isEditingText={isEditingText}
|
||||
onChange={setText}
|
||||
onClick={() => {
|
||||
if (!isEditingText) {
|
||||
setIsEditingText(true);
|
||||
}
|
||||
ref={setLinkPreviewInputPopperButtonRef}
|
||||
type="button"
|
||||
/>
|
||||
{isLinkPreviewInputShowing && (
|
||||
<div
|
||||
className={classNames(
|
||||
'StoryCreator__popper StoryCreator__link-preview-input-popper',
|
||||
themeClassName(Theme.Dark)
|
||||
)}
|
||||
ref={setLinkPreviewInputPopperRef}
|
||||
style={linkPreviewInputPopper.styles.popper}
|
||||
{...linkPreviewInputPopper.attributes.popper}
|
||||
>
|
||||
}}
|
||||
onRemoveLinkPreview={() => {
|
||||
setHasLinkPreviewApplied(false);
|
||||
}}
|
||||
textAttachment={textAttachment}
|
||||
/>
|
||||
</div>
|
||||
<div className="StoryCreator__toolbar">
|
||||
{isEditingText ? (
|
||||
<div className="StoryCreator__tools">
|
||||
<Slider
|
||||
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="HueSlider StoryCreator__tools__tool"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-regular',
|
||||
label: i18n('StoryCreator__text--regular'),
|
||||
onClick: () => 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}
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__text-bg')}
|
||||
className={classNames('StoryCreator__tools__tool', {
|
||||
'StoryCreator__tools__button--bg-none':
|
||||
textBackground === TextBackground.None,
|
||||
'StoryCreator__tools__button--bg':
|
||||
textBackground === TextBackground.Background,
|
||||
'StoryCreator__tools__button--bg-inverse':
|
||||
textBackground === TextBackground.Inverse,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (textBackground === TextBackground.None) {
|
||||
setTextBackground(TextBackground.Background);
|
||||
} else if (textBackground === TextBackground.Background) {
|
||||
setTextBackground(TextBackground.Inverse);
|
||||
} else {
|
||||
setTextBackground(TextBackground.None);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="StoryCreator__toolbar--space" />
|
||||
)}
|
||||
<div className="StoryCreator__toolbar--buttons">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('discard')}
|
||||
</Button>
|
||||
<div className="StoryCreator__controls">
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__story-bg')}
|
||||
className={classNames({
|
||||
StoryCreator__control: true,
|
||||
'StoryCreator__control--bg': true,
|
||||
'StoryCreator__control--bg--selected': isColorPickerShowing,
|
||||
})}
|
||||
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
|
||||
ref={setColorPickerPopperButtonRef}
|
||||
style={{
|
||||
background: getBackgroundColor(
|
||||
getBackground(selectedBackground)
|
||||
),
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
{isColorPickerShowing && (
|
||||
<div
|
||||
data-popper-arrow
|
||||
className="StoryCreator__popper__arrow"
|
||||
/>
|
||||
<Input
|
||||
disableSpellcheck
|
||||
i18n={i18n}
|
||||
moduleClassName="StoryCreator__link-preview-input"
|
||||
onChange={setLinkPreviewInputValue}
|
||||
placeholder={i18n('StoryCreator__link-preview-placeholder')}
|
||||
ref={el => el?.focus()}
|
||||
value={linkPreviewInputValue}
|
||||
/>
|
||||
<div className="StoryCreator__link-preview-container">
|
||||
{linkPreview ? (
|
||||
<>
|
||||
<StagedLinkPreview
|
||||
domain={linkPreview.domain}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
moduleClassName="StoryCreator__link-preview"
|
||||
title={linkPreview.title}
|
||||
url={linkPreview.url}
|
||||
/>
|
||||
<Button
|
||||
className="StoryCreator__link-preview-button"
|
||||
className="StoryCreator__popper"
|
||||
ref={setColorPickerPopperRef}
|
||||
style={colorPickerPopper.styles.popper}
|
||||
{...colorPickerPopper.attributes.popper}
|
||||
>
|
||||
<div
|
||||
data-popper-arrow
|
||||
className="StoryCreator__popper__arrow"
|
||||
/>
|
||||
{objectMap<BackgroundStyleType>(
|
||||
BackgroundStyle,
|
||||
(bg, backgroundValue) => (
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__story-bg')}
|
||||
className={classNames({
|
||||
StoryCreator__bg: true,
|
||||
'StoryCreator__bg--selected':
|
||||
selectedBackground === backgroundValue,
|
||||
})}
|
||||
key={String(bg)}
|
||||
onClick={() => {
|
||||
setHasLinkPreviewApplied(true);
|
||||
setIsLinkPreviewInputShowing(false);
|
||||
setSelectedBackground(backgroundValue);
|
||||
setIsColorPickerShowing(false);
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('StoryCreator__add-link')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="StoryCreator__link-preview-empty">
|
||||
<div className="StoryCreator__link-preview-empty__icon" />
|
||||
{i18n('StoryCreator__link-preview-empty')}
|
||||
</div>
|
||||
type="button"
|
||||
style={{
|
||||
background: getBackgroundColor(
|
||||
getBackground(backgroundValue)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__control--text')}
|
||||
className={classNames({
|
||||
StoryCreator__control: true,
|
||||
'StoryCreator__control--text': true,
|
||||
'StoryCreator__control--selected': isEditingText,
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsEditingText(!isEditingText);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__control--link')}
|
||||
className="StoryCreator__control StoryCreator__control--link"
|
||||
onClick={() =>
|
||||
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
|
||||
}
|
||||
ref={setLinkPreviewInputPopperButtonRef}
|
||||
type="button"
|
||||
/>
|
||||
{isLinkPreviewInputShowing && (
|
||||
<div
|
||||
className={classNames(
|
||||
'StoryCreator__popper StoryCreator__link-preview-input-popper',
|
||||
themeClassName(Theme.Dark)
|
||||
)}
|
||||
ref={setLinkPreviewInputPopperRef}
|
||||
style={linkPreviewInputPopper.styles.popper}
|
||||
{...linkPreviewInputPopper.attributes.popper}
|
||||
>
|
||||
<div
|
||||
data-popper-arrow
|
||||
className="StoryCreator__popper__arrow"
|
||||
/>
|
||||
<Input
|
||||
disableSpellcheck
|
||||
i18n={i18n}
|
||||
moduleClassName="StoryCreator__link-preview-input"
|
||||
onChange={setLinkPreviewInputValue}
|
||||
placeholder={i18n(
|
||||
'StoryCreator__link-preview-placeholder'
|
||||
)}
|
||||
ref={el => el?.focus()}
|
||||
value={linkPreviewInputValue}
|
||||
/>
|
||||
<div className="StoryCreator__link-preview-container">
|
||||
{linkPreview ? (
|
||||
<>
|
||||
<StagedLinkPreview
|
||||
domain={linkPreview.domain}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
moduleClassName="StoryCreator__link-preview"
|
||||
title={linkPreview.title}
|
||||
url={linkPreview.url}
|
||||
/>
|
||||
<Button
|
||||
className="StoryCreator__link-preview-button"
|
||||
onClick={() => {
|
||||
setHasLinkPreviewApplied(true);
|
||||
setIsLinkPreviewInputShowing(false);
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('StoryCreator__add-link')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="StoryCreator__link-preview-empty">
|
||||
<div className="StoryCreator__link-preview-empty__icon" />
|
||||
{i18n('StoryCreator__link-preview-empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
disabled={!hasChanges}
|
||||
onClick={() => setHasSendToModal(true)}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('StoryCreator__next')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('StoryCreator__next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</FocusTrap>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<typeof reactionJobDataSchema>;
|
||||
|
||||
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<typeof storyJobDataSchema>;
|
||||
|
||||
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<ConversationQueueJobData> {
|
|||
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.
|
||||
|
|
498
ts/jobs/helpers/sendStory.ts
Normal file
498
ts/jobs/helpers/sendStory.ts
Normal file
|
@ -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<void> {
|
||||
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<string, SendState>();
|
||||
const canReplyUuids = new Set<string>();
|
||||
const recipientsByUuid = new Map<string, Set<string>>();
|
||||
|
||||
// This function is used to keep track of all the recipients so once we're
|
||||
// done with our send we can build up the storyMessageRecipients object for
|
||||
// sending in the sync message.
|
||||
function processStoryMessageRecipient(
|
||||
listId: string,
|
||||
uuid: string,
|
||||
canReply?: boolean
|
||||
): void {
|
||||
if (conversation.get('uuid') === uuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distributionListIds = recipientsByUuid.get(uuid) || new Set<string>();
|
||||
|
||||
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<void> {
|
||||
const oldSendStateByConversationId =
|
||||
message.get('sendStateByConversationId') || {};
|
||||
|
||||
const newSendStateByConversationId = Object.keys(
|
||||
oldSendStateByConversationId
|
||||
).reduce((acc, conversationId) => {
|
||||
const sendState = accSendStateByConversationId.get(conversationId);
|
||||
if (sendState) {
|
||||
return {
|
||||
...acc,
|
||||
[conversationId]: sendState,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as SendStateByConversationId);
|
||||
|
||||
if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
message.set('sendStateByConversationId', newSendStateByConversationId);
|
||||
await window.Signal.Data.saveMessage(message.attributes, {
|
||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||
});
|
||||
}
|
||||
|
||||
let isSyncMessageUpdate = false;
|
||||
|
||||
// Send to all distribution lists
|
||||
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<Error> = [];
|
||||
|
||||
// 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<Error>) => {
|
||||
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<string>;
|
||||
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<string>;
|
||||
allowedReplyByUuid: Map<string, boolean>;
|
||||
recipientIdentifiersWithoutMe: Array<string>;
|
||||
sentRecipientIdentifiers: Array<string>;
|
||||
untrustedUuids: Array<string>;
|
||||
} {
|
||||
const allRecipientIdentifiers: Array<string> = [];
|
||||
const recipientIdentifiersWithoutMe: Array<string> = [];
|
||||
const untrustedUuids: Array<string> = [];
|
||||
const sentRecipientIdentifiers: Array<string> = [];
|
||||
const allowedReplyByUuid = new Map<string, boolean>();
|
||||
|
||||
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<Error>
|
||||
): Promise<void> {
|
||||
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<MessageModel>): boolean {
|
||||
const sendStateByConversationId =
|
||||
message.get('sendStateByConversationId') || {};
|
||||
return Object.values(sendStateByConversationId).every(sendState =>
|
||||
isSent(sendState.status)
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -194,6 +194,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
// 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<MessageAttributesType> {
|
|||
|
||||
updateLeftPane();
|
||||
|
||||
if (sentToAtLeastOneRecipient) {
|
||||
if (sentToAtLeastOneRecipient && !this.doNotSendSyncMessage) {
|
||||
promises.push(this.sendSyncMessage());
|
||||
}
|
||||
|
||||
|
|
|
@ -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<UUIDStringType>,
|
||||
textAttachment: TextAttachmentType
|
||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||
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' &&
|
||||
|
|
|
@ -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<ConversationType> => Object.values(lookup)
|
||||
);
|
||||
|
||||
export const getAllSignalConnections = createSelector(
|
||||
getAllConversations,
|
||||
(conversations): Array<ConversationType> =>
|
||||
conversations.filter(isSignalConnection)
|
||||
);
|
||||
|
||||
export const getConversationsByTitleSelector = createSelector(
|
||||
getAllConversations,
|
||||
(conversations): ((title: string) => Array<ConversationType>) =>
|
||||
|
|
|
@ -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<StateType, LocalizerType>(getIntl);
|
||||
const linkPreviewForSource = useSelector(getLinkPreview);
|
||||
const distributionLists = useSelector(getDistributionLists);
|
||||
const me = useSelector(getMe);
|
||||
const signalConnections = useSelector(getAllSignalConnections);
|
||||
|
||||
return (
|
||||
<StoryCreator
|
||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||
distributionLists={distributionLists}
|
||||
i18n={i18n}
|
||||
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
|
||||
me={me}
|
||||
onClose={onClose}
|
||||
onNext={noop}
|
||||
onSend={sendStoryMessage}
|
||||
signalConnections={signalConnections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
37
ts/test-both/helpers/getFakeDistributionLists.ts
Normal file
37
ts/test-both/helpers/getFakeDistributionLists.ts
Normal file
|
@ -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<StoryDistributionListDataType> {
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -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 ||
|
||||
|
|
|
@ -835,6 +835,53 @@ export default class MessageSender {
|
|||
|
||||
// Proto assembly
|
||||
|
||||
async getTextAttachmentProto(
|
||||
attachmentAttrs: Attachment.TextAttachmentType
|
||||
): Promise<Proto.TextAttachment> {
|
||||
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<MessageOptionsType>
|
||||
): Promise<Uint8Array> {
|
||||
|
@ -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<Proto.StoryMessage> {
|
||||
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<MessageOptionsType>
|
||||
): Promise<Proto.Content> {
|
||||
|
@ -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<string>;
|
||||
|
@ -1270,6 +1373,9 @@ export default class MessageSender {
|
|||
expirationStartTimestamp
|
||||
);
|
||||
}
|
||||
if (storyMessage) {
|
||||
sentMessage.storyMessage = storyMessage;
|
||||
}
|
||||
if (storyMessageRecipients) {
|
||||
sentMessage.storyMessageRecipients = storyMessageRecipients.map(
|
||||
recipient => {
|
||||
|
|
|
@ -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' ||
|
||||
|
|
19
ts/util/getSignalConnections.ts
Normal file
19
ts/util/getSignalConnections.ts
Normal file
|
@ -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<ConversationModel> {
|
||||
return window
|
||||
.getConversations()
|
||||
.filter(conversation => isSignalConnection(conversation.attributes));
|
||||
}
|
200
ts/util/sendStoryMessage.ts
Normal file
200
ts/util/sendStoryMessage.ts
Normal file
|
@ -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<string>,
|
||||
textAttachment: TextAttachmentType
|
||||
): Promise<void> {
|
||||
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<UUIDStringType, boolean>();
|
||||
|
||||
// * 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<UUIDStringType> = [];
|
||||
|
||||
if (
|
||||
distributionList.id === MY_STORIES_ID &&
|
||||
distributionList.isBlockList
|
||||
) {
|
||||
const inBlockList = new Set<UUIDStringType>(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<UUIDStringType>
|
||||
);
|
||||
} 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<MessageAttributesType> = await Promise.all(
|
||||
distributionLists.map(async distributionList => {
|
||||
const sendStateByConversationId = sendStateByListId.get(
|
||||
distributionList.id
|
||||
);
|
||||
|
||||
if (!sendStateByConversationId) {
|
||||
log.warn(
|
||||
'stories.sendStoryMessage: No sendStateByConversationId for distribution list',
|
||||
distributionList.id
|
||||
);
|
||||
}
|
||||
|
||||
return window.Signal.Migrations.upgradeMessageSchema({
|
||||
attachments: [
|
||||
{
|
||||
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));
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue