Send text attachment stories

This commit is contained in:
Josh Perez 2022-08-02 15:31:55 -04:00 committed by GitHub
parent 0340f4ee1d
commit 9eff67446f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1635 additions and 339 deletions

View file

@ -7365,6 +7365,14 @@
"message": "Story settings",
"description": "Button label to get to story settings"
},
"SendStoryModal__title": {
"message": "Send to",
"description": "Title for the send story modal"
},
"SendStoryModal__send": {
"message": "Send story",
"description": "aria-label for the send story button"
},
"Stories__settings-toggle--title": {
"message": "Share & View Stories",
"description": "Select box title for the stories on/off toggle"
@ -7517,6 +7525,14 @@
"message": "Condensed",
"description": "Label for font"
},
"StoryCreator__control--text": {
"message": "Add story text",
"description": "aria-label for edit text button"
},
"StoryCreator__control--link": {
"message": "Add a link",
"description": "aria-label for adding a link preview"
},
"StoryCreator__link-preview-placeholder": {
"message": "Type or paste a URL",
"description": "Placeholder for the URL input for link previews"

View file

@ -0,0 +1,67 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.SendStoryModal {
&__distribution-list {
&__container {
justify-content: space-between;
margin: 8px 0;
user-select: none;
width: 100%;
}
&__info {
margin-left: 8px;
}
&__label {
align-items: center;
display: flex;
justify-content: flex-start;
flex: 1;
}
&__name {
@include font-body-1-bold;
}
&__description {
@include font-body-2;
color: $color-gray-60;
}
}
&__button-footer {
align-items: center;
justify-content: space-between;
}
&__selected-lists {
@include font-body-2;
color: $color-gray-60;
max-width: 280px;
user-select: none;
}
&__send {
@include button-reset;
@include rounded-corners;
align-items: center;
background: $color-ultramarine;
display: flex;
height: 40px;
justify-content: center;
width: 40px;
&::disabled {
background: $color-gray-60;
}
&::after {
@include color-svg('../images/icons/v2/send-24.svg', $color-white);
content: '';
height: 24px;
width: 24px;
}
}
}

View file

@ -103,6 +103,7 @@
@import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss';
@import './components/Select.scss';
@import './components/SendStoryModal.scss';
@import './components/SignalConnectionsModal.scss';
@import './components/Slider.scss';
@import './components/StagedLinkPreview.scss';

View file

@ -8,6 +8,11 @@ import { getClassNamesFor } from '../util/getClassNamesFor';
export type PropsType = {
checked?: boolean;
children?: (childrenOpts: {
id: string;
checkboxNode: JSX.Element;
labelNode: JSX.Element;
}) => JSX.Element;
description?: string;
disabled?: boolean;
isRadio?: boolean;
@ -20,6 +25,7 @@ export type PropsType = {
export const Checkbox = ({
checked,
children,
description,
disabled,
isRadio,
@ -31,26 +37,41 @@ export const Checkbox = ({
}: PropsType): JSX.Element => {
const getClassName = getClassNamesFor('Checkbox', moduleClassName);
const id = useMemo(() => `${name}::${uuid()}`, [name]);
const checkboxNode = (
<div className={getClassName('__checkbox')}>
<input
checked={Boolean(checked)}
disabled={disabled}
id={id}
name={name}
onChange={ev => onChange(ev.target.checked)}
onClick={onClick}
type={isRadio ? 'radio' : 'checkbox'}
/>
</div>
);
const labelNode = (
<div>
<label htmlFor={id}>
<div>{label}</div>
<div className={getClassName('__description')}>{description}</div>
</label>
</div>
);
return (
<div className={getClassName('')}>
<div className={getClassName('__container')}>
<div className={getClassName('__checkbox')}>
<input
checked={Boolean(checked)}
disabled={disabled}
id={id}
name={name}
onChange={ev => onChange(ev.target.checked)}
onClick={onClick}
type={isRadio ? 'radio' : 'checkbox'}
/>
</div>
<div>
<label htmlFor={id}>
<div>{label}</div>
<div className={getClassName('__description')}>{description}</div>
</label>
</div>
{children ? (
children({ id, checkboxNode, labelNode })
) : (
<>
{checkboxNode}
{labelNode}
</>
)}
</div>
</div>
);

View file

@ -0,0 +1,45 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import type { PropsType } from './SendStoryModal';
import enMessages from '../../_locales/en/messages.json';
import { SendStoryModal } from './SendStoryModal';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import {
getMyStories,
getFakeDistributionLists,
} from '../test-both/helpers/getFakeDistributionLists';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/SendStoryModal',
component: SendStoryModal,
argTypes: {
distributionLists: {
defaultValue: [getMyStories()],
},
i18n: {
defaultValue: i18n,
},
me: {
defaultValue: getDefaultConversation(),
},
onClose: { action: true },
onSend: { action: true },
signalConnections: {
defaultValue: Array.from(Array(42), getDefaultConversation),
},
},
} as Meta;
const Template: Story<PropsType> = args => <SendStoryModal {...args} />;
export const Modal = Template.bind({});
Modal.args = {
distributionLists: getFakeDistributionLists(),
};

View file

@ -0,0 +1,153 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useMemo, useState } from 'react';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import type { UUIDStringType } from '../types/UUID';
import { Avatar, AvatarSize } from './Avatar';
import { Checkbox } from './Checkbox';
import { MY_STORIES_ID, getStoryDistributionListName } from '../types/Stories';
import { Modal } from './Modal';
import { StoryDistributionListName } from './StoryDistributionListName';
export type PropsType = {
distributionLists: Array<StoryDistributionListDataType>;
i18n: LocalizerType;
me: ConversationType;
onClose: () => unknown;
onSend: (listIds: Array<UUIDStringType>) => unknown;
signalConnections: Array<ConversationType>;
};
function getListViewers(
list: StoryDistributionListDataType,
i18n: LocalizerType,
signalConnections: Array<ConversationType>
): string {
let memberCount = list.memberUuids.length;
if (list.id === MY_STORIES_ID && list.isBlockList) {
memberCount = list.isBlockList
? signalConnections.length - list.memberUuids.length
: signalConnections.length;
}
return memberCount === 1
? i18n('StoriesSettingsModal__list__viewers--singular', ['1'])
: i18n('StoriesSettings__viewers--plural', [String(memberCount)]);
}
export const SendStoryModal = ({
distributionLists,
i18n,
me,
onClose,
onSend,
signalConnections,
}: PropsType): JSX.Element => {
const [selectedListIds, setSelectedListIds] = useState<Set<UUIDStringType>>(
new Set()
);
const selectedListNames = useMemo(
() =>
distributionLists
.filter(list => selectedListIds.has(list.id))
.map(list => list.name),
[distributionLists, selectedListIds]
);
return (
<Modal
hasXButton
i18n={i18n}
onClose={onClose}
title={i18n('SendStoryModal__title')}
>
{distributionLists.map(list => (
<Checkbox
checked={selectedListIds.has(list.id)}
key={list.id}
label={getStoryDistributionListName(i18n, list.id, list.name)}
moduleClassName="SendStoryModal__distribution-list"
name="SendStoryModal__distribution-list"
onChange={(value: boolean) => {
if (value) {
setSelectedListIds(listIds => {
listIds.add(list.id);
return new Set([...listIds]);
});
} else {
setSelectedListIds(listIds => {
listIds.delete(list.id);
return new Set([...listIds]);
});
}
}}
>
{({ id, checkboxNode }) => (
<>
<label
className="SendStoryModal__distribution-list__label"
htmlFor={id}
>
{list.id === MY_STORIES_ID ? (
<Avatar
acceptedMessageRequest={me.acceptedMessageRequest}
avatarPath={me.avatarPath}
badge={undefined}
color={me.color}
conversationType={me.type}
i18n={i18n}
isMe
sharedGroupNames={me.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
title={me.title}
/>
) : (
<span className="StoriesSettingsModal__list__avatar--private" />
)}
<div className="SendStoryModal__distribution-list__info">
<div className="SendStoryModal__distribution-list__name">
<StoryDistributionListName
i18n={i18n}
id={list.id}
name={list.name}
/>
</div>
<div className="SendStoryModal__distribution-list__description">
{getListViewers(list, i18n, signalConnections)}
</div>
</div>
</label>
{checkboxNode}
</>
)}
</Checkbox>
))}
<Modal.ButtonFooter moduleClassName="SendStoryModal">
<div className="SendStoryModal__selected-lists">
{selectedListNames
.map(listName =>
getStoryDistributionListName(i18n, listName, listName)
)
.join(', ')}
</div>
<button
aria-label="SendStoryModal__send"
className="SendStoryModal__send"
disabled={!selectedListIds.size}
onClick={() => {
onSend(Array.from(selectedListIds));
}}
type="button"
/>
</Modal.ButtonFooter>
</Modal>
);
};

View file

@ -6,11 +6,13 @@ import React from 'react';
import type { PropsType } from './StoriesSettingsModal';
import enMessages from '../../_locales/en/messages.json';
import { MY_STORIES_ID } from '../types/Stories';
import { StoriesSettingsModal } from './StoriesSettingsModal';
import { UUID } from '../types/UUID';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import {
getMyStories,
getFakeDistributionList,
} from '../test-both/helpers/getFakeDistributionLists';
const i18n = setupI18n('en', enMessages);
@ -46,60 +48,59 @@ export default {
const Template: Story<PropsType> = args => <StoriesSettingsModal {...args} />;
export const MyStories = Template.bind({});
MyStories.args = {
distributionLists: [
{
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: false,
members: [],
name: MY_STORIES_ID,
},
],
};
{
const myStories = getMyStories();
MyStories.args = {
distributionLists: [
{
...myStories,
members: [],
},
],
};
}
export const MyStoriesBlockList = Template.bind({});
MyStoriesBlockList.args = {
distributionLists: [
{
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: true,
members: Array.from(Array(2), () => getDefaultConversation()),
name: MY_STORIES_ID,
},
],
};
{
const myStories = getMyStories();
MyStoriesBlockList.args = {
distributionLists: [
{
...myStories,
members: Array.from(Array(2), () => getDefaultConversation()),
},
],
};
}
export const MyStoriesExclusive = Template.bind({});
MyStoriesExclusive.args = {
distributionLists: [
{
allowsReplies: false,
id: MY_STORIES_ID,
isBlockList: false,
members: Array.from(Array(11), () => getDefaultConversation()),
name: MY_STORIES_ID,
},
],
};
{
const myStories = getMyStories();
MyStoriesExclusive.args = {
distributionLists: [
{
...myStories,
isBlockList: false,
members: Array.from(Array(11), () => getDefaultConversation()),
},
],
};
}
export const SingleList = Template.bind({});
SingleList.args = {
distributionLists: [
{
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: false,
members: [],
name: MY_STORIES_ID,
},
{
allowsReplies: true,
id: UUID.generate().toString(),
isBlockList: false,
members: Array.from(Array(4), () => getDefaultConversation()),
name: 'Thailand 2021',
},
],
};
{
const myStories = getMyStories();
const fakeDistroList = getFakeDistributionList();
SingleList.args = {
distributionLists: [
{
...myStories,
members: [],
},
{
...fakeDistroList,
members: fakeDistroList.memberUuids.map(() => getDefaultConversation()),
},
],
};
}

View file

@ -3,12 +3,13 @@
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryCreator';
import enMessages from '../../_locales/en/messages.json';
import { StoryCreator } from './StoryCreator';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { getFakeDistributionLists } from '../test-both/helpers/getFakeDistributionLists';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
@ -16,26 +17,34 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryCreator',
component: StoryCreator,
argTypes: {
debouncedMaybeGrabLinkPreview: { action: true },
distributionLists: { defaultValue: getFakeDistributionLists() },
linkPreview: {
defaultValue: undefined,
},
i18n: { defaultValue: i18n },
me: {
defaultValue: getDefaultConversation(),
},
onClose: { action: true },
onSend: { action: true },
signalConnections: {
defaultValue: Array.from(Array(42), getDefaultConversation),
},
},
} as Meta;
const getDefaultProps = (): PropsType => ({
debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'),
i18n,
onClose: action('onClose'),
onNext: action('onNext'),
});
const Template: Story<PropsType> = args => <StoryCreator {...args} />;
export const Default = Template.bind({});
Default.args = getDefaultProps();
Default.args = {};
Default.story = {
name: 'w/o Link Preview available',
};
export const LinkPreview = Template.bind({});
LinkPreview.args = {
...getDefaultProps(),
linkPreview: {
domain: 'www.catsandkittens.lolcats',
image: fakeAttachment({

View file

@ -7,9 +7,12 @@ import classNames from 'classnames';
import { get, has } from 'lodash';
import { usePopper } from 'react-popper';
import type { ConversationType } from '../state/ducks/conversations';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { LocalizerType } from '../types/Util';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import type { TextAttachmentType } from '../types/Attachment';
import type { UUIDStringType } from '../types/UUID';
import { Button, ButtonVariant } from './Button';
import { ContextMenu } from './ContextMenu';
@ -17,6 +20,7 @@ import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview';
import { Input } from './Input';
import { Slider } from './Slider';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { SendStoryModal } from './SendStoryModal';
import { TextAttachment } from './TextAttachment';
import { Theme, themeClassName } from '../util/theme';
import { getRGBA, getRGBANumber } from '../mediaEditor/util/color';
@ -32,10 +36,16 @@ export type PropsType = {
message: string,
source: LinkPreviewSourceType
) => unknown;
distributionLists: Array<StoryDistributionListDataType>;
i18n: LocalizerType;
linkPreview?: LinkPreviewType;
me: ConversationType;
onClose: () => unknown;
onNext: () => unknown;
onSend: (
listIds: Array<UUIDStringType>,
textAttachment: TextAttachmentType
) => unknown;
signalConnections: Array<ConversationType>;
};
enum TextStyle {
@ -92,10 +102,13 @@ function getBackground(
export const StoryCreator = ({
debouncedMaybeGrabLinkPreview,
distributionLists,
i18n,
linkPreview,
me,
onClose,
onNext,
onSend,
signalConnections,
}: PropsType): JSX.Element => {
const [isEditingText, setIsEditingText] = useState(false);
const [selectedBackground, setSelectedBackground] =
@ -106,6 +119,7 @@ export const StoryCreator = ({
);
const [sliderValue, setSliderValue] = useState<number>(100);
const [text, setText] = useState<string>('');
const [hasSendToModal, setHasSendToModal] = useState(false);
const textEditorRef = useRef<HTMLInputElement | null>(null);
@ -229,266 +243,289 @@ export const StoryCreator = ({
textForegroundColor = COLOR_WHITE_INT;
}
const textAttachment: TextAttachmentType = {
...getBackground(selectedBackground),
text,
textStyle,
textForegroundColor,
textBackgroundColor,
preview: hasLinkPreviewApplied ? linkPreview : undefined,
};
const hasChanges = Boolean(text || hasLinkPreviewApplied);
return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryCreator">
<div className="StoryCreator__container">
<TextAttachment
disableLinkPreviewPopup
i18n={i18n}
isEditingText={isEditingText}
onChange={setText}
onClick={() => {
if (!isEditingText) {
setIsEditingText(true);
}
}}
onRemoveLinkPreview={() => {
setHasLinkPreviewApplied(false);
}}
textAttachment={{
...getBackground(selectedBackground),
text,
textStyle,
textForegroundColor,
textBackgroundColor,
preview: hasLinkPreviewApplied ? linkPreview : undefined,
}}
/>
</div>
<div className="StoryCreator__toolbar">
{isEditingText ? (
<div className="StoryCreator__tools">
<Slider
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="HueSlider StoryCreator__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'StoryCreator__icon--font-regular',
label: i18n('StoryCreator__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'StoryCreator__icon--font-bold',
label: i18n('StoryCreator__text--bold'),
onClick: () => setTextStyle(TextStyle.Bold),
value: TextStyle.Bold,
},
{
icon: 'StoryCreator__icon--font-serif',
label: i18n('StoryCreator__text--serif'),
onClick: () => setTextStyle(TextStyle.Serif),
value: TextStyle.Serif,
},
{
icon: 'StoryCreator__icon--font-script',
label: i18n('StoryCreator__text--script'),
onClick: () => setTextStyle(TextStyle.Script),
value: TextStyle.Script,
},
{
icon: 'StoryCreator__icon--font-condensed',
label: i18n('StoryCreator__text--condensed'),
onClick: () => setTextStyle(TextStyle.Condensed),
value: TextStyle.Condensed,
},
]}
moduleClassName={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--font-regular':
textStyle === TextStyle.Regular,
'StoryCreator__tools__button--font-bold':
textStyle === TextStyle.Bold,
'StoryCreator__tools__button--font-serif':
textStyle === TextStyle.Serif,
'StoryCreator__tools__button--font-script':
textStyle === TextStyle.Script,
'StoryCreator__tools__button--font-condensed':
textStyle === TextStyle.Condensed,
})}
theme={Theme.Dark}
value={textStyle}
/>
<button
aria-label={i18n('StoryCreator__text-bg')}
className={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--bg-none':
textBackground === TextBackground.None,
'StoryCreator__tools__button--bg':
textBackground === TextBackground.Background,
'StoryCreator__tools__button--bg-inverse':
textBackground === TextBackground.Inverse,
})}
onClick={() => {
if (textBackground === TextBackground.None) {
setTextBackground(TextBackground.Background);
} else if (textBackground === TextBackground.Background) {
setTextBackground(TextBackground.Inverse);
} else {
setTextBackground(TextBackground.None);
}
}}
type="button"
/>
</div>
) : (
<div className="StoryCreator__toolbar--space" />
)}
<div className="StoryCreator__toolbar--buttons">
<Button
onClick={onClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('discard')}
</Button>
<div className="StoryCreator__controls">
<button
aria-label={i18n('StoryCreator__story-bg')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--bg': true,
'StoryCreator__control--bg--selected': isColorPickerShowing,
})}
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
ref={setColorPickerPopperButtonRef}
style={{
background: getBackgroundColor(
getBackground(selectedBackground)
),
}}
type="button"
/>
{isColorPickerShowing && (
<div
className="StoryCreator__popper"
ref={setColorPickerPopperRef}
style={colorPickerPopper.styles.popper}
{...colorPickerPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
{objectMap<BackgroundStyleType>(
BackgroundStyle,
(bg, backgroundValue) => (
<button
aria-label={i18n('StoryCreator__story-bg')}
className={classNames({
StoryCreator__bg: true,
'StoryCreator__bg--selected':
selectedBackground === backgroundValue,
})}
key={String(bg)}
onClick={() => {
setSelectedBackground(backgroundValue);
setIsColorPickerShowing(false);
}}
type="button"
style={{
background: getBackgroundColor(
getBackground(backgroundValue)
),
}}
/>
)
)}
</div>
)}
<button
aria-label={i18n('StoryCreator__control--draw')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--text': true,
'StoryCreator__control--selected': isEditingText,
})}
onClick={() => {
setIsEditingText(!isEditingText);
}}
type="button"
/>
<button
aria-label={i18n('StoryCreator__control--link')}
className="StoryCreator__control StoryCreator__control--link"
onClick={() =>
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
<>
{hasSendToModal && (
<SendStoryModal
distributionLists={distributionLists}
i18n={i18n}
me={me}
onClose={() => setHasSendToModal(false)}
onSend={listIds => {
onSend(listIds, textAttachment);
setHasSendToModal(false);
onClose();
}}
signalConnections={signalConnections}
/>
)}
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryCreator">
<div className="StoryCreator__container">
<TextAttachment
disableLinkPreviewPopup
i18n={i18n}
isEditingText={isEditingText}
onChange={setText}
onClick={() => {
if (!isEditingText) {
setIsEditingText(true);
}
ref={setLinkPreviewInputPopperButtonRef}
type="button"
/>
{isLinkPreviewInputShowing && (
<div
className={classNames(
'StoryCreator__popper StoryCreator__link-preview-input-popper',
themeClassName(Theme.Dark)
)}
ref={setLinkPreviewInputPopperRef}
style={linkPreviewInputPopper.styles.popper}
{...linkPreviewInputPopper.attributes.popper}
>
}}
onRemoveLinkPreview={() => {
setHasLinkPreviewApplied(false);
}}
textAttachment={textAttachment}
/>
</div>
<div className="StoryCreator__toolbar">
{isEditingText ? (
<div className="StoryCreator__tools">
<Slider
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
label={i18n('CustomColorEditor__hue')}
moduleClassName="HueSlider StoryCreator__tools__tool"
onChange={setSliderValue}
value={sliderValue}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'StoryCreator__icon--font-regular',
label: i18n('StoryCreator__text--regular'),
onClick: () => setTextStyle(TextStyle.Regular),
value: TextStyle.Regular,
},
{
icon: 'StoryCreator__icon--font-bold',
label: i18n('StoryCreator__text--bold'),
onClick: () => setTextStyle(TextStyle.Bold),
value: TextStyle.Bold,
},
{
icon: 'StoryCreator__icon--font-serif',
label: i18n('StoryCreator__text--serif'),
onClick: () => setTextStyle(TextStyle.Serif),
value: TextStyle.Serif,
},
{
icon: 'StoryCreator__icon--font-script',
label: i18n('StoryCreator__text--script'),
onClick: () => setTextStyle(TextStyle.Script),
value: TextStyle.Script,
},
{
icon: 'StoryCreator__icon--font-condensed',
label: i18n('StoryCreator__text--condensed'),
onClick: () => setTextStyle(TextStyle.Condensed),
value: TextStyle.Condensed,
},
]}
moduleClassName={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--font-regular':
textStyle === TextStyle.Regular,
'StoryCreator__tools__button--font-bold':
textStyle === TextStyle.Bold,
'StoryCreator__tools__button--font-serif':
textStyle === TextStyle.Serif,
'StoryCreator__tools__button--font-script':
textStyle === TextStyle.Script,
'StoryCreator__tools__button--font-condensed':
textStyle === TextStyle.Condensed,
})}
theme={Theme.Dark}
value={textStyle}
/>
<button
aria-label={i18n('StoryCreator__text-bg')}
className={classNames('StoryCreator__tools__tool', {
'StoryCreator__tools__button--bg-none':
textBackground === TextBackground.None,
'StoryCreator__tools__button--bg':
textBackground === TextBackground.Background,
'StoryCreator__tools__button--bg-inverse':
textBackground === TextBackground.Inverse,
})}
onClick={() => {
if (textBackground === TextBackground.None) {
setTextBackground(TextBackground.Background);
} else if (textBackground === TextBackground.Background) {
setTextBackground(TextBackground.Inverse);
} else {
setTextBackground(TextBackground.None);
}
}}
type="button"
/>
</div>
) : (
<div className="StoryCreator__toolbar--space" />
)}
<div className="StoryCreator__toolbar--buttons">
<Button
onClick={onClose}
theme={Theme.Dark}
variant={ButtonVariant.Secondary}
>
{i18n('discard')}
</Button>
<div className="StoryCreator__controls">
<button
aria-label={i18n('StoryCreator__story-bg')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--bg': true,
'StoryCreator__control--bg--selected': isColorPickerShowing,
})}
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
ref={setColorPickerPopperButtonRef}
style={{
background: getBackgroundColor(
getBackground(selectedBackground)
),
}}
type="button"
/>
{isColorPickerShowing && (
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
<Input
disableSpellcheck
i18n={i18n}
moduleClassName="StoryCreator__link-preview-input"
onChange={setLinkPreviewInputValue}
placeholder={i18n('StoryCreator__link-preview-placeholder')}
ref={el => el?.focus()}
value={linkPreviewInputValue}
/>
<div className="StoryCreator__link-preview-container">
{linkPreview ? (
<>
<StagedLinkPreview
domain={linkPreview.domain}
i18n={i18n}
image={linkPreview.image}
moduleClassName="StoryCreator__link-preview"
title={linkPreview.title}
url={linkPreview.url}
/>
<Button
className="StoryCreator__link-preview-button"
className="StoryCreator__popper"
ref={setColorPickerPopperRef}
style={colorPickerPopper.styles.popper}
{...colorPickerPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
{objectMap<BackgroundStyleType>(
BackgroundStyle,
(bg, backgroundValue) => (
<button
aria-label={i18n('StoryCreator__story-bg')}
className={classNames({
StoryCreator__bg: true,
'StoryCreator__bg--selected':
selectedBackground === backgroundValue,
})}
key={String(bg)}
onClick={() => {
setHasLinkPreviewApplied(true);
setIsLinkPreviewInputShowing(false);
setSelectedBackground(backgroundValue);
setIsColorPickerShowing(false);
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__add-link')}
</Button>
</>
) : (
<div className="StoryCreator__link-preview-empty">
<div className="StoryCreator__link-preview-empty__icon" />
{i18n('StoryCreator__link-preview-empty')}
</div>
type="button"
style={{
background: getBackgroundColor(
getBackground(backgroundValue)
),
}}
/>
)
)}
</div>
</div>
)}
)}
<button
aria-label={i18n('StoryCreator__control--text')}
className={classNames({
StoryCreator__control: true,
'StoryCreator__control--text': true,
'StoryCreator__control--selected': isEditingText,
})}
onClick={() => {
setIsEditingText(!isEditingText);
}}
type="button"
/>
<button
aria-label={i18n('StoryCreator__control--link')}
className="StoryCreator__control StoryCreator__control--link"
onClick={() =>
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
}
ref={setLinkPreviewInputPopperButtonRef}
type="button"
/>
{isLinkPreviewInputShowing && (
<div
className={classNames(
'StoryCreator__popper StoryCreator__link-preview-input-popper',
themeClassName(Theme.Dark)
)}
ref={setLinkPreviewInputPopperRef}
style={linkPreviewInputPopper.styles.popper}
{...linkPreviewInputPopper.attributes.popper}
>
<div
data-popper-arrow
className="StoryCreator__popper__arrow"
/>
<Input
disableSpellcheck
i18n={i18n}
moduleClassName="StoryCreator__link-preview-input"
onChange={setLinkPreviewInputValue}
placeholder={i18n(
'StoryCreator__link-preview-placeholder'
)}
ref={el => el?.focus()}
value={linkPreviewInputValue}
/>
<div className="StoryCreator__link-preview-container">
{linkPreview ? (
<>
<StagedLinkPreview
domain={linkPreview.domain}
i18n={i18n}
image={linkPreview.image}
moduleClassName="StoryCreator__link-preview"
title={linkPreview.title}
url={linkPreview.url}
/>
<Button
className="StoryCreator__link-preview-button"
onClick={() => {
setHasLinkPreviewApplied(true);
setIsLinkPreviewInputShowing(false);
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__add-link')}
</Button>
</>
) : (
<div className="StoryCreator__link-preview-empty">
<div className="StoryCreator__link-preview-empty__icon" />
{i18n('StoryCreator__link-preview-empty')}
</div>
)}
</div>
</div>
)}
</div>
<Button
disabled={!hasChanges}
onClick={() => setHasSendToModal(true)}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__next')}
</Button>
</div>
<Button
onClick={onNext}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{i18n('StoryCreator__next')}
</Button>
</div>
</div>
</div>
</FocusTrap>
</FocusTrap>
</>
);
};

View file

@ -18,6 +18,7 @@ import { sendGroupUpdate } from './helpers/sendGroupUpdate';
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
import { sendProfileKey } from './helpers/sendProfileKey';
import { sendReaction } from './helpers/sendReaction';
import { sendStory } from './helpers/sendStory';
import type { LoggerType } from '../types/Logging';
import { ConversationVerificationState } from '../state/ducks/conversationsEnums';
@ -44,6 +45,7 @@ export const conversationQueueJobEnum = z.enum([
'NormalMessage',
'ProfileKey',
'Reaction',
'Story',
]);
const deleteForEveryoneJobDataSchema = z.object({
@ -105,6 +107,17 @@ const reactionJobDataSchema = z.object({
});
export type ReactionJobData = z.infer<typeof reactionJobDataSchema>;
const storyJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.Story),
conversationId: z.string(),
// Note: recipients are baked into the message itself
messageIds: z.string().array(),
textAttachment: z.any(), // TODO TextAttachmentType
timestamp: z.number(),
revision: z.number().optional(),
});
export type StoryJobData = z.infer<typeof storyJobDataSchema>;
export const conversationQueueJobDataSchema = z.union([
deleteForEveryoneJobDataSchema,
expirationTimerUpdateJobDataSchema,
@ -112,6 +125,7 @@ export const conversationQueueJobDataSchema = z.union([
normalMessageSendJobDataSchema,
profileKeyJobDataSchema,
reactionJobDataSchema,
storyJobDataSchema,
]);
export type ConversationQueueJobData = z.infer<
typeof conversationQueueJobDataSchema
@ -332,6 +346,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
case jobSet.Reaction:
await sendReaction(conversation, jobBundle, data);
break;
case jobSet.Story:
await sendStory(conversation, jobBundle, data);
break;
default: {
// Note: This should never happen, because the zod call in parseData wouldn't
// accept data that doesn't look like our type specification.

View file

@ -0,0 +1,498 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEqual } from 'lodash';
import type { ConversationModel } from '../../models/conversations';
import type {
ConversationQueueJobBundle,
StoryJobData,
} from '../conversationJobQueue';
import type { LoggerType } from '../../types/Logging';
import type { MessageModel } from '../../models/messages';
import type { SenderKeyInfoType } from '../../model-types.d';
import type {
SendState,
SendStateByConversationId,
} from '../../messages/MessageSendState';
import type { UUIDStringType } from '../../types/UUID';
import * as Errors from '../../types/errors';
import dataInterface from '../../sql/Client';
import { SignalService as Proto } from '../../protobuf';
import { getMessageById } from '../../messages/getMessageById';
import {
getSendOptions,
getSendOptionsForRecipients,
} from '../../util/getSendOptions';
import { handleMessageSend } from '../../util/handleMessageSend';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { isMe } from '../../util/whatTypeOfConversation';
import { isNotNil } from '../../util/isNotNil';
import { isSent } from '../../messages/MessageSendState';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { sendContentMessageToGroup } from '../../util/sendToGroup';
export async function sendStory(
conversation: ConversationModel,
{
isFinalAttempt,
messaging,
shouldContinue,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: StoryJobData
): Promise<void> {
const { messageIds, textAttachment, timestamp } = data;
const profileKey = await ourProfileKeyService.get();
if (!profileKey) {
log.info('stories.sendStory: no profile key cannot send');
return;
}
// Some distribution lists need allowsReplies false, some need it set to true
// we create this proto (for the sync message) and also to re-use some of the
// attributes inside it.
const originalStoryMessage = await messaging.getStoryMessage({
allowsReplies: true,
textAttachment,
profileKey,
});
const accSendStateByConversationId = new Map<string, SendState>();
const canReplyUuids = new Set<string>();
const recipientsByUuid = new Map<string, Set<string>>();
// This function is used to keep track of all the recipients so once we're
// done with our send we can build up the storyMessageRecipients object for
// sending in the sync message.
function processStoryMessageRecipient(
listId: string,
uuid: string,
canReply?: boolean
): void {
if (conversation.get('uuid') === uuid) {
return;
}
const distributionListIds = recipientsByUuid.get(uuid) || new Set<string>();
recipientsByUuid.set(uuid, new Set([...distributionListIds, listId]));
if (canReply) {
canReplyUuids.add(uuid);
}
}
// Since some contacts will be duplicated across lists but we won't be sending
// duplicate messages we need to ensure that sendStateByConversationId is kept
// in sync across all messages.
async function maybeUpdateMessageSendState(
message: MessageModel
): Promise<void> {
const oldSendStateByConversationId =
message.get('sendStateByConversationId') || {};
const newSendStateByConversationId = Object.keys(
oldSendStateByConversationId
).reduce((acc, conversationId) => {
const sendState = accSendStateByConversationId.get(conversationId);
if (sendState) {
return {
...acc,
[conversationId]: sendState,
};
}
return acc;
}, {} as SendStateByConversationId);
if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) {
return;
}
message.set('sendStateByConversationId', newSendStateByConversationId);
await window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}
let isSyncMessageUpdate = false;
// Send to all distribution lists
await Promise.all(
messageIds.map(async messageId => {
const message = await getMessageById(messageId);
if (!message) {
log.info(
`stories.sendStory: message ${messageId} was not found, maybe because it was deleted. Giving up on sending it`
);
return;
}
const messageConversation = message.getConversation();
if (messageConversation !== conversation) {
log.error(
`stories.sendStory: Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
);
return;
}
if (message.isErased() || message.get('deletedForEveryone')) {
log.info(
`stories.sendStory: message ${messageId} was erased. Giving up on sending it`
);
return;
}
const listId = message.get('storyDistributionListId');
if (!listId) {
log.info(
`stories.sendStory: message ${messageId} does not have a storyDistributionListId. Giving up on sending it`
);
return;
}
const distributionList =
await dataInterface.getStoryDistributionWithMembers(listId);
if (!distributionList) {
log.info(
`stories.sendStory: Distribution list ${listId} was not found. Giving up on sending message ${messageId}`
);
return;
}
let messageSendErrors: Array<Error> = [];
// We don't want to save errors on messages unless we're giving up. If it's our
// final attempt, we know upfront that we want to give up. However, we might also
// want to give up if (1) we get a 508 from the server, asking us to please stop
// (2) we get a 428 from the server, flagging the message for spam (3) some other
// reason not known at the time of this writing.
//
// This awkward callback lets us hold onto errors we might want to save, so we can
// decide whether to save them later on.
const saveErrors = isFinalAttempt
? undefined
: (errors: Array<Error>) => {
messageSendErrors = errors;
};
if (!shouldContinue) {
log.info(
`stories.sendStory: message ${messageId} ran out of time. Giving up on sending it`
);
await markMessageFailed(message, [
new Error('Message send ran out of time'),
]);
return;
}
let originalError: Error | undefined;
const {
allRecipientIdentifiers,
allowedReplyByUuid,
recipientIdentifiersWithoutMe,
sentRecipientIdentifiers,
untrustedUuids,
} = getMessageRecipients({
log,
message,
});
try {
if (untrustedUuids.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification(
{
conversationId: conversation.id,
untrustedUuids,
}
);
throw new Error(
`stories.sendStory: Message ${messageId} sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.`
);
}
if (
!allRecipientIdentifiers.length ||
!recipientIdentifiersWithoutMe.length
) {
log.info(
`stories.sendStory: trying to send message ${messageId} but it looks like it was already sent to everyone.`
);
sentRecipientIdentifiers.forEach(uuid =>
processStoryMessageRecipient(
listId,
uuid,
allowedReplyByUuid.get(uuid)
)
);
await maybeUpdateMessageSendState(message);
return;
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const recipientsSet = new Set(recipientIdentifiersWithoutMe);
const sendOptions = await getSendOptionsForRecipients(
recipientIdentifiersWithoutMe
);
log.info(
'stories.sendStory: sending story to distribution list',
listId
);
const storyMessage = new Proto.StoryMessage();
storyMessage.profileKey = originalStoryMessage.profileKey;
storyMessage.fileAttachment = originalStoryMessage.fileAttachment;
storyMessage.textAttachment = originalStoryMessage.textAttachment;
storyMessage.group = originalStoryMessage.group;
storyMessage.allowsReplies = Boolean(distributionList.allowsReplies);
const contentMessage = new Proto.Content();
contentMessage.storyMessage = storyMessage;
const innerPromise = sendContentMessageToGroup({
contentHint: ContentHint.IMPLICIT,
contentMessage,
isPartialSend: false,
messageId: undefined,
recipients: recipientIdentifiersWithoutMe,
sendOptions,
sendTarget: {
getGroupId: () => undefined,
getMembers: () =>
recipientIdentifiersWithoutMe
.map(uuid => window.ConversationController.get(uuid))
.filter(isNotNil),
hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid),
idForLogging: () => `dl(${listId})`,
isGroupV2: () => true,
isValid: () => true,
getSenderKeyInfo: () => distributionList.senderKeyInfo,
saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) =>
dataInterface.modifyStoryDistribution({
...distributionList,
senderKeyInfo,
}),
},
sendType: 'story',
timestamp,
urgent: false,
});
message.doNotSendSyncMessage = true;
const messageSendPromise = message.send(
handleMessageSend(innerPromise, {
messageIds: [messageId],
sendType: 'story',
}),
saveErrors
);
// Because message.send swallows and processes errors, we'll await the
// inner promise to get the SendMessageProtoError, which gives us
// information upstream processors need to detect certain kinds of situations.
try {
await innerPromise;
} catch (error) {
if (error instanceof Error) {
originalError = error;
} else {
log.error(
`promiseForError threw something other than an error: ${Errors.toLogFormat(
error
)}`
);
}
}
await messageSendPromise;
// Track sendState across message sends so that we can update all
// subsequent messages.
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
Object.entries(sendStateByConversationId).forEach(
([recipientConversationId, sendState]) => {
if (accSendStateByConversationId.has(recipientConversationId)) {
return;
}
accSendStateByConversationId.set(
recipientConversationId,
sendState
);
}
);
const didFullySend =
!messageSendErrors.length || didSendToEveryone(message);
if (!didFullySend) {
throw new Error('message did not fully send');
}
} catch (thrownError: unknown) {
const errors = [thrownError, ...messageSendErrors];
await handleMultipleSendErrors({
errors,
isFinalAttempt,
log,
markFailed: () => markMessageFailed(message, messageSendErrors),
timeRemaining,
// In the case of a failed group send thrownError will not be
// SentMessageProtoError, but we should have been able to harvest
// the original error. In the Note to Self send case, thrownError
// will be the error we care about, and we won't have an originalError.
toThrow: originalError || thrownError,
});
} finally {
recipientIdentifiersWithoutMe.forEach(uuid =>
processStoryMessageRecipient(
listId,
uuid,
allowedReplyByUuid.get(uuid)
)
);
// Greater than 1 because our own conversation will always count as "sent"
isSyncMessageUpdate = sentRecipientIdentifiers.length > 1;
await maybeUpdateMessageSendState(message);
}
})
);
// Send the sync message
const storyMessageRecipients: Array<{
destinationUuid: string;
distributionListIds: Array<string>;
isAllowedToReply: boolean;
}> = [];
recipientsByUuid.forEach((distributionListIds, destinationUuid) => {
storyMessageRecipients.push({
destinationUuid,
distributionListIds: Array.from(distributionListIds),
isAllowedToReply: canReplyUuids.has(destinationUuid),
});
});
const options = await getSendOptions(conversation.attributes, {
syncMessage: true,
});
messaging.sendSyncMessage({
destination: conversation.get('e164'),
destinationUuid: conversation.get('uuid'),
storyMessage: originalStoryMessage,
storyMessageRecipients,
expirationStartTimestamp: null,
isUpdate: isSyncMessageUpdate,
options,
timestamp,
urgent: false,
});
}
function getMessageRecipients({
log,
message,
}: Readonly<{
log: LoggerType;
message: MessageModel;
}>): {
allRecipientIdentifiers: Array<string>;
allowedReplyByUuid: Map<string, boolean>;
recipientIdentifiersWithoutMe: Array<string>;
sentRecipientIdentifiers: Array<string>;
untrustedUuids: Array<string>;
} {
const allRecipientIdentifiers: Array<string> = [];
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedUuids: Array<string> = [];
const sentRecipientIdentifiers: Array<string> = [];
const allowedReplyByUuid = new Map<string, boolean>();
Object.entries(message.get('sendStateByConversationId') || {}).forEach(
([recipientConversationId, sendState]) => {
if (sendState.isAlreadyIncludedInAnotherDistributionList) {
return;
}
const recipient = window.ConversationController.get(
recipientConversationId
);
if (!recipient) {
return;
}
const isRecipientMe = isMe(recipient.attributes);
if (recipient.isUntrusted()) {
const uuid = recipient.get('uuid');
if (!uuid) {
log.error(
`stories.sendStory/getMessageRecipients: Untrusted conversation ${recipient.idForLogging()} missing UUID.`
);
return;
}
untrustedUuids.push(uuid);
return;
}
if (recipient.isUnregistered()) {
return;
}
const recipientIdentifier = recipient.getSendTarget();
if (!recipientIdentifier) {
return;
}
allowedReplyByUuid.set(
recipientIdentifier,
Boolean(sendState.isAllowedToReplyToStory)
);
if (isSent(sendState.status)) {
sentRecipientIdentifiers.push(recipientIdentifier);
return;
}
allRecipientIdentifiers.push(recipientIdentifier);
if (!isRecipientMe) {
recipientIdentifiersWithoutMe.push(recipientIdentifier);
}
}
);
return {
allRecipientIdentifiers,
allowedReplyByUuid,
recipientIdentifiersWithoutMe,
sentRecipientIdentifiers,
untrustedUuids,
};
}
async function markMessageFailed(
message: MessageModel,
errors: Array<Error>
): Promise<void> {
message.markFailed();
message.saveErrors(errors, { skipSave: true });
await window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
return Object.values(sendStateByConversationId).every(sendState =>
isSent(sendState.status)
);
}

View file

@ -69,6 +69,10 @@ export const isFailed = (status: SendStatus): boolean =>
* The timestamp may be undefined if reading old data, which did not store a timestamp.
*/
export type SendState = Readonly<{
// When sending a story to multiple distribution lists at once, we need to
// de-duplicate the recipients. The story should only be sent once to each
// recipient in the list so the recipient only sees it rendered once.
isAlreadyIncludedInAnotherDistributionList?: boolean;
isAllowedToReplyToStory?: boolean;
status:
| SendStatus.Pending

View file

@ -194,6 +194,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Set when sending some sync messages, so we get the functionality of
// send(), without zombie messages going into the database.
doNotSave?: boolean;
// Set when sending stories, so we get the functionality of send() but we are
// able to send the sync message elsewhere.
doNotSendSyncMessage?: boolean;
INITIAL_PROTOCOL_VERSION?: number;
@ -1575,7 +1578,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
updateLeftPane();
if (sentToAtLeastOneRecipient) {
if (sentToAtLeastOneRecipient && !this.doNotSendSyncMessage) {
promises.push(this.sendSyncMessage());
}

View file

@ -3,17 +3,22 @@
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { isEqual, noop, pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type {
AttachmentType,
TextAttachmentType,
} from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util';
import type { MessageAttributesType } from '../../model-types.d';
import type {
MessageChangedActionType,
MessageDeletedActionType,
MessagesAddedActionType,
} from './conversations';
import type { NoopActionType } from './noop';
import type { StateType as RootStateType } from '../reducer';
import type { StoryViewType } from '../../types/Stories';
import type { SyncType } from '../../jobs/helpers/syncHelpers';
import type { UUIDStringType } from '../../types/UUID';
import * as log from '../../logging/log';
import dataInterface from '../../sql/Client';
import { DAY } from '../../util/durations';
@ -36,8 +41,12 @@ import {
import { getConversationSelector } from '../selectors/conversations';
import { getSendOptions } from '../../util/getSendOptions';
import { getStories } from '../selectors/stories';
import { getStoryDataFromMessageAttributes } from '../../services/storyLoader';
import { isGroup } from '../../util/whatTypeOfConversation';
import { isNotNil } from '../../util/isNotNil';
import { isStory } from '../../messages/helpers';
import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate';
import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage';
import { useBoundActions } from '../../hooks/useBoundActions';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
@ -147,6 +156,7 @@ export type StoriesActionType =
| MarkStoryReadActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
| ReplyToStoryActionType
| ResolveAttachmentUrlActionType
| StoryChangedActionType
@ -542,6 +552,20 @@ function replyToStory(
};
}
function sendStoryMessage(
listIds: Array<UUIDStringType>,
textAttachment: TextAttachmentType
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
await doSendStoryMessage(listIds, textAttachment);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function storyChanged(story: StoryDataType): StoryChangedActionType {
return {
type: STORY_CHANGED,
@ -896,6 +920,7 @@ export const actions = {
queueStoryDownload,
reactToStory,
replyToStory,
sendStoryMessage,
storyChanged,
toggleStoriesView,
viewUserStories,
@ -1046,6 +1071,26 @@ export function reducer(
};
}
if (action.type === 'MESSAGES_ADDED' && action.payload.isJustSent) {
const stories = action.payload.messages.filter(isStory);
if (!stories.length) {
return state;
}
const newStories = stories
.map(messageAttrs => getStoryDataFromMessageAttributes(messageAttrs))
.filter(isNotNil);
if (!newStories.length) {
return state;
}
return {
...state,
stories: [...state.stories, ...newStories],
};
}
// For live updating of the story replies
if (
action.type === 'MESSAGE_CHANGED' &&

View file

@ -37,6 +37,7 @@ import { ContactNameColors } from '../../types/Colors';
import type { AvatarDataType } from '../../types/Avatar';
import type { UUIDStringType } from '../../types/UUID';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { isSignalConnection } from '../../util/getSignalConnections';
import { sortByTitle } from '../../util/sortByTitle';
import {
isDirectConversation,
@ -127,6 +128,12 @@ export const getAllConversations = createSelector(
(lookup): Array<ConversationType> => Object.values(lookup)
);
export const getAllSignalConnections = createSelector(
getAllConversations,
(conversations): Array<ConversationType> =>
conversations.filter(isSignalConnection)
);
export const getConversationsByTitleSelector = createSelector(
getAllConversations,
(conversations): ((title: string) => Array<ConversationType>) =>

View file

@ -3,15 +3,17 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { noop } from 'lodash';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import { LinkPreviewSourceType } from '../../types/LinkPreview';
import { StoryCreator } from '../../components/StoryCreator';
import { getDistributionLists } from '../selectors/storyDistributionLists';
import { getIntl } from '../selectors/user';
import { getLinkPreview } from '../selectors/linkPreviews';
import { getAllSignalConnections, getMe } from '../selectors/conversations';
import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { useStoriesActions } from '../ducks/stories';
export type PropsType = {
onClose: () => unknown;
@ -19,17 +21,24 @@ export type PropsType = {
export function SmartStoryCreator({ onClose }: PropsType): JSX.Element | null {
const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions();
const { sendStoryMessage } = useStoriesActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const linkPreviewForSource = useSelector(getLinkPreview);
const distributionLists = useSelector(getDistributionLists);
const me = useSelector(getMe);
const signalConnections = useSelector(getAllSignalConnections);
return (
<StoryCreator
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
distributionLists={distributionLists}
i18n={i18n}
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
me={me}
onClose={onClose}
onNext={noop}
onSend={sendStoryMessage}
signalConnections={signalConnections}
/>
);
}

View file

@ -0,0 +1,37 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import casual from 'casual';
import type { StoryDistributionListDataType } from '../../state/ducks/storyDistributionLists';
import { MY_STORIES_ID } from '../../types/Stories';
import { UUID } from '../../types/UUID';
export function getFakeDistributionLists(): Array<StoryDistributionListDataType> {
return [
getMyStories(),
...Array.from(Array(casual.integer(2, 8)), getFakeDistributionList),
];
}
export function getFakeDistributionList(): StoryDistributionListDataType {
return {
allowsReplies: Boolean(casual.coin_flip),
id: UUID.generate().toString(),
isBlockList: false,
memberUuids: Array.from(Array(casual.integer(3, 12)), () =>
UUID.generate().toString()
),
name: casual.title,
};
}
export function getMyStories(): StoryDistributionListDataType {
return {
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: true,
memberUuids: [],
name: MY_STORIES_ID,
};
}

View file

@ -114,7 +114,7 @@ import * as log from '../logging/log';
import * as durations from '../util/durations';
import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
import { generateBlurHash } from '../util/generateBlurHash';
import { APPLICATION_OCTET_STREAM } from '../types/MIME';
import { TEXT_ATTACHMENT } from '../types/MIME';
import type { SendTypesType } from '../util/handleMessageSend';
const GROUPV1_ID_LENGTH = 16;
@ -1884,7 +1884,7 @@ export default class MessageReceiver
// TODO DESKTOP-3714 we should download the story link preview image
attachments.push({
size: text.length,
contentType: APPLICATION_OCTET_STREAM,
contentType: TEXT_ATTACHMENT,
textAttachment: msg.textAttachment,
blurHash: generateBlurHash(
(msg.textAttachment.color ||

View file

@ -835,6 +835,53 @@ export default class MessageSender {
// Proto assembly
async getTextAttachmentProto(
attachmentAttrs: Attachment.TextAttachmentType
): Promise<Proto.TextAttachment> {
const textAttachment = new Proto.TextAttachment();
if (attachmentAttrs.text) {
textAttachment.text = attachmentAttrs.text;
}
textAttachment.textStyle = attachmentAttrs.textStyle
? Number(attachmentAttrs.textStyle)
: 0;
if (attachmentAttrs.textForegroundColor) {
textAttachment.textForegroundColor = attachmentAttrs.textForegroundColor;
}
if (attachmentAttrs.textBackgroundColor) {
textAttachment.textBackgroundColor = attachmentAttrs.textBackgroundColor;
}
if (attachmentAttrs.preview) {
const previewImage = attachmentAttrs.preview.image;
// This cast is OK because we're ensuring that previewImage.data is truthy
const image =
previewImage && previewImage.data
? await this.makeAttachmentPointer(previewImage as AttachmentType)
: undefined;
textAttachment.preview = {
image,
title: attachmentAttrs.preview.title,
url: attachmentAttrs.preview.url,
};
}
if (attachmentAttrs.gradient) {
textAttachment.gradient = attachmentAttrs.gradient;
textAttachment.background = 'gradient';
} else {
textAttachment.color = attachmentAttrs.color;
textAttachment.background = 'color';
}
return textAttachment;
}
async getDataMessage(
options: Readonly<MessageOptionsType>
): Promise<Uint8Array> {
@ -842,6 +889,60 @@ export default class MessageSender {
return message.encode();
}
async getStoryMessage({
allowsReplies,
fileAttachment,
groupV2,
profileKey,
textAttachment,
}: {
allowsReplies?: boolean;
fileAttachment?: AttachmentType;
groupV2?: GroupV2InfoType;
profileKey: Uint8Array;
textAttachment?: Attachment.TextAttachmentType;
}): Promise<Proto.StoryMessage> {
const storyMessage = new Proto.StoryMessage();
storyMessage.profileKey = profileKey;
if (fileAttachment) {
try {
const attachmentPointer = await this.makeAttachmentPointer(
fileAttachment
);
storyMessage.fileAttachment = attachmentPointer;
} catch (error) {
if (error instanceof HTTPError) {
throw new MessageError(message, error);
} else {
throw error;
}
}
}
if (textAttachment) {
storyMessage.textAttachment = await this.getTextAttachmentProto(
textAttachment
);
}
if (groupV2) {
const groupV2Context = new Proto.GroupContextV2();
groupV2Context.masterKey = groupV2.masterKey;
groupV2Context.revision = groupV2.revision;
if (groupV2.groupChange) {
groupV2Context.groupChange = groupV2.groupChange;
}
storyMessage.group = groupV2Context;
}
storyMessage.allowsReplies = Boolean(allowsReplies);
return storyMessage;
}
async getContentMessage(
options: Readonly<MessageOptionsType>
): Promise<Proto.Content> {
@ -1232,6 +1333,7 @@ export default class MessageSender {
isUpdate,
urgent,
options,
storyMessage,
storyMessageRecipients,
}: Readonly<{
encodedDataMessage?: Uint8Array;
@ -1244,6 +1346,7 @@ export default class MessageSender {
isUpdate?: boolean;
urgent: boolean;
options?: SendOptionsType;
storyMessage?: Proto.StoryMessage;
storyMessageRecipients?: Array<{
destinationUuid: string;
distributionListIds: Array<string>;
@ -1270,6 +1373,9 @@ export default class MessageSender {
expirationStartTimestamp
);
}
if (storyMessage) {
sentMessage.storyMessage = storyMessage;
}
if (storyMessageRecipients) {
sentMessage.storyMessageRecipients = storyMessageRecipients.map(
recipient => {

View file

@ -25,6 +25,7 @@ export const IMAGE_BMP = stringToMIMEType('image/bmp');
export const VIDEO_MP4 = stringToMIMEType('video/mp4');
export const VIDEO_QUICKTIME = stringToMIMEType('video/quicktime');
export const LONG_MESSAGE = stringToMIMEType('text/x-signal-plain');
export const TEXT_ATTACHMENT = stringToMIMEType('text/x-signal-story');
export const isHeic = (value: string, fileName: string): boolean =>
value === 'image/heic' ||

View file

@ -0,0 +1,19 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { ConversationModel } from '../models/conversations';
import type { ConversationType } from '../state/ducks/conversations';
import { isInSystemContacts } from './isInSystemContacts';
export function isSignalConnection(
conversation: ConversationType | ConversationAttributesType
): boolean {
return conversation.profileSharing || isInSystemContacts(conversation);
}
export function getSignalConnections(): Array<ConversationModel> {
return window
.getConversations()
.filter(conversation => isSignalConnection(conversation.attributes));
}

200
ts/util/sendStoryMessage.ts Normal file
View file

@ -0,0 +1,200 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import type { SendStateByConversationId } from '../messages/MessageSendState';
import type { TextAttachmentType } from '../types/Attachment';
import type { UUIDStringType } from '../types/UUID';
import * as log from '../logging/log';
import dataInterface from '../sql/Client';
import { DAY, SECOND } from './durations';
import { MY_STORIES_ID } from '../types/Stories';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
import { SendStatus } from '../messages/MessageSendState';
import { TEXT_ATTACHMENT } from '../types/MIME';
import { UUID } from '../types/UUID';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { formatJobForInsert } from '../jobs/formatJobForInsert';
import { getSignalConnections } from './getSignalConnections';
import { incrementMessageCounter } from './incrementMessageCounter';
import { isNotNil } from './isNotNil';
export async function sendStoryMessage(
listIds: Array<string>,
textAttachment: TextAttachmentType
): Promise<void> {
const { messaging } = window.textsecure;
if (!messaging) {
log.warn('stories.sendStoryMessage: messaging not available');
return;
}
const distributionLists = (
await Promise.all(
listIds.map(listId =>
dataInterface.getStoryDistributionWithMembers(listId)
)
)
).filter(isNotNil);
if (!distributionLists.length) {
log.info(
'stories.sendStoryMessage: no distribution lists found for',
listIds
);
return;
}
const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const timestamp = Date.now();
const sendStateByListId = new Map<
UUIDStringType,
SendStateByConversationId
>();
const recipientsAlreadySentTo = new Map<UUIDStringType, boolean>();
// * Create the custom sendStateByConversationId for each distribution list
// * De-dupe members to make sure they're only sent to once
// * Figure out who can reply/who can't
distributionLists
.sort(list => (list.allowsReplies ? -1 : 1))
.forEach(distributionList => {
const sendStateByConversationId: SendStateByConversationId = {};
let distributionListMembers: Array<UUIDStringType> = [];
if (
distributionList.id === MY_STORIES_ID &&
distributionList.isBlockList
) {
const inBlockList = new Set<UUIDStringType>(distributionList.members);
distributionListMembers = getSignalConnections().reduce(
(acc, convo) => {
const id = convo.get('uuid');
if (!id) {
return acc;
}
const uuid = UUID.fromString(id);
if (inBlockList.has(uuid)) {
return acc;
}
acc.push(uuid);
return acc;
},
[] as Array<UUIDStringType>
);
} else {
distributionListMembers = distributionList.members;
}
distributionListMembers.forEach(destinationUuid => {
const conversation = window.ConversationController.get(destinationUuid);
if (!conversation) {
return;
}
sendStateByConversationId[conversation.id] = {
isAllowedToReplyToStory:
recipientsAlreadySentTo.get(destinationUuid) ||
distributionList.allowsReplies,
isAlreadyIncludedInAnotherDistributionList:
recipientsAlreadySentTo.has(destinationUuid),
status: SendStatus.Pending,
updatedAt: timestamp,
};
if (!recipientsAlreadySentTo.has(destinationUuid)) {
recipientsAlreadySentTo.set(
destinationUuid,
distributionList.allowsReplies
);
}
});
sendStateByListId.set(distributionList.id, sendStateByConversationId);
});
// * Gather all the job data we'll be sending to the sendStory job
// * Create the message for each distribution list
const messagesToSave: Array<MessageAttributesType> = await Promise.all(
distributionLists.map(async distributionList => {
const sendStateByConversationId = sendStateByListId.get(
distributionList.id
);
if (!sendStateByConversationId) {
log.warn(
'stories.sendStoryMessage: No sendStateByConversationId for distribution list',
distributionList.id
);
}
return window.Signal.Migrations.upgradeMessageSchema({
attachments: [
{
contentType: TEXT_ATTACHMENT,
textAttachment,
size: textAttachment.text?.length || 0,
},
],
conversationId: ourConversation.id,
expireTimer: DAY / SECOND,
id: UUID.generate().toString(),
readStatus: ReadStatus.Read,
received_at: incrementMessageCounter(),
received_at_ms: timestamp,
seenStatus: SeenStatus.NotApplicable,
sendStateByConversationId,
sent_at: timestamp,
source: window.textsecure.storage.user.getNumber(),
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
storyDistributionListId: distributionList.id,
timestamp,
type: 'story',
});
})
);
// * Save the message model
// * Add the message to the conversation
await Promise.all(
messagesToSave.map(messageAttributes => {
const model = new window.Whisper.Message(messageAttributes);
const message = window.MessageController.register(model.id, model);
ourConversation.addSingleMessage(model, { isJustSent: true });
log.info(`stories.sendStoryMessage: saving message ${message.id}`);
return dataInterface.saveMessage(message.attributes, {
forceSave: true,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
})
);
// * Place into job queue
// * Save the job
await conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.Story,
conversationId: ourConversation.id,
messageIds: messagesToSave.map(m => m.id),
textAttachment,
timestamp,
},
async jobToInsert => {
log.info(`stories.sendStoryMessage: saving job ${jobToInsert.id}`);
await dataInterface.insertJob(formatJobForInsert(jobToInsert));
}
);
}