Send text attachment stories
This commit is contained in:
parent
0340f4ee1d
commit
9eff67446f
|
@ -7365,6 +7365,14 @@
|
||||||
"message": "Story settings",
|
"message": "Story settings",
|
||||||
"description": "Button label to get to 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": {
|
"Stories__settings-toggle--title": {
|
||||||
"message": "Share & View Stories",
|
"message": "Share & View Stories",
|
||||||
"description": "Select box title for the stories on/off toggle"
|
"description": "Select box title for the stories on/off toggle"
|
||||||
|
@ -7517,6 +7525,14 @@
|
||||||
"message": "Condensed",
|
"message": "Condensed",
|
||||||
"description": "Label for font"
|
"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": {
|
"StoryCreator__link-preview-placeholder": {
|
||||||
"message": "Type or paste a URL",
|
"message": "Type or paste a URL",
|
||||||
"description": "Placeholder for the URL input for link previews"
|
"description": "Placeholder for the URL input for link previews"
|
||||||
|
|
|
@ -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/SearchResultsLoadingFakeHeader.scss';
|
||||||
@import './components/SearchResultsLoadingFakeRow.scss';
|
@import './components/SearchResultsLoadingFakeRow.scss';
|
||||||
@import './components/Select.scss';
|
@import './components/Select.scss';
|
||||||
|
@import './components/SendStoryModal.scss';
|
||||||
@import './components/SignalConnectionsModal.scss';
|
@import './components/SignalConnectionsModal.scss';
|
||||||
@import './components/Slider.scss';
|
@import './components/Slider.scss';
|
||||||
@import './components/StagedLinkPreview.scss';
|
@import './components/StagedLinkPreview.scss';
|
||||||
|
|
|
@ -8,6 +8,11 @@ import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
children?: (childrenOpts: {
|
||||||
|
id: string;
|
||||||
|
checkboxNode: JSX.Element;
|
||||||
|
labelNode: JSX.Element;
|
||||||
|
}) => JSX.Element;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isRadio?: boolean;
|
isRadio?: boolean;
|
||||||
|
@ -20,6 +25,7 @@ export type PropsType = {
|
||||||
|
|
||||||
export const Checkbox = ({
|
export const Checkbox = ({
|
||||||
checked,
|
checked,
|
||||||
|
children,
|
||||||
description,
|
description,
|
||||||
disabled,
|
disabled,
|
||||||
isRadio,
|
isRadio,
|
||||||
|
@ -31,26 +37,41 @@ export const Checkbox = ({
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const getClassName = getClassNamesFor('Checkbox', moduleClassName);
|
const getClassName = getClassNamesFor('Checkbox', moduleClassName);
|
||||||
const id = useMemo(() => `${name}::${uuid()}`, [name]);
|
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 (
|
return (
|
||||||
<div className={getClassName('')}>
|
<div className={getClassName('')}>
|
||||||
<div className={getClassName('__container')}>
|
<div className={getClassName('__container')}>
|
||||||
<div className={getClassName('__checkbox')}>
|
{children ? (
|
||||||
<input
|
children({ id, checkboxNode, labelNode })
|
||||||
checked={Boolean(checked)}
|
) : (
|
||||||
disabled={disabled}
|
<>
|
||||||
id={id}
|
{checkboxNode}
|
||||||
name={name}
|
{labelNode}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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(),
|
||||||
|
};
|
|
@ -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 type { PropsType } from './StoriesSettingsModal';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import { MY_STORIES_ID } from '../types/Stories';
|
|
||||||
import { StoriesSettingsModal } from './StoriesSettingsModal';
|
import { StoriesSettingsModal } from './StoriesSettingsModal';
|
||||||
import { UUID } from '../types/UUID';
|
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
|
import {
|
||||||
|
getMyStories,
|
||||||
|
getFakeDistributionList,
|
||||||
|
} from '../test-both/helpers/getFakeDistributionLists';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -46,60 +48,59 @@ export default {
|
||||||
const Template: Story<PropsType> = args => <StoriesSettingsModal {...args} />;
|
const Template: Story<PropsType> = args => <StoriesSettingsModal {...args} />;
|
||||||
|
|
||||||
export const MyStories = Template.bind({});
|
export const MyStories = Template.bind({});
|
||||||
MyStories.args = {
|
{
|
||||||
distributionLists: [
|
const myStories = getMyStories();
|
||||||
{
|
MyStories.args = {
|
||||||
allowsReplies: true,
|
distributionLists: [
|
||||||
id: MY_STORIES_ID,
|
{
|
||||||
isBlockList: false,
|
...myStories,
|
||||||
members: [],
|
members: [],
|
||||||
name: MY_STORIES_ID,
|
},
|
||||||
},
|
],
|
||||||
],
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const MyStoriesBlockList = Template.bind({});
|
export const MyStoriesBlockList = Template.bind({});
|
||||||
MyStoriesBlockList.args = {
|
{
|
||||||
distributionLists: [
|
const myStories = getMyStories();
|
||||||
{
|
MyStoriesBlockList.args = {
|
||||||
allowsReplies: true,
|
distributionLists: [
|
||||||
id: MY_STORIES_ID,
|
{
|
||||||
isBlockList: true,
|
...myStories,
|
||||||
members: Array.from(Array(2), () => getDefaultConversation()),
|
members: Array.from(Array(2), () => getDefaultConversation()),
|
||||||
name: MY_STORIES_ID,
|
},
|
||||||
},
|
],
|
||||||
],
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const MyStoriesExclusive = Template.bind({});
|
export const MyStoriesExclusive = Template.bind({});
|
||||||
MyStoriesExclusive.args = {
|
{
|
||||||
distributionLists: [
|
const myStories = getMyStories();
|
||||||
{
|
MyStoriesExclusive.args = {
|
||||||
allowsReplies: false,
|
distributionLists: [
|
||||||
id: MY_STORIES_ID,
|
{
|
||||||
isBlockList: false,
|
...myStories,
|
||||||
members: Array.from(Array(11), () => getDefaultConversation()),
|
isBlockList: false,
|
||||||
name: MY_STORIES_ID,
|
members: Array.from(Array(11), () => getDefaultConversation()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const SingleList = Template.bind({});
|
export const SingleList = Template.bind({});
|
||||||
SingleList.args = {
|
{
|
||||||
distributionLists: [
|
const myStories = getMyStories();
|
||||||
{
|
const fakeDistroList = getFakeDistributionList();
|
||||||
allowsReplies: true,
|
SingleList.args = {
|
||||||
id: MY_STORIES_ID,
|
distributionLists: [
|
||||||
isBlockList: false,
|
{
|
||||||
members: [],
|
...myStories,
|
||||||
name: MY_STORIES_ID,
|
members: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
allowsReplies: true,
|
...fakeDistroList,
|
||||||
id: UUID.generate().toString(),
|
members: fakeDistroList.memberUuids.map(() => getDefaultConversation()),
|
||||||
isBlockList: false,
|
},
|
||||||
members: Array.from(Array(4), () => getDefaultConversation()),
|
],
|
||||||
name: 'Thailand 2021',
|
};
|
||||||
},
|
}
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
|
@ -3,12 +3,13 @@
|
||||||
|
|
||||||
import type { Meta, Story } from '@storybook/react';
|
import type { Meta, Story } from '@storybook/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
|
|
||||||
import type { PropsType } from './StoryCreator';
|
import type { PropsType } from './StoryCreator';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import { StoryCreator } from './StoryCreator';
|
import { StoryCreator } from './StoryCreator';
|
||||||
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
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';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
@ -16,26 +17,34 @@ const i18n = setupI18n('en', enMessages);
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/StoryCreator',
|
title: 'Components/StoryCreator',
|
||||||
component: 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;
|
} as Meta;
|
||||||
|
|
||||||
const getDefaultProps = (): PropsType => ({
|
|
||||||
debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'),
|
|
||||||
i18n,
|
|
||||||
onClose: action('onClose'),
|
|
||||||
onNext: action('onNext'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const Template: Story<PropsType> = args => <StoryCreator {...args} />;
|
const Template: Story<PropsType> = args => <StoryCreator {...args} />;
|
||||||
|
|
||||||
export const Default = Template.bind({});
|
export const Default = Template.bind({});
|
||||||
Default.args = getDefaultProps();
|
Default.args = {};
|
||||||
Default.story = {
|
Default.story = {
|
||||||
name: 'w/o Link Preview available',
|
name: 'w/o Link Preview available',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinkPreview = Template.bind({});
|
export const LinkPreview = Template.bind({});
|
||||||
LinkPreview.args = {
|
LinkPreview.args = {
|
||||||
...getDefaultProps(),
|
|
||||||
linkPreview: {
|
linkPreview: {
|
||||||
domain: 'www.catsandkittens.lolcats',
|
domain: 'www.catsandkittens.lolcats',
|
||||||
image: fakeAttachment({
|
image: fakeAttachment({
|
||||||
|
|
|
@ -7,9 +7,12 @@ import classNames from 'classnames';
|
||||||
import { get, has } from 'lodash';
|
import { get, has } from 'lodash';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
|
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
|
||||||
import type { TextAttachmentType } from '../types/Attachment';
|
import type { TextAttachmentType } from '../types/Attachment';
|
||||||
|
import type { UUIDStringType } from '../types/UUID';
|
||||||
|
|
||||||
import { Button, ButtonVariant } from './Button';
|
import { Button, ButtonVariant } from './Button';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
@ -17,6 +20,7 @@ import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview';
|
||||||
import { Input } from './Input';
|
import { Input } from './Input';
|
||||||
import { Slider } from './Slider';
|
import { Slider } from './Slider';
|
||||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||||
|
import { SendStoryModal } from './SendStoryModal';
|
||||||
import { TextAttachment } from './TextAttachment';
|
import { TextAttachment } from './TextAttachment';
|
||||||
import { Theme, themeClassName } from '../util/theme';
|
import { Theme, themeClassName } from '../util/theme';
|
||||||
import { getRGBA, getRGBANumber } from '../mediaEditor/util/color';
|
import { getRGBA, getRGBANumber } from '../mediaEditor/util/color';
|
||||||
|
@ -32,10 +36,16 @@ export type PropsType = {
|
||||||
message: string,
|
message: string,
|
||||||
source: LinkPreviewSourceType
|
source: LinkPreviewSourceType
|
||||||
) => unknown;
|
) => unknown;
|
||||||
|
distributionLists: Array<StoryDistributionListDataType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
linkPreview?: LinkPreviewType;
|
linkPreview?: LinkPreviewType;
|
||||||
|
me: ConversationType;
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
onNext: () => unknown;
|
onSend: (
|
||||||
|
listIds: Array<UUIDStringType>,
|
||||||
|
textAttachment: TextAttachmentType
|
||||||
|
) => unknown;
|
||||||
|
signalConnections: Array<ConversationType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum TextStyle {
|
enum TextStyle {
|
||||||
|
@ -92,10 +102,13 @@ function getBackground(
|
||||||
|
|
||||||
export const StoryCreator = ({
|
export const StoryCreator = ({
|
||||||
debouncedMaybeGrabLinkPreview,
|
debouncedMaybeGrabLinkPreview,
|
||||||
|
distributionLists,
|
||||||
i18n,
|
i18n,
|
||||||
linkPreview,
|
linkPreview,
|
||||||
|
me,
|
||||||
onClose,
|
onClose,
|
||||||
onNext,
|
onSend,
|
||||||
|
signalConnections,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const [isEditingText, setIsEditingText] = useState(false);
|
const [isEditingText, setIsEditingText] = useState(false);
|
||||||
const [selectedBackground, setSelectedBackground] =
|
const [selectedBackground, setSelectedBackground] =
|
||||||
|
@ -106,6 +119,7 @@ export const StoryCreator = ({
|
||||||
);
|
);
|
||||||
const [sliderValue, setSliderValue] = useState<number>(100);
|
const [sliderValue, setSliderValue] = useState<number>(100);
|
||||||
const [text, setText] = useState<string>('');
|
const [text, setText] = useState<string>('');
|
||||||
|
const [hasSendToModal, setHasSendToModal] = useState(false);
|
||||||
|
|
||||||
const textEditorRef = useRef<HTMLInputElement | null>(null);
|
const textEditorRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
@ -229,266 +243,289 @@ export const StoryCreator = ({
|
||||||
textForegroundColor = COLOR_WHITE_INT;
|
textForegroundColor = COLOR_WHITE_INT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textAttachment: TextAttachmentType = {
|
||||||
|
...getBackground(selectedBackground),
|
||||||
|
text,
|
||||||
|
textStyle,
|
||||||
|
textForegroundColor,
|
||||||
|
textBackgroundColor,
|
||||||
|
preview: hasLinkPreviewApplied ? linkPreview : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = Boolean(text || hasLinkPreviewApplied);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
<>
|
||||||
<div className="StoryCreator">
|
{hasSendToModal && (
|
||||||
<div className="StoryCreator__container">
|
<SendStoryModal
|
||||||
<TextAttachment
|
distributionLists={distributionLists}
|
||||||
disableLinkPreviewPopup
|
i18n={i18n}
|
||||||
i18n={i18n}
|
me={me}
|
||||||
isEditingText={isEditingText}
|
onClose={() => setHasSendToModal(false)}
|
||||||
onChange={setText}
|
onSend={listIds => {
|
||||||
onClick={() => {
|
onSend(listIds, textAttachment);
|
||||||
if (!isEditingText) {
|
setHasSendToModal(false);
|
||||||
setIsEditingText(true);
|
onClose();
|
||||||
}
|
}}
|
||||||
}}
|
signalConnections={signalConnections}
|
||||||
onRemoveLinkPreview={() => {
|
/>
|
||||||
setHasLinkPreviewApplied(false);
|
)}
|
||||||
}}
|
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||||
textAttachment={{
|
<div className="StoryCreator">
|
||||||
...getBackground(selectedBackground),
|
<div className="StoryCreator__container">
|
||||||
text,
|
<TextAttachment
|
||||||
textStyle,
|
disableLinkPreviewPopup
|
||||||
textForegroundColor,
|
i18n={i18n}
|
||||||
textBackgroundColor,
|
isEditingText={isEditingText}
|
||||||
preview: hasLinkPreviewApplied ? linkPreview : undefined,
|
onChange={setText}
|
||||||
}}
|
onClick={() => {
|
||||||
/>
|
if (!isEditingText) {
|
||||||
</div>
|
setIsEditingText(true);
|
||||||
<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)
|
|
||||||
}
|
}
|
||||||
ref={setLinkPreviewInputPopperButtonRef}
|
}}
|
||||||
type="button"
|
onRemoveLinkPreview={() => {
|
||||||
/>
|
setHasLinkPreviewApplied(false);
|
||||||
{isLinkPreviewInputShowing && (
|
}}
|
||||||
<div
|
textAttachment={textAttachment}
|
||||||
className={classNames(
|
/>
|
||||||
'StoryCreator__popper StoryCreator__link-preview-input-popper',
|
</div>
|
||||||
themeClassName(Theme.Dark)
|
<div className="StoryCreator__toolbar">
|
||||||
)}
|
{isEditingText ? (
|
||||||
ref={setLinkPreviewInputPopperRef}
|
<div className="StoryCreator__tools">
|
||||||
style={linkPreviewInputPopper.styles.popper}
|
<Slider
|
||||||
{...linkPreviewInputPopper.attributes.popper}
|
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
|
<div
|
||||||
data-popper-arrow
|
className="StoryCreator__popper"
|
||||||
className="StoryCreator__popper__arrow"
|
ref={setColorPickerPopperRef}
|
||||||
/>
|
style={colorPickerPopper.styles.popper}
|
||||||
<Input
|
{...colorPickerPopper.attributes.popper}
|
||||||
disableSpellcheck
|
>
|
||||||
i18n={i18n}
|
<div
|
||||||
moduleClassName="StoryCreator__link-preview-input"
|
data-popper-arrow
|
||||||
onChange={setLinkPreviewInputValue}
|
className="StoryCreator__popper__arrow"
|
||||||
placeholder={i18n('StoryCreator__link-preview-placeholder')}
|
/>
|
||||||
ref={el => el?.focus()}
|
{objectMap<BackgroundStyleType>(
|
||||||
value={linkPreviewInputValue}
|
BackgroundStyle,
|
||||||
/>
|
(bg, backgroundValue) => (
|
||||||
<div className="StoryCreator__link-preview-container">
|
<button
|
||||||
{linkPreview ? (
|
aria-label={i18n('StoryCreator__story-bg')}
|
||||||
<>
|
className={classNames({
|
||||||
<StagedLinkPreview
|
StoryCreator__bg: true,
|
||||||
domain={linkPreview.domain}
|
'StoryCreator__bg--selected':
|
||||||
i18n={i18n}
|
selectedBackground === backgroundValue,
|
||||||
image={linkPreview.image}
|
})}
|
||||||
moduleClassName="StoryCreator__link-preview"
|
key={String(bg)}
|
||||||
title={linkPreview.title}
|
|
||||||
url={linkPreview.url}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="StoryCreator__link-preview-button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHasLinkPreviewApplied(true);
|
setSelectedBackground(backgroundValue);
|
||||||
setIsLinkPreviewInputShowing(false);
|
setIsColorPickerShowing(false);
|
||||||
}}
|
}}
|
||||||
theme={Theme.Dark}
|
type="button"
|
||||||
variant={ButtonVariant.Primary}
|
style={{
|
||||||
>
|
background: getBackgroundColor(
|
||||||
{i18n('StoryCreator__add-link')}
|
getBackground(backgroundValue)
|
||||||
</Button>
|
),
|
||||||
</>
|
}}
|
||||||
) : (
|
/>
|
||||||
<div className="StoryCreator__link-preview-empty">
|
)
|
||||||
<div className="StoryCreator__link-preview-empty__icon" />
|
|
||||||
{i18n('StoryCreator__link-preview-empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
theme={Theme.Dark}
|
|
||||||
variant={ButtonVariant.Primary}
|
|
||||||
>
|
|
||||||
{i18n('StoryCreator__next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FocusTrap>
|
||||||
</FocusTrap>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { sendGroupUpdate } from './helpers/sendGroupUpdate';
|
||||||
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
|
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
|
||||||
import { sendProfileKey } from './helpers/sendProfileKey';
|
import { sendProfileKey } from './helpers/sendProfileKey';
|
||||||
import { sendReaction } from './helpers/sendReaction';
|
import { sendReaction } from './helpers/sendReaction';
|
||||||
|
import { sendStory } from './helpers/sendStory';
|
||||||
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import { ConversationVerificationState } from '../state/ducks/conversationsEnums';
|
import { ConversationVerificationState } from '../state/ducks/conversationsEnums';
|
||||||
|
@ -44,6 +45,7 @@ export const conversationQueueJobEnum = z.enum([
|
||||||
'NormalMessage',
|
'NormalMessage',
|
||||||
'ProfileKey',
|
'ProfileKey',
|
||||||
'Reaction',
|
'Reaction',
|
||||||
|
'Story',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const deleteForEveryoneJobDataSchema = z.object({
|
const deleteForEveryoneJobDataSchema = z.object({
|
||||||
|
@ -105,6 +107,17 @@ const reactionJobDataSchema = z.object({
|
||||||
});
|
});
|
||||||
export type ReactionJobData = z.infer<typeof reactionJobDataSchema>;
|
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([
|
export const conversationQueueJobDataSchema = z.union([
|
||||||
deleteForEveryoneJobDataSchema,
|
deleteForEveryoneJobDataSchema,
|
||||||
expirationTimerUpdateJobDataSchema,
|
expirationTimerUpdateJobDataSchema,
|
||||||
|
@ -112,6 +125,7 @@ export const conversationQueueJobDataSchema = z.union([
|
||||||
normalMessageSendJobDataSchema,
|
normalMessageSendJobDataSchema,
|
||||||
profileKeyJobDataSchema,
|
profileKeyJobDataSchema,
|
||||||
reactionJobDataSchema,
|
reactionJobDataSchema,
|
||||||
|
storyJobDataSchema,
|
||||||
]);
|
]);
|
||||||
export type ConversationQueueJobData = z.infer<
|
export type ConversationQueueJobData = z.infer<
|
||||||
typeof conversationQueueJobDataSchema
|
typeof conversationQueueJobDataSchema
|
||||||
|
@ -332,6 +346,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
|
||||||
case jobSet.Reaction:
|
case jobSet.Reaction:
|
||||||
await sendReaction(conversation, jobBundle, data);
|
await sendReaction(conversation, jobBundle, data);
|
||||||
break;
|
break;
|
||||||
|
case jobSet.Story:
|
||||||
|
await sendStory(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
default: {
|
default: {
|
||||||
// Note: This should never happen, because the zod call in parseData wouldn't
|
// Note: This should never happen, because the zod call in parseData wouldn't
|
||||||
// accept data that doesn't look like our type specification.
|
// accept data that doesn't look like our type specification.
|
||||||
|
|
|
@ -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.
|
* The timestamp may be undefined if reading old data, which did not store a timestamp.
|
||||||
*/
|
*/
|
||||||
export type SendState = Readonly<{
|
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;
|
isAllowedToReplyToStory?: boolean;
|
||||||
status:
|
status:
|
||||||
| SendStatus.Pending
|
| 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
|
// Set when sending some sync messages, so we get the functionality of
|
||||||
// send(), without zombie messages going into the database.
|
// send(), without zombie messages going into the database.
|
||||||
doNotSave?: boolean;
|
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;
|
INITIAL_PROTOCOL_VERSION?: number;
|
||||||
|
|
||||||
|
@ -1575,7 +1578,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
updateLeftPane();
|
updateLeftPane();
|
||||||
|
|
||||||
if (sentToAtLeastOneRecipient) {
|
if (sentToAtLeastOneRecipient && !this.doNotSendSyncMessage) {
|
||||||
promises.push(this.sendSyncMessage());
|
promises.push(this.sendSyncMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,17 +3,22 @@
|
||||||
|
|
||||||
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
||||||
import { isEqual, noop, pick } from 'lodash';
|
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 { BodyRangeType } from '../../types/Util';
|
||||||
import type { MessageAttributesType } from '../../model-types.d';
|
import type { MessageAttributesType } from '../../model-types.d';
|
||||||
import type {
|
import type {
|
||||||
MessageChangedActionType,
|
MessageChangedActionType,
|
||||||
MessageDeletedActionType,
|
MessageDeletedActionType,
|
||||||
|
MessagesAddedActionType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
import type { StoryViewType } from '../../types/Stories';
|
import type { StoryViewType } from '../../types/Stories';
|
||||||
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
||||||
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import dataInterface from '../../sql/Client';
|
import dataInterface from '../../sql/Client';
|
||||||
import { DAY } from '../../util/durations';
|
import { DAY } from '../../util/durations';
|
||||||
|
@ -36,8 +41,12 @@ import {
|
||||||
import { getConversationSelector } from '../selectors/conversations';
|
import { getConversationSelector } from '../selectors/conversations';
|
||||||
import { getSendOptions } from '../../util/getSendOptions';
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
import { getStories } from '../selectors/stories';
|
import { getStories } from '../selectors/stories';
|
||||||
|
import { getStoryDataFromMessageAttributes } from '../../services/storyLoader';
|
||||||
import { isGroup } from '../../util/whatTypeOfConversation';
|
import { isGroup } from '../../util/whatTypeOfConversation';
|
||||||
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
|
import { isStory } from '../../messages/helpers';
|
||||||
import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate';
|
import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate';
|
||||||
|
import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
|
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
|
||||||
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
||||||
|
@ -147,6 +156,7 @@ export type StoriesActionType =
|
||||||
| MarkStoryReadActionType
|
| MarkStoryReadActionType
|
||||||
| MessageChangedActionType
|
| MessageChangedActionType
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
|
| MessagesAddedActionType
|
||||||
| ReplyToStoryActionType
|
| ReplyToStoryActionType
|
||||||
| ResolveAttachmentUrlActionType
|
| ResolveAttachmentUrlActionType
|
||||||
| StoryChangedActionType
|
| 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 {
|
function storyChanged(story: StoryDataType): StoryChangedActionType {
|
||||||
return {
|
return {
|
||||||
type: STORY_CHANGED,
|
type: STORY_CHANGED,
|
||||||
|
@ -896,6 +920,7 @@ export const actions = {
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
reactToStory,
|
reactToStory,
|
||||||
replyToStory,
|
replyToStory,
|
||||||
|
sendStoryMessage,
|
||||||
storyChanged,
|
storyChanged,
|
||||||
toggleStoriesView,
|
toggleStoriesView,
|
||||||
viewUserStories,
|
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
|
// For live updating of the story replies
|
||||||
if (
|
if (
|
||||||
action.type === 'MESSAGE_CHANGED' &&
|
action.type === 'MESSAGE_CHANGED' &&
|
||||||
|
|
|
@ -37,6 +37,7 @@ import { ContactNameColors } from '../../types/Colors';
|
||||||
import type { AvatarDataType } from '../../types/Avatar';
|
import type { AvatarDataType } from '../../types/Avatar';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
import { isInSystemContacts } from '../../util/isInSystemContacts';
|
import { isInSystemContacts } from '../../util/isInSystemContacts';
|
||||||
|
import { isSignalConnection } from '../../util/getSignalConnections';
|
||||||
import { sortByTitle } from '../../util/sortByTitle';
|
import { sortByTitle } from '../../util/sortByTitle';
|
||||||
import {
|
import {
|
||||||
isDirectConversation,
|
isDirectConversation,
|
||||||
|
@ -127,6 +128,12 @@ export const getAllConversations = createSelector(
|
||||||
(lookup): Array<ConversationType> => Object.values(lookup)
|
(lookup): Array<ConversationType> => Object.values(lookup)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getAllSignalConnections = createSelector(
|
||||||
|
getAllConversations,
|
||||||
|
(conversations): Array<ConversationType> =>
|
||||||
|
conversations.filter(isSignalConnection)
|
||||||
|
);
|
||||||
|
|
||||||
export const getConversationsByTitleSelector = createSelector(
|
export const getConversationsByTitleSelector = createSelector(
|
||||||
getAllConversations,
|
getAllConversations,
|
||||||
(conversations): ((title: string) => Array<ConversationType>) =>
|
(conversations): ((title: string) => Array<ConversationType>) =>
|
||||||
|
|
|
@ -3,15 +3,17 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { noop } from 'lodash';
|
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
||||||
import { StoryCreator } from '../../components/StoryCreator';
|
import { StoryCreator } from '../../components/StoryCreator';
|
||||||
|
import { getDistributionLists } from '../selectors/storyDistributionLists';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { getLinkPreview } from '../selectors/linkPreviews';
|
import { getLinkPreview } from '../selectors/linkPreviews';
|
||||||
|
import { getAllSignalConnections, getMe } from '../selectors/conversations';
|
||||||
import { useLinkPreviewActions } from '../ducks/linkPreviews';
|
import { useLinkPreviewActions } from '../ducks/linkPreviews';
|
||||||
|
import { useStoriesActions } from '../ducks/stories';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
|
@ -19,17 +21,24 @@ export type PropsType = {
|
||||||
|
|
||||||
export function SmartStoryCreator({ onClose }: PropsType): JSX.Element | null {
|
export function SmartStoryCreator({ onClose }: PropsType): JSX.Element | null {
|
||||||
const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions();
|
const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions();
|
||||||
|
const { sendStoryMessage } = useStoriesActions();
|
||||||
|
|
||||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||||
const linkPreviewForSource = useSelector(getLinkPreview);
|
const linkPreviewForSource = useSelector(getLinkPreview);
|
||||||
|
const distributionLists = useSelector(getDistributionLists);
|
||||||
|
const me = useSelector(getMe);
|
||||||
|
const signalConnections = useSelector(getAllSignalConnections);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StoryCreator
|
<StoryCreator
|
||||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||||
|
distributionLists={distributionLists}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
|
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
|
||||||
|
me={me}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onNext={noop}
|
onSend={sendStoryMessage}
|
||||||
|
signalConnections={signalConnections}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 * as durations from '../util/durations';
|
||||||
import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
|
import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
|
||||||
import { generateBlurHash } from '../util/generateBlurHash';
|
import { generateBlurHash } from '../util/generateBlurHash';
|
||||||
import { APPLICATION_OCTET_STREAM } from '../types/MIME';
|
import { TEXT_ATTACHMENT } from '../types/MIME';
|
||||||
import type { SendTypesType } from '../util/handleMessageSend';
|
import type { SendTypesType } from '../util/handleMessageSend';
|
||||||
|
|
||||||
const GROUPV1_ID_LENGTH = 16;
|
const GROUPV1_ID_LENGTH = 16;
|
||||||
|
@ -1884,7 +1884,7 @@ export default class MessageReceiver
|
||||||
// TODO DESKTOP-3714 we should download the story link preview image
|
// TODO DESKTOP-3714 we should download the story link preview image
|
||||||
attachments.push({
|
attachments.push({
|
||||||
size: text.length,
|
size: text.length,
|
||||||
contentType: APPLICATION_OCTET_STREAM,
|
contentType: TEXT_ATTACHMENT,
|
||||||
textAttachment: msg.textAttachment,
|
textAttachment: msg.textAttachment,
|
||||||
blurHash: generateBlurHash(
|
blurHash: generateBlurHash(
|
||||||
(msg.textAttachment.color ||
|
(msg.textAttachment.color ||
|
||||||
|
|
|
@ -835,6 +835,53 @@ export default class MessageSender {
|
||||||
|
|
||||||
// Proto assembly
|
// 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(
|
async getDataMessage(
|
||||||
options: Readonly<MessageOptionsType>
|
options: Readonly<MessageOptionsType>
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
|
@ -842,6 +889,60 @@ export default class MessageSender {
|
||||||
return message.encode();
|
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(
|
async getContentMessage(
|
||||||
options: Readonly<MessageOptionsType>
|
options: Readonly<MessageOptionsType>
|
||||||
): Promise<Proto.Content> {
|
): Promise<Proto.Content> {
|
||||||
|
@ -1232,6 +1333,7 @@ export default class MessageSender {
|
||||||
isUpdate,
|
isUpdate,
|
||||||
urgent,
|
urgent,
|
||||||
options,
|
options,
|
||||||
|
storyMessage,
|
||||||
storyMessageRecipients,
|
storyMessageRecipients,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
encodedDataMessage?: Uint8Array;
|
encodedDataMessage?: Uint8Array;
|
||||||
|
@ -1244,6 +1346,7 @@ export default class MessageSender {
|
||||||
isUpdate?: boolean;
|
isUpdate?: boolean;
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
options?: SendOptionsType;
|
options?: SendOptionsType;
|
||||||
|
storyMessage?: Proto.StoryMessage;
|
||||||
storyMessageRecipients?: Array<{
|
storyMessageRecipients?: Array<{
|
||||||
destinationUuid: string;
|
destinationUuid: string;
|
||||||
distributionListIds: Array<string>;
|
distributionListIds: Array<string>;
|
||||||
|
@ -1270,6 +1373,9 @@ export default class MessageSender {
|
||||||
expirationStartTimestamp
|
expirationStartTimestamp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (storyMessage) {
|
||||||
|
sentMessage.storyMessage = storyMessage;
|
||||||
|
}
|
||||||
if (storyMessageRecipients) {
|
if (storyMessageRecipients) {
|
||||||
sentMessage.storyMessageRecipients = storyMessageRecipients.map(
|
sentMessage.storyMessageRecipients = storyMessageRecipients.map(
|
||||||
recipient => {
|
recipient => {
|
||||||
|
|
|
@ -25,6 +25,7 @@ export const IMAGE_BMP = stringToMIMEType('image/bmp');
|
||||||
export const VIDEO_MP4 = stringToMIMEType('video/mp4');
|
export const VIDEO_MP4 = stringToMIMEType('video/mp4');
|
||||||
export const VIDEO_QUICKTIME = stringToMIMEType('video/quicktime');
|
export const VIDEO_QUICKTIME = stringToMIMEType('video/quicktime');
|
||||||
export const LONG_MESSAGE = stringToMIMEType('text/x-signal-plain');
|
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 =>
|
export const isHeic = (value: string, fileName: string): boolean =>
|
||||||
value === 'image/heic' ||
|
value === 'image/heic' ||
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
|
@ -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 New Issue