Sync my stories with primary device

This commit is contained in:
Josh Perez 2022-06-30 20:52:03 -04:00 committed by GitHub
parent 7554d8326a
commit 9155784d56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 2954 additions and 1238 deletions

View File

@ -7315,6 +7315,10 @@
"message": "No longer available",
"description": "Label for when a story is not found"
},
"ContextMenu--button": {
"message": "Context menu",
"description": "Default aria-label for the context menu buttons"
},
"WhatsNew__modal-title": {
"message": "What's New",
"description": "Title for the whats new modal"

View File

@ -335,6 +335,7 @@ message StoryMessage {
AttachmentPointer fileAttachment = 3;
TextAttachment textAttachment = 4;
}
optional bool allowsReplies = 5;
}
message TextAttachment {
@ -386,6 +387,12 @@ message SyncMessage {
optional bool unidentified = 2;
}
message StoryMessageRecipient {
optional string destinationUuid = 1;
repeated string distributionListIds = 2;
optional bool isAllowedToReply = 3;
}
optional string destination = 1;
optional string destinationUuid = 7;
optional uint64 timestamp = 2;
@ -393,6 +400,8 @@ message SyncMessage {
optional uint64 expirationStartTimestamp = 4;
repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5;
optional bool isRecipientUpdate = 6 [default = false];
optional StoryMessage storyMessage = 8;
repeated StoryMessageRecipient storyMessageRecipients = 9;
}
message Contacts {

View File

@ -39,6 +39,7 @@ message ManifestRecord {
GROUPV1 = 2;
GROUPV2 = 3;
ACCOUNT = 4;
STORY_DISTRIBUTION_LIST = 5;
}
optional bytes raw = 1;
@ -57,6 +58,7 @@ message StorageRecord {
GroupV1Record groupV1 = 2;
GroupV2Record groupV2 = 3;
AccountRecord account = 4;
StoryDistributionListRecord storyDistributionList = 5;
}
}
@ -147,3 +149,12 @@ message AccountRecord {
optional bool displayBadgesOnProfile = 23;
optional bool keepMutedChatsArchived = 25;
}
message StoryDistributionListRecord {
optional bytes identifier = 1;
optional string name = 2;
repeated string recipientUuids = 3;
optional uint64 deletedAtTimestamp = 4;
optional bool allowsReplies = 5;
optional bool isBlockList = 6;
}

View File

@ -73,7 +73,8 @@
border-radius: 6px;
display: flex;
justify-content: space-between;
padding: 6px 8px;
padding: 6px;
margin: 0 2px;
min-width: 150px;
&--container {

View File

@ -25,15 +25,7 @@
&__preview {
@include button-reset;
align-items: center;
background-color: $color-gray-60;
background-size: cover;
border-radius: 8px;
height: 72px;
margin-right: 12px;
overflow: hidden;
width: 46px;
}
&__timestamp {

View File

@ -164,4 +164,8 @@
}
}
}
&__my-stories {
padding: 0 10px;
}
}

View File

@ -44,6 +44,10 @@ import { IdleDetector } from './IdleDetector';
import { expiringMessagesDeletionService } from './services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService';
import { getStoriesForRedux, loadStories } from './services/storyLoader';
import {
getDistributionListsForRedux,
loadDistributionLists,
} from './services/distributionListLoader';
import { senderCertificateService } from './services/senderCertificate';
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
import * as KeyboardLayout from './services/keyboardLayout';
@ -977,6 +981,7 @@ export async function startApp(): Promise<void> {
loadRecentEmojis(),
loadInitialBadgesState(),
loadStories(),
loadDistributionLists(),
window.textsecure.storage.protocol.hydrateCaches(),
(async () => {
mainWindowStats = await window.SignalContext.getMainWindowStats();
@ -1021,9 +1026,10 @@ export async function startApp(): Promise<void> {
const convoCollection = window.getConversations();
const initialState = getInitialState({
badges: initialBadgesState,
stories: getStoriesForRedux(),
mainWindowStats,
menuOptions,
stories: getStoriesForRedux(),
storyDistributionLists: getDistributionListsForRedux(),
});
const store = window.Signal.State.createStore(initialState);
@ -1072,6 +1078,10 @@ export async function startApp(): Promise<void> {
search: bindActionCreators(actionCreators.search, store.dispatch),
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
stories: bindActionCreators(actionCreators.stories, store.dispatch),
storyDistributionLists: bindActionCreators(
actionCreators.storyDistributionLists,
store.dispatch
),
updates: bindActionCreators(actionCreators.updates, store.dispatch),
user: bindActionCreators(actionCreators.user, store.dispatch),
};
@ -3091,7 +3101,7 @@ export async function startApp(): Promise<void> {
unidentifiedStatus.reduce(
(
result: SendStateByConversationId,
{ destinationUuid, destination }
{ destinationUuid, destination, isAllowedToReplyToStory }
) => {
const conversationId = window.ConversationController.ensureContactIds(
{
@ -3106,6 +3116,7 @@ export async function startApp(): Promise<void> {
return {
...result,
[conversationId]: {
isAllowedToReplyToStory,
status: SendStatus.Sent,
updatedAt: timestamp,
},
@ -3130,6 +3141,9 @@ export async function startApp(): Promise<void> {
}
return new window.Whisper.Message({
canReplyToStory: data.message.isStory
? data.message.canReplyToStory
: undefined,
conversationId: descriptor.id,
expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || timestamp,
@ -3146,7 +3160,8 @@ export async function startApp(): Promise<void> {
sourceDevice: data.device,
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
timestamp,
type: 'outgoing',
type: data.message.isStory ? 'story' : 'outgoing',
storyDistributionListId: data.storyDistributionListId,
unidentifiedDeliveries,
} as Partial<MessageAttributesType> as WhatIsThis);
}
@ -3384,20 +3399,23 @@ export async function startApp(): Promise<void> {
`Did not receive receivedAtCounter for message: ${data.timestamp}`
);
return new window.Whisper.Message({
source: data.source,
sourceUuid: data.sourceUuid,
sourceDevice: data.sourceDevice,
canReplyToStory: data.message.isStory
? data.message.canReplyToStory
: undefined,
conversationId: descriptor.id,
readStatus: ReadStatus.Unread,
received_at: data.receivedAtCounter,
received_at_ms: data.receivedAtDate,
seenStatus: SeenStatus.Unseen,
sent_at: data.timestamp,
serverGuid: data.serverGuid,
serverTimestamp: data.serverTimestamp,
received_at: data.receivedAtCounter,
received_at_ms: data.receivedAtDate,
conversationId: descriptor.id,
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: data.message.isStory ? 'story' : 'incoming',
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
source: data.source,
sourceDevice: data.sourceDevice,
sourceUuid: data.sourceUuid,
timestamp: data.timestamp,
type: data.message.isStory ? 'story' : 'incoming',
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
} as Partial<MessageAttributesType> as WhatIsThis);
}

View File

@ -86,55 +86,63 @@ export function ContextMenuPopper<T>({
}
return (
<div className={theme ? themeClassName(theme) : undefined}>
<div
className="ContextMenu__popper"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className="ContextMenu__title">{title}</div>}
{menuOptions.map((option, index) => (
<button
aria-label={option.label}
className={classNames({
ContextMenu__option: true,
'ContextMenu__option--focused': focusedIndex === index,
})}
key={option.label}
type="button"
onClick={() => {
option.onClick(option.value);
onClose();
}}
>
<div className="ContextMenu__option--container">
{option.icon && (
<div
className={classNames(
'ContextMenu__option--icon',
option.icon
)}
/>
)}
<div>
<div className="ContextMenu__option--title">{option.label}</div>
{option.description && (
<div className="ContextMenu__option--description">
{option.description}
</div>
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<div className={theme ? themeClassName(theme) : undefined}>
<div
className="ContextMenu__popper"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{title && <div className="ContextMenu__title">{title}</div>}
{menuOptions.map((option, index) => (
<button
aria-label={option.label}
className={classNames({
ContextMenu__option: true,
'ContextMenu__option--focused': focusedIndex === index,
})}
key={option.label}
type="button"
onClick={() => {
option.onClick(option.value);
onClose();
}}
>
<div className="ContextMenu__option--container">
{option.icon && (
<div
className={classNames(
'ContextMenu__option--icon',
option.icon
)}
/>
)}
<div>
<div className="ContextMenu__option--title">
{option.label}
</div>
{option.description && (
<div className="ContextMenu__option--description">
{option.description}
</div>
)}
</div>
</div>
</div>
{typeof value !== 'undefined' &&
typeof option.value !== 'undefined' &&
value === option.value ? (
<div className="ContextMenu__option--selected" />
) : null}
</button>
))}
{typeof value !== 'undefined' &&
typeof option.value !== 'undefined' &&
value === option.value ? (
<div className="ContextMenu__option--selected" />
) : null}
</button>
))}
</div>
</div>
</div>
</FocusTrap>
);
}
@ -214,22 +222,16 @@ export function ContextMenu<T>({
type="button"
/>
{menuShowing && (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
}}
>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
</FocusTrap>
<ContextMenuPopper
focusedIndex={focusedIndex}
isMenuShowing={menuShowing}
menuOptions={menuOptions}
onClose={() => setMenuShowing(false)}
popperOptions={popperOptions}
referenceElement={referenceElement}
title={title}
value={value}
/>
)}
</div>
);

View File

@ -4,6 +4,7 @@
import React from 'react';
import type {
ContactModalStateType,
ForwardMessagePropsType,
UserNotFoundModalStateType,
} from '../state/ducks/globalModals';
import type { LocalizerType } from '../types/Util';
@ -18,6 +19,9 @@ type PropsType = {
// ContactModal
contactModalState?: ContactModalStateType;
renderContactModal: () => JSX.Element;
// ForwardMessageModal
forwardMessageProps?: ForwardMessagePropsType;
renderForwardMessageModal: () => JSX.Element;
// ProfileEditor
isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element;
@ -37,6 +41,9 @@ export const GlobalModalContainer = ({
// ContactModal
contactModalState,
renderContactModal,
// ForwardMessageModal
forwardMessageProps,
renderForwardMessageModal,
// ProfileEditor
isProfileEditorVisible,
renderProfileEditor,
@ -94,5 +101,9 @@ export const GlobalModalContainer = ({
return <WhatsNewModal hideWhatsNewModal={hideWhatsNewModal} i18n={i18n} />;
}
if (forwardMessageProps) {
return renderForwardMessageModal();
}
return null;
};

View File

@ -0,0 +1,105 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, ReactFramework, Story } from '@storybook/react';
import type { PlayFunction } from '@storybook/csf';
import React from 'react';
import { expect } from '@storybook/jest';
import { v4 as uuid } from 'uuid';
import { within, userEvent } from '@storybook/testing-library';
import type { PropsType } from './MyStories';
import enMessages from '../../_locales/en/messages.json';
import { MY_STORIES_ID } from '../types/Stories';
import { MyStories } from './MyStories';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { getFakeMyStory } from '../test-both/helpers/getFakeStory';
import { setupI18n } from '../util/setupI18n';
import { sleep } from '../util/sleep';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/MyStories',
component: MyStories,
argTypes: {
i18n: {
defaultValue: i18n,
},
onBack: {
action: true,
},
onDelete: {
action: true,
},
onForward: {
action: true,
},
onSave: {
action: true,
},
ourConversationId: {
defaultValue: getDefaultConversation().id,
},
queueStoryDownload: {
action: true,
},
renderStoryViewer: {
action: true,
},
},
} as Meta;
const Template: Story<PropsType> = args => <MyStories {...args} />;
export const NoStories = Template.bind({});
NoStories.args = {
myStories: [],
};
NoStories.story = {
name: 'No Stories',
};
const interactionTest: PlayFunction<ReactFramework, PropsType> = async ({
args,
canvasElement,
}) => {
const canvas = within(canvasElement);
const [btnDownload] = canvas.getAllByLabelText('Download story');
await userEvent.click(btnDownload);
await expect(args.onSave).toHaveBeenCalled();
const [btnBack] = canvas.getAllByLabelText('Back');
await userEvent.click(btnBack);
await expect(args.onBack).toHaveBeenCalled();
const [btnCtxMenu] = canvas.getAllByLabelText('Context menu');
await userEvent.click(btnCtxMenu);
await sleep(300);
const [btnFwd] = canvas.getAllByLabelText('Forward');
await userEvent.click(btnFwd);
await expect(args.onForward).toHaveBeenCalled();
};
export const SingleListStories = Template.bind({});
SingleListStories.args = {
myStories: [getFakeMyStory(MY_STORIES_ID)],
};
SingleListStories.play = interactionTest;
SingleListStories.story = {
name: 'One distribution list',
};
export const MultiListStories = Template.bind({});
MultiListStories.args = {
myStories: [
getFakeMyStory(MY_STORIES_ID),
getFakeMyStory(uuid(), 'Cool Peeps'),
getFakeMyStory(uuid(), 'Family'),
],
};
MultiListStories.play = interactionTest;
MultiListStories.story = {
name: 'Multiple distribution lists',
};

167
ts/components/MyStories.tsx Normal file
View File

@ -0,0 +1,167 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { MyStoryType, StoryViewType } from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
import { MY_STORIES_ID } from '../types/Stories';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage';
import { Theme } from '../util/theme';
export type PropsType = {
i18n: LocalizerType;
myStories: Array<MyStoryType>;
onBack: () => unknown;
onDelete: (story: StoryViewType) => unknown;
onForward: (storyId: string) => unknown;
onSave: (story: StoryViewType) => unknown;
ourConversationId: string;
queueStoryDownload: (storyId: string) => unknown;
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
};
export const MyStories = ({
i18n,
myStories,
onBack,
onDelete,
onForward,
onSave,
ourConversationId,
queueStoryDownload,
renderStoryViewer,
}: PropsType): JSX.Element => {
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined
>();
const [storyToView, setStoryToView] = useState<StoryViewType | undefined>();
return (
<>
{confirmDeleteStory && (
<ConfirmationDialog
actions={[
{
text: i18n('delete'),
action: () => onDelete(confirmDeleteStory),
style: 'negative',
},
]}
i18n={i18n}
onClose={() => setConfirmDeleteStory(undefined)}
>
{i18n('MyStories__delete')}
</ConfirmationDialog>
)}
{storyToView &&
renderStoryViewer({
conversationId: ourConversationId,
onClose: () => setStoryToView(undefined),
storyToView,
})}
<div className="Stories__pane__header Stories__pane__header--centered">
<button
aria-label={i18n('back')}
className="Stories__pane__header--back"
onClick={onBack}
type="button"
/>
<div className="Stories__pane__header--title">
{i18n('MyStories__title')}
</div>
</div>
<div className="Stories__pane__list">
{myStories.map(list => (
<div className="MyStories__distribution" key={list.distributionId}>
<div className="MyStories__distribution__title">
{list.distributionId === MY_STORIES_ID
? i18n('Stories__mine')
: list.distributionName}
</div>
{list.stories.map(story => (
<div className="MyStories__story" key={story.timestamp}>
{story.attachment && (
<button
aria-label={i18n('MyStories__story')}
className="MyStories__story__preview"
onClick={() => setStoryToView(story)}
type="button"
>
<StoryImage
attachment={story.attachment}
i18n={i18n}
isThumbnail
label={i18n('MyStories__story')}
moduleClassName="MyStories__story__preview"
queueStoryDownload={queueStoryDownload}
storyId={story.messageId}
/>
</button>
)}
<div className="MyStories__story__details">
{story.views === 1
? i18n('MyStories__views--singular', [String(story.views)])
: i18n('MyStories__views--plural', [
String(story.views || 0),
])}
<MessageTimestamp
i18n={i18n}
module="MyStories__story__timestamp"
timestamp={story.timestamp}
/>
</div>
<button
aria-label={i18n('MyStories__download')}
className="MyStories__story__download"
onClick={() => {
onSave(story);
}}
type="button"
/>
<ContextMenu
buttonClassName="MyStories__story__more"
i18n={i18n}
menuOptions={[
{
icon: 'MyStories__icon--save',
label: i18n('save'),
onClick: () => {
onSave(story);
},
},
{
icon: 'MyStories__icon--forward',
label: i18n('forward'),
onClick: () => {
onForward(story.messageId);
},
},
{
icon: 'MyStories__icon--delete',
label: i18n('delete'),
onClick: () => {
setConfirmDeleteStory(story);
},
},
]}
theme={Theme.Dark}
/>
</div>
))}
</div>
))}
</div>
{!myStories.length && (
<div className="Stories__pane__list--empty">
{i18n('Stories__list-empty')}
</div>
)}
</>
);
};

View File

@ -0,0 +1,81 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, ReactFramework, Story } from '@storybook/react';
import type { PlayFunction } from '@storybook/csf';
import React from 'react';
import { expect } from '@storybook/jest';
import { within, userEvent } from '@storybook/testing-library';
import type { PropsType } from './MyStoriesButton';
import enMessages from '../../_locales/en/messages.json';
import { MyStoriesButton } from './MyStoriesButton';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { getFakeStoryView } from '../test-both/helpers/getFakeStory';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/MyStoriesButton',
component: MyStoriesButton,
argTypes: {
hasMultiple: {
control: 'checkbox',
defaultValue: false,
},
i18n: {
defaultValue: i18n,
},
me: {
defaultValue: getDefaultConversation(),
},
newestStory: {
defaultValue: getFakeStoryView(),
},
onClick: {
action: true,
},
queueStoryDownload: {
action: true,
},
},
} as Meta;
const Template: Story<PropsType> = args => <MyStoriesButton {...args} />;
const interactionTest: PlayFunction<ReactFramework, PropsType> = async ({
args,
canvasElement,
}) => {
const canvas = within(canvasElement);
const [btnStory] = canvas.getAllByLabelText('Story');
await userEvent.click(btnStory);
await expect(args.onClick).toHaveBeenCalled();
};
export const NoStory = Template.bind({});
NoStory.args = {
hasMultiple: false,
newestStory: undefined,
};
NoStory.story = {
name: 'No Story',
};
NoStory.play = interactionTest;
export const OneStory = Template.bind({});
OneStory.args = {};
OneStory.story = {
name: 'One Story',
};
OneStory.play = interactionTest;
export const ManyStories = Template.bind({});
ManyStories.args = {
hasMultiple: true,
};
ManyStories.story = {
name: 'Many Stories',
};
ManyStories.play = interactionTest;

View File

@ -0,0 +1,103 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import type { StoryViewType } from '../types/Stories';
import { Avatar, AvatarSize } from './Avatar';
import { StoryImage } from './StoryImage';
import { getAvatarColor } from '../types/Colors';
export type PropsType = {
hasMultiple: boolean;
i18n: LocalizerType;
me: ConversationType;
newestStory?: StoryViewType;
onClick: () => unknown;
queueStoryDownload: (storyId: string) => unknown;
};
export const MyStoriesButton = ({
hasMultiple,
i18n,
me,
newestStory,
onClick,
queueStoryDownload,
}: PropsType): JSX.Element => {
const {
acceptedMessageRequest,
avatarPath,
color,
isMe,
name,
profileName,
sharedGroupNames,
title,
} = me;
return (
<div className="Stories__my-stories">
<button
aria-label={i18n('StoryListItem__label')}
className="StoryListItem"
onClick={onClick}
tabIndex={0}
type="button"
>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
sharedGroupNames={sharedGroupNames}
avatarPath={avatarPath}
badge={undefined}
color={getAvatarColor(color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(isMe)}
name={name}
profileName={profileName}
size={AvatarSize.FORTY_EIGHT}
title={title}
/>
<div className="StoryListItem__info">
<>
<div className="StoryListItem__info--title">
{i18n('Stories__mine')}
</div>
{!newestStory && (
<div className="StoryListItem__info--timestamp">
{i18n('Stories__add')}
</div>
)}
</>
</div>
<div
className={classNames('StoryListItem__previews', {
'StoryListItem__previews--multiple': hasMultiple,
})}
>
{hasMultiple && <div className="StoryListItem__previews--more" />}
{newestStory ? (
<StoryImage
attachment={newestStory.attachment}
i18n={i18n}
isThumbnail
label=""
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={newestStory.messageId}
/>
) : (
<div
aria-label={i18n('Stories__add')}
className="StoryListItem__previews--add StoryListItem__previews--image"
/>
)}
</div>
</button>
</div>
);
};

View File

@ -3,20 +3,16 @@
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { v4 as uuid } from 'uuid';
import { action } from '@storybook/addon-actions';
import type { AttachmentType } from '../types/Attachment';
import type { ConversationType } from '../state/ducks/conversations';
import type { PropsType } from './Stories';
import { Stories } from './Stories';
import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import {
fakeAttachment,
fakeThumbnail,
} from '../test-both/helpers/fakeAttachment';
getFakeMyStory,
getFakeStory,
} from '../test-both/helpers/getFakeStory';
import * as durations from '../util/durations';
const i18n = setupI18n('en', enMessages);
@ -24,119 +20,136 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Stories',
component: Stories,
argTypes: {
deleteStoryForEveryone: { action: true },
hiddenStories: {
defaultValue: [],
},
i18n: {
defaultValue: i18n,
},
myStories: {
defaultValue: [],
},
onForwardStory: { action: true },
onSaveStory: { action: true },
ourConversationId: {
defaultValue: getDefaultConversation().id,
},
preferredWidthFromStorage: {
defaultValue: 380,
},
queueStoryDownload: { action: true },
renderStoryCreator: { action: true },
renderStoryViewer: { action: true },
showConversation: { action: true },
stories: {
defaultValue: [],
},
toggleHideStories: { action: true },
toggleStoriesView: { action: true },
},
} as Meta;
function createStory({
attachment,
group,
timestamp,
}: {
attachment?: AttachmentType;
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
timestamp: number;
}) {
const replies = Math.random() > 0.5;
let hasReplies = false;
let hasRepliesFromSelf = false;
if (replies) {
hasReplies = true;
hasRepliesFromSelf = Math.random() > 0.5;
}
const sender = getDefaultConversation();
return {
conversationId: sender.id,
group,
stories: [
{
attachment,
hasReplies,
hasRepliesFromSelf,
isMe: false,
isUnread: Math.random() > 0.5,
messageId: uuid(),
sender,
timestamp,
},
],
};
}
function getAttachmentWithThumbnail(url: string): AttachmentType {
return fakeAttachment({
url,
thumbnail: fakeThumbnail(url),
});
}
const getDefaultProps = (): PropsType => ({
hiddenStories: [],
i18n,
preferredWidthFromStorage: 380,
queueStoryDownload: action('queueStoryDownload'),
renderStoryCreator: () => <div />,
renderStoryViewer: () => <div />,
showConversation: action('showConversation'),
stories: [
createStory({
attachment: getAttachmentWithThumbnail(
'/fixtures/tina-rolf-269345-unsplash.jpg'
),
timestamp: Date.now() - 2 * durations.MINUTE,
}),
createStory({
attachment: getAttachmentWithThumbnail(
'/fixtures/koushik-chowdavarapu-105425-unsplash.jpg'
),
timestamp: Date.now() - 5 * durations.MINUTE,
}),
createStory({
group: getDefaultConversation({ title: 'BBQ in the park' }),
attachment: getAttachmentWithThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
timestamp: Date.now() - 65 * durations.MINUTE,
}),
createStory({
attachment: getAttachmentWithThumbnail('/fixtures/snow.jpg'),
timestamp: Date.now() - 92 * durations.MINUTE,
}),
createStory({
attachment: getAttachmentWithThumbnail('/fixtures/kitten-1-64-64.jpg'),
timestamp: Date.now() - 164 * durations.MINUTE,
}),
createStory({
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
createStory({
attachment: getAttachmentWithThumbnail('/fixtures/kitten-3-64-64.jpg'),
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],
toggleHideStories: action('toggleHideStories'),
toggleStoriesView: action('toggleStoriesView'),
});
const Template: Story<PropsType> = args => <Stories {...args} />;
export const Blank = Template.bind({});
Blank.args = {
...getDefaultProps(),
stories: [],
};
Blank.args = {};
export const Many = Template.bind({});
Many.args = getDefaultProps();
Many.args = {
stories: [
getFakeStory({
attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg',
timestamp: Date.now() - 2 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/koushik-chowdavarapu-105425-unsplash.jpg',
timestamp: Date.now() - 5 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/nathan-anderson-316188-unsplash.jpg',
group: getDefaultConversation({ title: 'BBQ in the park' }),
timestamp: Date.now() - 65 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/snow.jpg',
timestamp: Date.now() - 92 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-1-64-64.jpg',
timestamp: Date.now() - 164 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-2-64-64.jpg',
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-3-64-64.jpg',
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],
};
export const HiddenStories = Template.bind({});
HiddenStories.args = {
hiddenStories: [
getFakeStory({
attachmentUrl: '/fixtures/kitten-1-64-64.jpg',
timestamp: Date.now() - 164 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-2-64-64.jpg',
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-3-64-64.jpg',
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],
stories: [
getFakeStory({
attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg',
timestamp: Date.now() - 2 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/snow.jpg',
timestamp: Date.now() - 92 * durations.MINUTE,
}),
],
};
export const MyStories = Template.bind({});
MyStories.args = {
myStories: [
getFakeMyStory(undefined, 'BFF'),
getFakeMyStory(undefined, 'The Fun Group'),
],
hiddenStories: [
getFakeStory({
attachmentUrl: '/fixtures/kitten-1-64-64.jpg',
timestamp: Date.now() - 164 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-2-64-64.jpg',
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
timestamp: Date.now() - 380 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/kitten-3-64-64.jpg',
timestamp: Date.now() - 421 * durations.MINUTE,
}),
],
stories: [
getFakeStory({
attachmentUrl: '/fixtures/tina-rolf-269345-unsplash.jpg',
timestamp: Date.now() - 2 * durations.MINUTE,
}),
getFakeStory({
attachmentUrl: '/fixtures/snow.jpg',
timestamp: Date.now() - 92 * durations.MINUTE,
}),
],
};

View File

@ -4,19 +4,33 @@
import FocusTrap from 'focus-trap-react';
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType } from './StoryListItem';
import type {
ConversationType,
ShowConversationType,
} from '../state/ducks/conversations';
import type {
ConversationStoryType,
MyStoryType,
StoryViewType,
} from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import type { ShowConversationType } from '../state/ducks/conversations';
import * as log from '../logging/log';
import { MyStories } from './MyStories';
import { StoriesPane } from './StoriesPane';
import { Theme, themeClassName } from '../util/theme';
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
import * as log from '../logging/log';
export type PropsType = {
deleteStoryForEveryone: (story: StoryViewType) => unknown;
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
me: ConversationType;
myStories: Array<MyStoryType>;
onForwardStory: (storyId: string) => unknown;
onSaveStory: (story: StoryViewType) => unknown;
ourConversationId: string;
preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
@ -28,8 +42,14 @@ export type PropsType = {
};
export const Stories = ({
deleteStoryForEveryone,
hiddenStories,
i18n,
me,
myStories,
onForwardStory,
onSaveStory,
ourConversationId,
preferredWidthFromStorage,
queueStoryDownload,
renderStoryCreator,
@ -100,6 +120,7 @@ export const Stories = ({
}, [conversationIdToView, stories]);
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
const [isMyStories, setIsMyStories] = useState(false);
return (
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
@ -116,26 +137,49 @@ export const Stories = ({
})}
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="Stories__pane" style={{ width }}>
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
onAddStory={() => setIsShowingStoryCreator(true)}
onStoryClicked={clickedIdToView => {
const storyIndex = stories.findIndex(
x => x.conversationId === clickedIdToView
);
log.info('stories.onStoryClicked', {
storyIndex,
length: stories.length,
});
setConversationIdToView(clickedIdToView);
}}
queueStoryDownload={queueStoryDownload}
showConversation={showConversation}
stories={stories}
toggleHideStories={toggleHideStories}
toggleStoriesView={toggleStoriesView}
/>
{isMyStories && myStories.length ? (
<MyStories
i18n={i18n}
myStories={myStories}
onBack={() => setIsMyStories(false)}
onDelete={deleteStoryForEveryone}
onForward={onForwardStory}
onSave={onSaveStory}
ourConversationId={ourConversationId}
queueStoryDownload={queueStoryDownload}
renderStoryViewer={renderStoryViewer}
/>
) : (
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
me={me}
myStories={myStories}
onAddStory={() => setIsShowingStoryCreator(true)}
onMyStoriesClicked={() => {
if (myStories.length) {
setIsMyStories(true);
} else {
setIsShowingStoryCreator(true);
}
}}
onStoryClicked={clickedIdToView => {
const storyIndex = stories.findIndex(
x => x.conversationId === clickedIdToView
);
log.info('stories.onStoryClicked[StoriesPane]', {
storyIndex,
length: stories.length,
});
setConversationIdToView(clickedIdToView);
}}
queueStoryDownload={queueStoryDownload}
showConversation={showConversation}
stories={stories}
toggleHideStories={toggleHideStories}
toggleStoriesView={toggleStoriesView}
/>
)}
</div>
</FocusTrap>
<div className="Stories__placeholder">

View File

@ -5,9 +5,17 @@ import Fuse from 'fuse.js';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type {
ConversationType,
ShowConversationType,
} from '../state/ducks/conversations';
import type {
ConversationStoryType,
MyStoryType,
StoryViewType,
} from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { ShowConversationType } from '../state/ducks/conversations';
import { MyStoriesButton } from './MyStoriesButton';
import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem';
import { isNotNil } from '../util/isNotNil';
@ -47,14 +55,19 @@ function search(
.map(result => result.item);
}
function getNewestStory(story: ConversationStoryType): StoryViewType {
function getNewestStory(
story: ConversationStoryType | MyStoryType
): StoryViewType {
return story.stories[story.stories.length - 1];
}
export type PropsType = {
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
me: ConversationType;
myStories: Array<MyStoryType>;
onAddStory: () => unknown;
onMyStoriesClicked: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown;
showConversation: ShowConversationType;
@ -66,7 +79,10 @@ export type PropsType = {
export const StoriesPane = ({
hiddenStories,
i18n,
me,
myStories,
onAddStory,
onMyStoriesClicked,
onStoryClicked,
queueStoryDownload,
showConversation,
@ -116,6 +132,16 @@ export const StoriesPane = ({
placeholder={i18n('search')}
value={searchTerm}
/>
<MyStoriesButton
hasMultiple={myStories.length ? myStories[0].stories.length > 1 : false}
i18n={i18n}
me={me}
newestStory={
myStories.length ? getNewestStory(myStories[0]) : undefined
}
onClick={onMyStoriesClicked}
queueStoryDownload={queueStoryDownload}
/>
<div
className={classNames('Stories__pane__list', {
'Stories__pane__list--empty': !stories.length,

View File

@ -1,8 +1,8 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './StoryListItem';
import { StoryListItem } from './StoryListItem';
@ -18,72 +18,41 @@ const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoryListItem',
};
function getDefaultProps(): PropsType {
return {
i18n,
onClick: action('onClick'),
onGoToConversation: action('onGoToConversation'),
onHideStory: action('onHideStory'),
queueStoryDownload: action('queueStoryDownload'),
story: {
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
component: StoryListItem,
argTypes: {
i18n: {
defaultValue: i18n,
},
};
}
onClick: { action: true },
onGoToConversation: { action: true },
onHideStory: { action: true },
queueStoryDownload: { action: true },
story: {
defaultValue: {
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
},
},
} as Meta;
export const MyStory = (): JSX.Element => (
<StoryListItem
{...getDefaultProps()}
story={{
messageId: '123',
sender: getDefaultConversation({ isMe: true }),
timestamp: Date.now(),
}}
/>
);
const Template: Story<PropsType> = args => <StoryListItem {...args} />;
export const MyStoryMany = (): JSX.Element => (
<StoryListItem
{...getDefaultProps()}
story={{
attachment: fakeAttachment({
thumbnail: fakeThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
}),
messageId: '123',
sender: getDefaultConversation({ isMe: true }),
timestamp: Date.now(),
}}
hasMultiple
/>
);
MyStoryMany.story = {
name: 'My Story (many)',
export const SomeonesStory = Template.bind({});
SomeonesStory.args = {
group: getDefaultConversation({ title: 'Sports Group' }),
story: {
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}),
hasReplies: true,
isUnread: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
},
};
export const SomeonesStory = (): JSX.Element => (
<StoryListItem
{...getDefaultProps()}
group={getDefaultConversation({ title: 'Sports Group' })}
story={{
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}),
hasReplies: true,
isUnread: true,
messageId: '123',
sender: getDefaultConversation(),
timestamp: Date.now(),
}}
/>
);
SomeonesStory.story = {
name: "Someone's story",
};

View File

@ -3,9 +3,8 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import type { AttachmentType } from '../types/Attachment';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { ConversationStoryType, StoryViewType } from '../types/Stories';
import { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu';
@ -13,53 +12,7 @@ import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage';
import { getAvatarColor } from '../types/Colors';
export type ConversationStoryType = {
conversationId: string;
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
hasMultiple?: boolean;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
stories: Array<StoryViewType>;
};
export type StoryViewType = {
attachment?: AttachmentType;
canReply?: boolean;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
isHidden?: boolean;
isUnread?: boolean;
messageId: string;
sender: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'firstName'
| 'id'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
timestamp: number;
};
export type PropsType = Pick<
ConversationStoryType,
'group' | 'hasMultiple' | 'isHidden'
> & {
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
i18n: LocalizerType;
onClick: () => unknown;
onGoToConversation: (conversationId: string) => unknown;
@ -70,7 +23,6 @@ export type PropsType = Pick<
export const StoryListItem = ({
group,
hasMultiple,
i18n,
isHidden,
onClick,
@ -129,9 +81,7 @@ export const StoryListItem = ({
ev.preventDefault();
ev.stopPropagation();
if (!isMe) {
setIsShowingContextMenu(true);
}
setIsShowingContextMenu(true);
}}
ref={setReferenceElement}
tabIndex={0}
@ -153,49 +103,25 @@ export const StoryListItem = ({
title={title}
/>
<div className="StoryListItem__info">
{isMe ? (
<>
<div className="StoryListItem__info--title">
{i18n('Stories__mine')}
</div>
{!attachment && (
<div className="StoryListItem__info--timestamp">
{i18n('Stories__add')}
</div>
)}
</>
) : (
<>
<div className="StoryListItem__info--title">
{group
? i18n('Stories__from-to-group', {
name: title,
group: group.title,
})
: title}
</div>
<MessageTimestamp
i18n={i18n}
module="StoryListItem__info--timestamp"
timestamp={timestamp}
/>
</>
)}
<>
<div className="StoryListItem__info--title">
{group
? i18n('Stories__from-to-group', {
name: title,
group: group.title,
})
: title}
</div>
<MessageTimestamp
i18n={i18n}
module="StoryListItem__info--timestamp"
timestamp={timestamp}
/>
</>
{repliesElement}
</div>
<div
className={classNames('StoryListItem__previews', {
'StoryListItem__previews--multiple': hasMultiple,
})}
>
{!attachment && isMe && (
<div
aria-label={i18n('Stories__add')}
className="StoryListItem__previews--add StoryListItem__previews--image"
/>
)}
{hasMultiple && <div className="StoryListItem__previews--more" />}
<div className="StoryListItem__previews">
<StoryImage
attachment={attachment}
i18n={i18n}

View File

@ -16,14 +16,14 @@ import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import type { ReplyStateType } from '../types/Stories';
import type { StoryViewType } from './StoryListItem';
import type { ReplyStateType, StoryViewType } from '../types/Stories';
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenuPopper } from './ContextMenu';
import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { SendStatus } from '../messages/MessageSendState';
import { StoryImage } from './StoryImage';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { Theme } from '../util/theme';
@ -56,8 +56,8 @@ export type PropsType = {
onClose: () => unknown;
onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown;
onNextUserStories: () => unknown;
onPrevUserStories: () => unknown;
onNextUserStories?: () => unknown;
onPrevUserStories?: () => unknown;
onSetSkinTone: (tone: number) => unknown;
onTextTooLong: () => unknown;
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
@ -76,7 +76,6 @@ export type PropsType = {
skinTone?: number;
stories: Array<StoryViewType>;
toggleHasAllStoriesMuted: () => unknown;
views?: Array<string>;
};
const CAPTION_BUFFER = 20;
@ -116,7 +115,6 @@ export const StoryViewer = ({
skinTone,
stories,
toggleHasAllStoriesMuted,
views,
}: PropsType): JSX.Element => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const [storyDuration, setStoryDuration] = useState<number | undefined>();
@ -128,7 +126,8 @@ export const StoryViewer = ({
const visibleStory = stories[currentStoryIndex];
const { attachment, canReply, isHidden, messageId, timestamp } = visibleStory;
const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
visibleStory;
const {
acceptedMessageRequest,
avatarPath,
@ -202,7 +201,7 @@ export const StoryViewer = ({
setCurrentStoryIndex(currentStoryIndex + 1);
} else {
setCurrentStoryIndex(0);
onNextUserStories();
onNextUserStories?.();
}
}, [currentStoryIndex, onNextUserStories, stories.length]);
@ -210,7 +209,7 @@ export const StoryViewer = ({
// for the prior user's stories.
const showPrevStory = useCallback(() => {
if (currentStoryIndex === 0) {
onPrevUserStories();
onPrevUserStories?.();
} else {
setCurrentStoryIndex(currentStoryIndex - 1);
}
@ -378,9 +377,13 @@ export const StoryViewer = ({
const replies =
replyState && replyState.messageId === messageId ? replyState.replies : [];
const viewCount = (views || []).length;
const views = sendState
? sendState.filter(({ status }) => status === SendStatus.Viewed)
: [];
const replyCount = replies.length;
const viewCount = views.length;
const shouldShowContextMenu = !sendState;
return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
@ -390,18 +393,20 @@ export const StoryViewer = ({
style={{ background: getStoryBackground(attachment) }}
/>
<div className="StoryViewer__content">
<button
aria-label={i18n('back')}
className={classNames(
'StoryViewer__arrow StoryViewer__arrow--left',
{
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
}
)}
onClick={showPrevStory}
onMouseMove={() => setArrowToShow(Arrow.Left)}
type="button"
/>
{onPrevUserStories && (
<button
aria-label={i18n('back')}
className={classNames(
'StoryViewer__arrow StoryViewer__arrow--left',
{
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
}
)}
onClick={showPrevStory}
onMouseMove={() => setArrowToShow(Arrow.Left)}
type="button"
/>
)}
<div className="StoryViewer__protection StoryViewer__protection--top" />
<div className="StoryViewer__container">
<StoryImage
@ -532,13 +537,15 @@ export const StoryViewer = ({
onClick={toggleHasAllStoriesMuted}
type="button"
/>
<button
aria-label={i18n('MyStories__more')}
className="StoryViewer__more"
onClick={() => setIsShowingContextMenu(true)}
ref={setReferenceElement}
type="button"
/>
{shouldShowContextMenu && (
<button
aria-label={i18n('MyStories__more')}
className="StoryViewer__more"
onClick={() => setIsShowingContextMenu(true)}
ref={setReferenceElement}
type="button"
/>
)}
</div>
</div>
<div className="StoryViewer__progress">
@ -619,18 +626,20 @@ export const StoryViewer = ({
)}
</div>
</div>
<button
aria-label={i18n('forward')}
className={classNames(
'StoryViewer__arrow StoryViewer__arrow--right',
{
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
}
)}
onClick={showNextStory}
onMouseMove={() => setArrowToShow(Arrow.Right)}
type="button"
/>
{onNextUserStories && (
<button
aria-label={i18n('forward')}
className={classNames(
'StoryViewer__arrow StoryViewer__arrow--right',
{
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
}
)}
onClick={showNextStory}
onMouseMove={() => setArrowToShow(Arrow.Right)}
type="button"
/>
)}
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
<button
aria-label={i18n('close')}
@ -696,7 +705,7 @@ export const StoryViewer = ({
replies={replies}
skinTone={skinTone}
storyPreviewAttachment={attachment}
views={[]}
views={views}
/>
)}
{hasConfirmHideStory && (

View File

@ -8,6 +8,7 @@ import type { PropsType } from './StoryViewsNRepliesModal';
import * as durations from '../util/durations';
import enMessages from '../../_locales/en/messages.json';
import { IMAGE_JPEG } from '../types/MIME';
import { SendStatus } from '../messages/MessageSendState';
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
@ -56,24 +57,29 @@ function getViewsAndReplies() {
const views = [
{
...p1,
timestamp: Date.now() - 20 * durations.MINUTE,
recipient: p1,
status: SendStatus.Viewed,
updatedAt: Date.now() - 20 * durations.MINUTE,
},
{
...p2,
timestamp: Date.now() - 25 * durations.MINUTE,
recipient: p2,
status: SendStatus.Viewed,
updatedAt: Date.now() - 25 * durations.MINUTE,
},
{
...p3,
timestamp: Date.now() - 15 * durations.MINUTE,
recipient: p3,
status: SendStatus.Viewed,
updatedAt: Date.now() - 15 * durations.MINUTE,
},
{
...p4,
timestamp: Date.now() - 5 * durations.MINUTE,
recipient: p4,
status: SendStatus.Viewed,
updatedAt: Date.now() - 5 * durations.MINUTE,
},
{
...p5,
timestamp: Date.now() - 30 * durations.MINUTE,
recipient: p5,
status: SendStatus.Viewed,
updatedAt: Date.now() - 30 * durations.MINUTE,
},
];

View File

@ -6,13 +6,11 @@ import classNames from 'classnames';
import { usePopper } from 'react-popper';
import type { AttachmentType } from '../types/Attachment';
import type { BodyRangeType, LocalizerType } from '../types/Util';
import type { ContactNameColorType } from '../types/Colors';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import type { ReplyType } from '../types/Stories';
import type { ReplyType, StorySendStateType } from '../types/Stories';
import { Avatar, AvatarSize } from './Avatar';
import { CompositionInput } from './CompositionInput';
import { ContactName } from './conversation/ContactName';
@ -29,21 +27,6 @@ import { ThemeType } from '../types/Util';
import { getAvatarColor } from '../types/Colors';
import { getStoryReplyText } from '../util/getStoryReplyText';
type ViewType = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
contactNameColor?: ContactNameColorType;
timestamp: number;
};
enum Tab {
Replies = 'Replies',
Views = 'Views',
@ -71,7 +54,7 @@ export type PropsType = {
replies: Array<ReplyType>;
skinTone?: number;
storyPreviewAttachment?: AttachmentType;
views: Array<ViewType>;
views: Array<StorySendStateType>;
};
export const StoryViewsNRepliesModal = ({
@ -328,34 +311,33 @@ export const StoryViewsNRepliesModal = ({
const viewsElement = views.length ? (
<div className="StoryViewsNRepliesModal__views">
{views.map(view => (
<div className="StoryViewsNRepliesModal__view" key={view.timestamp}>
<div className="StoryViewsNRepliesModal__view" key={view.recipient.id}>
<div>
<Avatar
acceptedMessageRequest={view.acceptedMessageRequest}
avatarPath={view.avatarPath}
acceptedMessageRequest={view.recipient.acceptedMessageRequest}
avatarPath={view.recipient.avatarPath}
badge={undefined}
color={getAvatarColor(view.color)}
color={getAvatarColor(view.recipient.color)}
conversationType="direct"
i18n={i18n}
isMe={Boolean(view.isMe)}
name={view.name}
profileName={view.profileName}
sharedGroupNames={view.sharedGroupNames || []}
isMe={Boolean(view.recipient.isMe)}
name={view.recipient.name}
profileName={view.recipient.profileName}
sharedGroupNames={view.recipient.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
title={view.title}
title={view.recipient.title}
/>
<span className="StoryViewsNRepliesModal__view--name">
<ContactName
contactNameColor={view.contactNameColor}
title={view.title}
/>
<ContactName title={view.recipient.title} />
</span>
</div>
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__view--timestamp"
timestamp={view.timestamp}
/>
{view.updatedAt && (
<MessageTimestamp
i18n={i18n}
module="StoryViewsNRepliesModal__view--timestamp"
timestamp={view.updatedAt}
/>
)}
</div>
))}
</div>

View File

@ -133,12 +133,92 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
return receipts;
}
async onReceipt(receipt: MessageReceiptModel): Promise<void> {
const type = receipt.get('type');
private async updateMessageSendState(
receipt: MessageReceiptModel,
message: MessageModel
): Promise<void> {
const messageSentAt = receipt.get('messageSentAt');
const receiptTimestamp = receipt.get('receiptTimestamp');
const sourceConversationId = receipt.get('sourceConversationId');
const type = receipt.get('type');
const oldSendStateByConversationId =
message.get('sendStateByConversationId') || {};
const oldSendState = getOwn(
oldSendStateByConversationId,
sourceConversationId
) ?? { status: SendStatus.Sent, updatedAt: undefined };
let sendActionType: SendActionType;
switch (type) {
case MessageReceiptType.Delivery:
sendActionType = SendActionType.GotDeliveryReceipt;
break;
case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt;
break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default:
throw missingCaseError(type);
}
const newSendState = sendStateReducer(oldSendState, {
type: sendActionType,
updatedAt: receiptTimestamp,
});
// The send state may not change. For example, this can happen if we get a read
// receipt before a delivery receipt.
if (!isEqual(oldSendState, newSendState)) {
message.set('sendStateByConversationId', {
...oldSendStateByConversationId,
[sourceConversationId]: newSendState,
});
window.Signal.Util.queueUpdateMessage(message.attributes);
// notify frontend listeners
const conversation = window.ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
}
if (
(type === MessageReceiptType.Delivery &&
wasDeliveredWithSealedSender(sourceConversationId, message)) ||
type === MessageReceiptType.Read
) {
const recipient = window.ConversationController.get(sourceConversationId);
const recipientUuid = recipient?.get('uuid');
const deviceId = receipt.get('sourceDevice');
if (recipientUuid && deviceId) {
await deleteSentProtoBatcher.add({
timestamp: messageSentAt,
recipientUuid,
deviceId,
});
} else {
log.warn(
`MessageReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${sourceConversationId}`
);
}
}
}
async onReceipt(receipt: MessageReceiptModel): Promise<void> {
const messageSentAt = receipt.get('messageSentAt');
const sourceConversationId = receipt.get('sourceConversationId');
const sourceUuid = receipt.get('sourceUuid');
const type = receipt.get('type');
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
@ -150,86 +230,36 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
sourceUuid,
messages
);
if (!message) {
log.info(
'No message for receipt',
type,
sourceConversationId,
messageSentAt
if (message) {
await this.updateMessageSendState(receipt, message);
} else {
// We didn't find any messages but maybe it's a story sent message
const targetMessages = messages.filter(
item =>
item.storyDistributionListId &&
item.sendStateByConversationId &&
!item.deletedForEveryone &&
Boolean(item.sendStateByConversationId[sourceConversationId])
);
return;
}
const oldSendStateByConversationId =
message.get('sendStateByConversationId') || {};
const oldSendState = getOwn(
oldSendStateByConversationId,
sourceConversationId
) ?? { status: SendStatus.Sent, updatedAt: undefined };
let sendActionType: SendActionType;
switch (type) {
case MessageReceiptType.Delivery:
sendActionType = SendActionType.GotDeliveryReceipt;
break;
case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt;
break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default:
throw missingCaseError(type);
}
const newSendState = sendStateReducer(oldSendState, {
type: sendActionType,
updatedAt: receiptTimestamp,
});
// The send state may not change. For example, this can happen if we get a read
// receipt before a delivery receipt.
if (!isEqual(oldSendState, newSendState)) {
message.set('sendStateByConversationId', {
...oldSendStateByConversationId,
[sourceConversationId]: newSendState,
});
window.Signal.Util.queueUpdateMessage(message.attributes);
// notify frontend listeners
const conversation = window.ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
}
if (
(type === MessageReceiptType.Delivery &&
wasDeliveredWithSealedSender(sourceConversationId, message)) ||
type === MessageReceiptType.Read
) {
const recipient =
window.ConversationController.get(sourceConversationId);
const recipientUuid = recipient?.get('uuid');
const deviceId = receipt.get('sourceDevice');
if (recipientUuid && deviceId) {
await deleteSentProtoBatcher.add({
timestamp: messageSentAt,
recipientUuid,
deviceId,
});
} else {
log.warn(
`MessageReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${sourceConversationId}`
// Nope, no target message was found
if (!targetMessages.length) {
log.info(
'No message for receipt',
type,
sourceConversationId,
messageSentAt
);
return;
}
await Promise.all(
targetMessages.map(msg => {
const model = window.MessageController.register(msg.id, msg);
return this.updateMessageSendState(receipt, model);
})
);
}
this.remove(receipt);

View File

@ -69,6 +69,7 @@ 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<{
isAllowedToReplyToStory?: boolean;
status:
| SendStatus.Pending
| SendStatus.Failed

2
ts/model-types.d.ts vendored
View File

@ -120,6 +120,7 @@ export type MessageAttributesType = {
bodyAttachment?: AttachmentType;
bodyRanges?: BodyRangesType;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
canReplyToStory?: boolean;
changedId?: string;
dataMessage?: Uint8Array | null;
decrypted_at?: number;
@ -147,6 +148,7 @@ export type MessageAttributesType = {
requiredProtocolVersion?: number;
retryOptions?: RetryOptions;
sourceDevice?: number;
storyDistributionListId?: string;
storyId?: string;
storyReplyContext?: StoryReplyContextType;
supportedVersionAtReceive?: unknown;

View File

@ -107,9 +107,7 @@ import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
import { DeleteModel } from '../messageModifiers/Deletes';
import type { ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
import { getProfile } from '../util/getProfile';
@ -124,6 +122,8 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { TimelineMessageLoadingState } from '../util/timelineUtil';
import { SeenStatus } from '../MessageSeenStatus';
import { getConversationIdForLogging } from '../util/idForLogging';
import { getSendTarget } from '../util/getSendTarget';
import { getRecipients } from '../util/getRecipients';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -148,7 +148,6 @@ const {
getNewerMessagesByConversation,
} = window.Signal.Data;
const THREE_HOURS = durations.HOUR * 3;
const FIVE_MINUTES = durations.MINUTE * 5;
const JOB_REPORTING_THRESHOLD_MS = 25;
@ -248,7 +247,7 @@ export class ConversationModel extends window.Backbone
// This is one of the few times that we want to collapse our uuid/e164 pair down into
// just one bit of data. If we have a UUID, we'll send using it.
getSendTarget(): string | undefined {
return this.get('uuid') || this.get('e164');
return getSendTarget(this.attributes);
}
getContactCollection(): Backbone.Collection<ConversationModel> {
@ -3615,32 +3614,10 @@ export class ConversationModel extends window.Backbone
includePendingMembers?: boolean;
extraConversationsForSend?: Array<string>;
} = {}): Array<string> {
if (isDirectConversation(this.attributes)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [this.getSendTarget()!];
}
const members = this.getMembers({ includePendingMembers });
// There are cases where we need to send to someone we just removed from the group, to
// let them know that we removed them. In that case, we need to send to more than
// are currently in the group.
const extraConversations = extraConversationsForSend
? extraConversationsForSend
.map(id => window.ConversationController.get(id))
.filter(isNotNil)
: [];
const unique = extraConversations.length
? window._.unique([...members, ...extraConversations])
: members;
// Eliminate ourself
return window._.compact(
unique.map(member =>
isMe(member.attributes) ? null : member.getSendTarget()
)
);
return getRecipients(this.attributes, {
includePendingMembers,
extraConversationsForSend,
});
}
// Members is all people in the group
@ -3648,24 +3625,6 @@ export class ConversationModel extends window.Backbone
return new Set(map(this.getMembers(), conversation => conversation.id));
}
// Recipients includes only the people we'll actually send to for this conversation
getRecipientConversationIds(): Set<string> {
const recipients = this.getRecipients();
const conversationIds = recipients.map(identifier => {
const conversation = window.ConversationController.getOrCreate(
identifier,
'private'
);
strictAssert(
conversation,
'getRecipientConversationIds should have created conversation!'
);
return conversation.id;
});
return new Set(conversationIds);
}
async getQuoteAttachment(
attachments?: Array<WhatIsThis>,
preview?: Array<WhatIsThis>,
@ -3848,65 +3807,6 @@ export class ConversationModel extends window.Backbone
window.reduxActions.stickers.useSticker(packId, stickerId);
}
async sendDeleteForEveryoneMessage(options: {
id: string;
timestamp: number;
}): Promise<void> {
const { timestamp: targetTimestamp, id: messageId } = options;
const message = await getMessageById(messageId);
if (!message) {
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
}
const messageModel = window.MessageController.register(messageId, message);
const timestamp = Date.now();
if (timestamp - targetTimestamp > THREE_HOURS) {
throw new Error('Cannot send DOE for a message older than three hours');
}
messageModel.set({
deletedForEveryoneSendStatus: zipObject(
this.getRecipientConversationIds(),
repeat(false)
),
});
try {
const jobData: ConversationQueueJobData = {
type: conversationQueueJobEnum.enum.DeleteForEveryone,
conversationId: this.id,
messageId,
recipients: this.getRecipients(),
revision: this.get('revision'),
targetTimestamp,
};
await conversationJobQueue.add(jobData, async jobToInsert => {
log.info(
`sendDeleteForEveryoneMessage: saving message ${this.idForLogging()} and job ${
jobToInsert.id
}`
);
await window.Signal.Data.saveMessage(messageModel.attributes, {
jobToInsert,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
});
} catch (error) {
log.error(
'sendDeleteForEveryoneMessage: Failed to queue delete for everyone',
Errors.toLogFormat(error)
);
throw error;
}
const deleteModel = new DeleteModel({
targetSentTimestamp: targetTimestamp,
serverTimestamp: Date.now(),
fromId: window.ConversationController.getOurConversationIdOrThrow(),
});
await window.Signal.Util.deleteForEveryone(messageModel, deleteModel);
}
async sendProfileKeyUpdate(): Promise<void> {
if (isMe(this.attributes)) {
return;

View File

@ -0,0 +1,28 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import dataInterface from '../sql/Client';
import type { StoryDistributionWithMembersType } from '../sql/Interface';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import { strictAssert } from '../util/assert';
let distributionLists: Array<StoryDistributionWithMembersType> | undefined;
export async function loadDistributionLists(): Promise<void> {
distributionLists = await dataInterface.getAllStoryDistributionsWithMembers();
}
export function getDistributionListsForRedux(): Array<StoryDistributionListDataType> {
strictAssert(distributionLists, 'distributionLists has not been loaded');
const lists = distributionLists.map(list => ({
allowsReplies: Boolean(list.allowsReplies),
id: list.id,
isBlockList: Boolean(list.isBlockList),
name: list.name,
}));
distributionLists = undefined;
return lists;
}

View File

@ -20,10 +20,12 @@ import {
mergeContactRecord,
mergeGroupV1Record,
mergeGroupV2Record,
mergeStoryDistributionListRecord,
toAccountRecord,
toContactRecord,
toGroupV1Record,
toGroupV2Record,
toStoryDistributionListRecord,
} from './storageRecordOps';
import type { MergeResultType } from './storageRecordOps';
import { MAX_READ_KEYS } from './storageConstants';
@ -67,6 +69,7 @@ const validRecordTypes = new Set([
2, // GROUPV1
3, // GROUPV2
4, // ACCOUNT
5, // STORY_DISTRIBUTION_LIST
]);
const backOff = new BackOff([
@ -99,10 +102,10 @@ function redactExtendedStorageID({
return redactStorageID(storageID, storageVersion);
}
async function encryptRecord(
function encryptRecord(
storageID: string | undefined,
storageRecord: Proto.IStorageRecord
): Promise<Proto.StorageItem> {
): Proto.StorageItem {
const storageItem = new Proto.StorageItem();
const storageKeyBuffer = storageID
@ -161,11 +164,80 @@ async function generateManifest(
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
const newItems: Set<Proto.IStorageItem> = new Set();
function processStorageRecord({
conversation,
currentStorageID,
currentStorageVersion,
identifierType,
storageNeedsSync,
storageRecord,
}: {
conversation?: ConversationModel;
currentStorageID?: string;
currentStorageVersion?: number;
identifierType: Proto.ManifestRecord.Identifier.Type;
storageNeedsSync: boolean;
storageRecord: Proto.IStorageRecord;
}) {
const identifier = new Proto.ManifestRecord.Identifier();
identifier.type = identifierType;
const currentRedactedID = currentStorageID
? redactStorageID(currentStorageID, currentStorageVersion)
: undefined;
const isNewItem = isNewManifest || storageNeedsSync || !currentStorageID;
const storageID = isNewItem
? Bytes.toBase64(generateStorageID())
: currentStorageID;
let storageItem;
try {
storageItem = encryptRecord(storageID, storageRecord);
} catch (err) {
log.error(
`storageService.upload(${version}): encrypt record failed:`,
Errors.toLogFormat(err)
);
throw err;
}
identifier.raw = storageItem.key;
// When a client needs to update a given record it should create it
// under a new key and delete the existing key.
if (isNewItem) {
newItems.add(storageItem);
insertKeys.push(storageID);
const newRedactedID = redactStorageID(storageID, version, conversation);
if (currentStorageID) {
log.info(
`storageService.upload(${version}): ` +
`updating from=${currentRedactedID} ` +
`to=${newRedactedID}`
);
deleteKeys.push(Bytes.fromBase64(currentStorageID));
} else {
log.info(
`storageService.upload(${version}): adding key=${newRedactedID}`
);
}
}
manifestRecordKeys.add(identifier);
return {
isNewItem,
storageID,
};
}
const conversations = window.getConversations();
for (let i = 0; i < conversations.length; i += 1) {
const conversation = conversations.models[i];
const identifier = new Proto.ManifestRecord.Identifier();
let identifierType;
let storageRecord;
const conversationType = typeofConversation(conversation.attributes);
@ -173,7 +245,7 @@ async function generateManifest(
storageRecord = new Proto.StorageRecord();
// eslint-disable-next-line no-await-in-loop
storageRecord.account = await toAccountRecord(conversation);
identifier.type = ITEM_TYPE.ACCOUNT;
identifierType = ITEM_TYPE.ACCOUNT;
} else if (conversationType === ConversationTypes.Direct) {
// Contacts must have UUID
if (!conversation.get('uuid')) {
@ -207,17 +279,15 @@ async function generateManifest(
storageRecord = new Proto.StorageRecord();
// eslint-disable-next-line no-await-in-loop
storageRecord.contact = await toContactRecord(conversation);
identifier.type = ITEM_TYPE.CONTACT;
identifierType = ITEM_TYPE.CONTACT;
} else if (conversationType === ConversationTypes.GroupV2) {
storageRecord = new Proto.StorageRecord();
// eslint-disable-next-line no-await-in-loop
storageRecord.groupV2 = await toGroupV2Record(conversation);
identifier.type = ITEM_TYPE.GROUPV2;
storageRecord.groupV2 = toGroupV2Record(conversation);
identifierType = ITEM_TYPE.GROUPV2;
} else if (conversationType === ConversationTypes.GroupV1) {
storageRecord = new Proto.StorageRecord();
// eslint-disable-next-line no-await-in-loop
storageRecord.groupV1 = await toGroupV1Record(conversation);
identifier.type = ITEM_TYPE.GROUPV1;
storageRecord.groupV1 = toGroupV1Record(conversation);
identifierType = ITEM_TYPE.GROUPV1;
} else {
log.warn(
`storageService.upload(${version}): ` +
@ -225,59 +295,20 @@ async function generateManifest(
);
}
if (!storageRecord) {
if (!storageRecord || !identifierType) {
continue;
}
const currentStorageID = conversation.get('storageID');
const currentStorageVersion = conversation.get('storageVersion');
const { isNewItem, storageID } = processStorageRecord({
conversation,
currentStorageID: conversation.get('storageID'),
currentStorageVersion: conversation.get('storageVersion'),
identifierType,
storageNeedsSync: Boolean(conversation.get('needsStorageServiceSync')),
storageRecord,
});
const currentRedactedID = currentStorageID
? redactStorageID(currentStorageID, currentStorageVersion)
: undefined;
const isNewItem =
isNewManifest ||
Boolean(conversation.get('needsStorageServiceSync')) ||
!currentStorageID;
const storageID = isNewItem
? Bytes.toBase64(generateStorageID())
: currentStorageID;
let storageItem;
try {
// eslint-disable-next-line no-await-in-loop
storageItem = await encryptRecord(storageID, storageRecord);
} catch (err) {
log.error(
`storageService.upload(${version}): encrypt record failed:`,
Errors.toLogFormat(err)
);
throw err;
}
identifier.raw = storageItem.key;
// When a client needs to update a given record it should create it
// under a new key and delete the existing key.
if (isNewItem) {
newItems.add(storageItem);
insertKeys.push(storageID);
const newRedactedID = redactStorageID(storageID, version, conversation);
if (currentStorageID) {
log.info(
`storageService.upload(${version}): ` +
`updating from=${currentRedactedID} ` +
`to=${newRedactedID}`
);
deleteKeys.push(Bytes.fromBase64(currentStorageID));
} else {
log.info(
`storageService.upload(${version}): adding key=${newRedactedID}`
);
}
postUploadUpdateFunctions.push(() => {
conversation.set({
needsStorageServiceSync: false,
@ -287,10 +318,36 @@ async function generateManifest(
updateConversation(conversation.attributes);
});
}
manifestRecordKeys.add(identifier);
}
const storyDistributionLists =
await dataInterface.getAllStoryDistributionsWithMembers();
log.info(
`storageService.upload(${version}): adding storyDistributionLists=${storyDistributionLists.length}`
);
storyDistributionLists.forEach(storyDistributionList => {
const { isNewItem, storageID } = processStorageRecord({
currentStorageID: storyDistributionList.storageID,
currentStorageVersion: storyDistributionList.storageVersion,
identifierType: ITEM_TYPE.STORY_DISTRIBUTION_LIST,
storageNeedsSync: storyDistributionList.storageNeedsSync,
storageRecord: toStoryDistributionListRecord(storyDistributionList),
});
if (isNewItem) {
postUploadUpdateFunctions.push(() => {
dataInterface.modifyStoryDistribution({
...storyDistributionList,
storageID,
storageVersion: version,
storageNeedsSync: false,
});
});
}
});
const unknownRecordsArray: ReadonlyArray<UnknownRecord> = (
window.storage.get('storage-service-unknown-records') || []
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
@ -785,6 +842,15 @@ async function mergeRecord(
storageVersion,
storageRecord.account
);
} else if (
itemType === ITEM_TYPE.STORY_DISTRIBUTION_LIST &&
storageRecord.storyDistributionList
) {
mergeResult = await mergeStoryDistributionListRecord(
storageID,
storageVersion,
storageRecord.storyDistributionList
);
} else {
isUnsupported = true;
log.warn(

View File

@ -4,7 +4,7 @@
import { isEqual, isNumber } from 'lodash';
import Long from 'long';
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
import { bytesToUuid, deriveMasterKeyFromGroupV1 } from '../Crypto';
import * as Bytes from '../Bytes';
import {
deriveGroupFields,
@ -39,6 +39,10 @@ import { isValidUuid, UUID, UUIDKind } from '../types/UUID';
import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji';
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import type { UUIDStringType } from '../types/UUID';
import { MY_STORIES_ID } from '../types/Stories';
import type { StoryDistributionWithMembersType } from '../sql/Interface';
import dataInterface from '../sql/Client';
type RecordClass =
| Proto.IAccountRecord
@ -374,6 +378,35 @@ export function toGroupV2Record(
return groupV2Record;
}
export function toStoryDistributionListRecord(
storyDistributionList: StoryDistributionWithMembersType
): Proto.StoryDistributionListRecord {
const storyDistributionListRecord = new Proto.StoryDistributionListRecord();
storyDistributionListRecord.identifier = Bytes.fromBinary(
storyDistributionList.id
);
storyDistributionListRecord.name = storyDistributionList.name;
storyDistributionListRecord.deletedAtTimestamp = getSafeLongFromTimestamp(
storyDistributionList.deletedAtTimestamp
);
storyDistributionListRecord.allowsReplies = Boolean(
storyDistributionList.allowsReplies
);
storyDistributionListRecord.isBlockList = Boolean(
storyDistributionList.isBlockList
);
storyDistributionListRecord.recipientUuids = storyDistributionList.members;
if (storyDistributionList.storageUnknownFields) {
storyDistributionListRecord.__unknownFields = [
storyDistributionList.storageUnknownFields,
];
}
return storyDistributionListRecord;
}
type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record;
function applyMessageRequestState(
@ -1187,3 +1220,127 @@ export async function mergeAccountRecord(
details,
};
}
export async function mergeStoryDistributionListRecord(
storageID: string,
storageVersion: number,
storyDistributionListRecord: Proto.IStoryDistributionListRecord
): Promise<MergeResultType> {
if (!storyDistributionListRecord.identifier) {
throw new Error(`No storyDistributionList identifier for ${storageID}`);
}
const details: Array<string> = [];
const listId =
storyDistributionListRecord.name === MY_STORIES_ID
? MY_STORIES_ID
: bytesToUuid(storyDistributionListRecord.identifier);
if (!listId) {
throw new Error('Could not parse distribution list id');
}
const localStoryDistributionList =
await dataInterface.getStoryDistributionWithMembers(listId);
const remoteListMembers: Array<UUIDStringType> = (
storyDistributionListRecord.recipientUuids || []
).map(UUID.fromString);
if (storyDistributionListRecord.__unknownFields) {
details.push('adding unknown fields');
}
const storyDistribution: StoryDistributionWithMembersType = {
id: listId,
name: String(storyDistributionListRecord.name),
deletedAtTimestamp: getTimestampFromLong(
storyDistributionListRecord.deletedAtTimestamp
),
allowsReplies: Boolean(storyDistributionListRecord.allowsReplies),
isBlockList: Boolean(storyDistributionListRecord.isBlockList),
members: remoteListMembers,
senderKeyInfo: localStoryDistributionList?.senderKeyInfo,
storageID,
storageVersion,
storageUnknownFields: storyDistributionListRecord.__unknownFields
? Bytes.concatenate(storyDistributionListRecord.__unknownFields)
: null,
storageNeedsSync: Boolean(localStoryDistributionList?.storageNeedsSync),
};
if (!localStoryDistributionList) {
await dataInterface.createNewStoryDistribution(storyDistribution);
window.reduxActions.storyDistributionLists.createDistributionList({
allowsReplies: Boolean(storyDistribution.allowsReplies),
id: storyDistribution.id,
isBlockList: Boolean(storyDistribution.isBlockList),
name: storyDistribution.name,
});
return {
details,
hasConflict: false,
};
}
const oldStorageID = localStoryDistributionList.storageID;
const oldStorageVersion = localStoryDistributionList.storageVersion;
const needsToClearUnknownFields =
!storyDistributionListRecord.__unknownFields &&
localStoryDistributionList.storageUnknownFields;
if (needsToClearUnknownFields) {
details.push('clearing unknown fields');
}
const { hasConflict, details: conflictDetails } = doRecordsConflict(
toStoryDistributionListRecord(storyDistribution),
storyDistributionListRecord
);
const needsUpdate = needsToClearUnknownFields || hasConflict;
const localMembersListSet = new Set(localStoryDistributionList.members);
const toAdd: Array<UUIDStringType> = remoteListMembers.filter(
uuid => !localMembersListSet.has(uuid)
);
const remoteMemberListSet = new Set(remoteListMembers);
const toRemove: Array<UUIDStringType> =
localStoryDistributionList.members.filter(
uuid => !remoteMemberListSet.has(uuid)
);
if (!needsUpdate) {
return {
details: [...details, ...conflictDetails],
hasConflict,
oldStorageID,
oldStorageVersion,
};
}
if (needsUpdate) {
await dataInterface.modifyStoryDistributionWithMembers(storyDistribution, {
toAdd,
toRemove,
});
window.reduxActions.storyDistributionLists.modifyDistributionList({
allowsReplies: Boolean(storyDistribution.allowsReplies),
id: storyDistribution.id,
isBlockList: Boolean(storyDistribution.isBlockList),
name: storyDistribution.name,
});
}
return {
details: [...details, ...conflictDetails],
hasConflict,
oldStorageID,
oldStorageVersion,
};
}

View File

@ -21,23 +21,25 @@ export async function loadStories(): Promise<void> {
export function getStoryDataFromMessageAttributes(
message: MessageAttributesType
): StoryDataType | undefined {
const { attachments } = message;
const { attachments, deletedForEveryone } = message;
const unresolvedAttachment = attachments ? attachments[0] : undefined;
if (!unresolvedAttachment) {
if (!unresolvedAttachment && !deletedForEveryone) {
log.warn(
`getStoryDataFromMessageAttributes: ${message.id} does not have an attachment`
);
return;
}
const [attachment] = unresolvedAttachment.path
? getAttachmentsForMessage(message)
: [unresolvedAttachment];
const [attachment] =
unresolvedAttachment && unresolvedAttachment.path
? getAttachmentsForMessage(message)
: [unresolvedAttachment];
return {
attachment,
messageId: message.id,
...pick(message, [
'canReplyToStory',
'conversationId',
'deletedForEveryone',
'reactions',
@ -45,6 +47,7 @@ export function getStoryDataFromMessageAttributes(
'sendStateByConversationId',
'source',
'sourceUuid',
'storyDistributionListId',
'timestamp',
'type',
]),

View File

@ -28,7 +28,6 @@ import { SystemTraySettingsCheckboxes } from './components/conversation/SystemTr
import { createChatColorPicker } from './state/roots/createChatColorPicker';
import { createConversationDetails } from './state/roots/createConversationDetails';
import { createApp } from './state/roots/createApp';
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
@ -417,7 +416,6 @@ export const setup = (options: {
createApp,
createChatColorPicker,
createConversationDetails,
createForwardMessageModal,
createGroupLinkManagement,
createGroupV1MigrationModal,
createGroupV2JoinModal,

View File

@ -293,6 +293,7 @@ const dataInterface: ClientInterface = {
getStoryDistributionWithMembers,
modifyStoryDistribution,
modifyStoryDistributionMembers,
modifyStoryDistributionWithMembers,
deleteStoryDistribution,
_getAllStoryReads,
@ -1634,6 +1635,15 @@ async function modifyStoryDistributionMembers(
): Promise<void> {
await channels.modifyStoryDistributionMembers(id, options);
}
async function modifyStoryDistributionWithMembers(
distribution: StoryDistributionType,
options: {
toAdd: Array<UUIDStringType>;
toRemove: Array<UUIDStringType>;
}
): Promise<void> {
await channels.modifyStoryDistributionWithMembers(distribution, options);
}
async function deleteStoryDistribution(id: UUIDStringType): Promise<void> {
await channels.deleteStoryDistribution(id);
}

View File

@ -233,10 +233,15 @@ export type DeleteSentProtoRecipientOptionsType = Readonly<{
export type StoryDistributionType = Readonly<{
id: UUIDStringType;
name: string;
avatarUrlPath: string;
avatarKey: Uint8Array;
deletedAtTimestamp?: number;
allowsReplies: boolean;
isBlockList: boolean;
senderKeyInfo: SenderKeyInfoType | undefined;
storageID: string;
storageVersion: number;
storageUnknownFields?: Uint8Array | null;
storageNeedsSync: boolean;
}>;
export type StoryDistributionMemberType = Readonly<{
listId: UUIDStringType;
@ -559,7 +564,14 @@ export type DataInterface = {
): Promise<StoryDistributionWithMembersType | undefined>;
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
modifyStoryDistributionMembers(
id: string,
listId: string,
options: {
toAdd: Array<UUIDStringType>;
toRemove: Array<UUIDStringType>;
}
): Promise<void>;
modifyStoryDistributionWithMembers(
distribution: StoryDistributionType,
options: {
toAdd: Array<UUIDStringType>;
toRemove: Array<UUIDStringType>;

View File

@ -287,6 +287,7 @@ const dataInterface: ServerInterface = {
getStoryDistributionWithMembers,
modifyStoryDistribution,
modifyStoryDistributionMembers,
modifyStoryDistributionWithMembers,
deleteStoryDistribution,
_getAllStoryReads,
@ -4026,8 +4027,19 @@ async function getAllBadgeImageFileLocalPaths(): Promise<Set<string>> {
type StoryDistributionForDatabase = Readonly<
{
allowsReplies: 0 | 1;
deletedAtTimestamp: number | null;
isBlockList: 0 | 1;
senderKeyInfoJson: string | null;
} & Omit<StoryDistributionType, 'senderKeyInfo'>
storageNeedsSync: 0 | 1;
} & Omit<
StoryDistributionType,
| 'allowsReplies'
| 'deletedAtTimestamp'
| 'isBlockList'
| 'senderKeyInfo'
| 'storageNeedsSync'
>
>;
function hydrateStoryDistribution(
@ -4035,9 +4047,14 @@ function hydrateStoryDistribution(
): StoryDistributionType {
return {
...omit(fromDatabase, 'senderKeyInfoJson'),
allowsReplies: Boolean(fromDatabase.allowsReplies),
deletedAtTimestamp: fromDatabase.deletedAtTimestamp || undefined,
isBlockList: Boolean(fromDatabase.isBlockList),
senderKeyInfo: fromDatabase.senderKeyInfoJson
? JSON.parse(fromDatabase.senderKeyInfoJson)
: undefined,
storageNeedsSync: Boolean(fromDatabase.storageNeedsSync),
storageUnknownFields: fromDatabase.storageUnknownFields || undefined,
};
}
function freezeStoryDistribution(
@ -4045,9 +4062,14 @@ function freezeStoryDistribution(
): StoryDistributionForDatabase {
return {
...omit(story, 'senderKeyInfo'),
allowsReplies: story.allowsReplies ? 1 : 0,
deletedAtTimestamp: story.deletedAtTimestamp || null,
isBlockList: story.isBlockList ? 1 : 0,
senderKeyInfoJson: story.senderKeyInfo
? JSON.stringify(story.senderKeyInfo)
: null,
storageNeedsSync: story.storageNeedsSync ? 1 : 0,
storageUnknownFields: story.storageUnknownFields || null,
};
}
@ -4087,15 +4109,25 @@ async function createNewStoryDistribution(
INSERT INTO storyDistributions(
id,
name,
avatarUrlPath,
avatarKey,
senderKeyInfoJson
deletedAtTimestamp,
allowsReplies,
isBlockList,
senderKeyInfoJson,
storageID,
storageVersion,
storageUnknownFields,
storageNeedsSync
) VALUES (
$id,
$name,
$avatarUrlPath,
$avatarKey,
$senderKeyInfoJson
$deletedAtTimestamp,
$allowsReplies,
$isBlockList,
$senderKeyInfoJson,
$storageID,
$storageVersion,
$storageUnknownFields,
$storageNeedsSync
);
`
).run(payload);
@ -4163,24 +4195,91 @@ async function getStoryDistributionWithMembers(
members: members.map(({ uuid }) => uuid),
};
}
async function modifyStoryDistribution(
distribution: StoryDistributionType
): Promise<void> {
const payload = freezeStoryDistribution(distribution);
const db = getInstance();
function modifyStoryDistributionSync(
db: Database,
payload: StoryDistributionForDatabase
): void {
prepare(
db,
`
UPDATE storyDistributions
SET
name = $name,
avatarUrlPath = $avatarUrlPath,
avatarKey = $avatarKey,
senderKeyInfoJson = $senderKeyInfoJson
deletedAtTimestamp = $deletedAtTimestamp,
allowsReplies = $allowsReplies,
isBlockList = $isBlockList,
senderKeyInfoJson = $senderKeyInfoJson,
storageID = $storageID,
storageVersion = $storageVersion,
storageUnknownFields = $storageUnknownFields,
storageNeedsSync = $storageNeedsSync
WHERE id = $id
`
).run(payload);
}
function modifyStoryDistributionMembersSync(
db: Database,
listId: string,
{
toAdd,
toRemove,
}: { toAdd: Array<UUIDStringType>; toRemove: Array<UUIDStringType> }
) {
const memberInsertStatement = prepare(
db,
`
INSERT OR REPLACE INTO storyDistributionMembers (
listId,
uuid
) VALUES (
$listId,
$uuid
);
`
);
for (const uuid of toAdd) {
memberInsertStatement.run({
listId,
uuid,
});
}
batchMultiVarQuery(db, toRemove, (uuids: Array<UUIDStringType>) => {
db.prepare<ArrayQuery>(
`
DELETE FROM storyDistributionMembers
WHERE listId = ? AND uuid IN ( ${uuids.map(() => '?').join(', ')} );
`
).run([listId, ...uuids]);
});
}
async function modifyStoryDistributionWithMembers(
distribution: StoryDistributionType,
{
toAdd,
toRemove,
}: { toAdd: Array<UUIDStringType>; toRemove: Array<UUIDStringType> }
): Promise<void> {
const payload = freezeStoryDistribution(distribution);
const db = getInstance();
if (toAdd.length || toRemove.length) {
db.transaction(() => {
modifyStoryDistributionSync(db, payload);
modifyStoryDistributionMembersSync(db, payload.id, { toAdd, toRemove });
})();
} else {
modifyStoryDistributionSync(db, payload);
}
}
async function modifyStoryDistribution(
distribution: StoryDistributionType
): Promise<void> {
const payload = freezeStoryDistribution(distribution);
const db = getInstance();
modifyStoryDistributionSync(db, payload);
}
async function modifyStoryDistributionMembers(
listId: string,
{
@ -4191,34 +4290,7 @@ async function modifyStoryDistributionMembers(
const db = getInstance();
db.transaction(() => {
const memberInsertStatement = prepare(
db,
`
INSERT OR REPLACE INTO storyDistributionMembers (
listId,
uuid
) VALUES (
$listId,
$uuid
);
`
);
for (const uuid of toAdd) {
memberInsertStatement.run({
listId,
uuid,
});
}
batchMultiVarQuery(db, toRemove, (uuids: Array<UUIDStringType>) => {
db.prepare<ArrayQuery>(
`
DELETE FROM storyDistributionMembers
WHERE listId = ? AND uuid IN ( ${uuids.map(() => '?').join(', ')} );
`
).run([listId, ...uuids]);
});
modifyStoryDistributionMembersSync(db, listId, { toAdd, toRemove });
})();
}
async function deleteStoryDistribution(id: UUIDStringType): Promise<void> {

View File

@ -0,0 +1,44 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import type { LoggerType } from '../../types/Logging';
export default function updateToSchemaVersion61(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 61) {
return;
}
db.transaction(() => {
db.exec(
`
ALTER TABLE storyDistributions DROP COLUMN avatarKey;
ALTER TABLE storyDistributions DROP COLUMN avatarUrlPath;
ALTER TABLE storyDistributions ADD COLUMN deletedAtTimestamp INTEGER;
ALTER TABLE storyDistributions ADD COLUMN allowsReplies INTEGER;
ALTER TABLE storyDistributions ADD COLUMN isBlockList INTEGER;
ALTER TABLE storyDistributions ADD COLUMN storageID STRING;
ALTER TABLE storyDistributions ADD COLUMN storageVersion INTEGER;
ALTER TABLE storyDistributions ADD COLUMN storageUnknownFields BLOB;
ALTER TABLE storyDistributions ADD COLUMN storageNeedsSync INTEGER;
ALTER TABLE messages ADD COLUMN storyDistributionListId STRING;
CREATE INDEX messages_by_distribution_list
ON messages(storyDistributionListId, received_at)
WHERE storyDistributionListId IS NOT NULL;
`
);
db.pragma('user_version = 61');
})();
logger.info('updateToSchemaVersion61: success!');
}

View File

@ -36,6 +36,7 @@ import updateToSchemaVersion57 from './57-rm-message-history-unsynced';
import updateToSchemaVersion58 from './58-update-unread';
import updateToSchemaVersion59 from './59-unprocessed-received-at-counter-index';
import updateToSchemaVersion60 from './60-update-expiring-index';
import updateToSchemaVersion61 from './61-distribution-list-storage';
function updateToSchemaVersion1(
currentVersion: number,
@ -1935,6 +1936,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion58,
updateToSchemaVersion59,
updateToSchemaVersion60,
updateToSchemaVersion61,
];
export function updateSchema(db: Database, logger: LoggerType): void {

View File

@ -20,6 +20,7 @@ import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
import { actions as stickers } from './ducks/stickers';
import { actions as stories } from './ducks/stories';
import { actions as storyDistributionLists } from './ducks/storyDistributionLists';
import { actions as updates } from './ducks/updates';
import { actions as user } from './ducks/user';
import type { ReduxActions } from './types';
@ -44,6 +45,7 @@ export const actionCreators: ReduxActions = {
search,
stickers,
stories,
storyDistributionLists,
updates,
user,
};
@ -68,6 +70,7 @@ export const mapDispatchToProps = {
...search,
...stickers,
...stories,
...storyDistributionLists,
...updates,
...user,
};

View File

@ -1,10 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { StateType as RootStateType } from '../reducer';
import type { PropsForMessage } from '../selectors/message';
import { getMessageById } from '../../messages/getMessageById';
import { getMessagePropsSelector } from '../selectors/message';
import { useBoundActions } from '../../hooks/useBoundActions';
// State
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
export type GlobalModalsStateType = {
readonly contactModalState?: ContactModalStateType;
readonly forwardMessageProps?: ForwardMessagePropsType;
readonly isProfileEditorVisible: boolean;
readonly isWhatsNewVisible: boolean;
readonly profileEditorHasError: boolean;
@ -16,10 +26,12 @@ export type GlobalModalsStateType = {
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL';
const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_NOT_FOUND_MODAL';
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL';
const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_NOT_FOUND_MODAL';
const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
const TOGGLE_FORWARD_MESSAGE_MODAL =
'globalModals/TOGGLE_FORWARD_MESSAGE_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
@ -66,6 +78,11 @@ export type ShowUserNotFoundModalActionType = {
payload: UserNotFoundModalStateType;
};
type ToggleForwardMessageModalActionType = {
type: typeof TOGGLE_FORWARD_MESSAGE_MODAL;
payload: ForwardMessagePropsType | undefined;
};
type ToggleProfileEditorActionType = {
type: typeof TOGGLE_PROFILE_EDITOR;
};
@ -86,6 +103,7 @@ export type GlobalModalsActionType =
| ShowWhatsNewModalActionType
| HideUserNotFoundModalActionType
| ShowUserNotFoundModalActionType
| ToggleForwardMessageModalActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType;
@ -99,11 +117,15 @@ export const actions = {
showWhatsNewModal,
hideUserNotFoundModal,
showUserNotFoundModal,
toggleForwardMessageModal,
toggleProfileEditor,
toggleProfileEditorHasError,
toggleSafetyNumberModal,
};
export const useGlobalModalActions = (): typeof actions =>
useBoundActions(actions);
function hideContactModal(): HideContactModalActionType {
return {
type: HIDE_CONTACT_MODAL,
@ -150,6 +172,41 @@ function showUserNotFoundModal(
};
}
function toggleForwardMessageModal(
messageId?: string
): ThunkAction<
void,
RootStateType,
unknown,
ToggleForwardMessageModalActionType
> {
return async (dispatch, getState) => {
if (!messageId) {
dispatch({
type: TOGGLE_FORWARD_MESSAGE_MODAL,
payload: undefined,
});
return;
}
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
`toggleForwardMessageModal: no message found for ${messageId}`
);
}
const messagePropsSelector = getMessagePropsSelector(getState());
const messageProps = messagePropsSelector(message.attributes);
dispatch({
type: TOGGLE_FORWARD_MESSAGE_MODAL,
payload: messageProps,
});
};
}
function toggleProfileEditor(): ToggleProfileEditorActionType {
return { type: TOGGLE_PROFILE_EDITOR };
}
@ -246,5 +303,12 @@ export function reducer(
};
}
if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) {
return {
...state,
forwardMessageProps: action.payload,
};
}
return state;
}

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import { pick } from 'lodash';
import { isEqual, pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util';
import type { MessageAttributesType } from '../../model-types.d';
@ -12,10 +12,11 @@ import type {
} from './conversations';
import type { NoopActionType } from './noop';
import type { StateType as RootStateType } from '../reducer';
import type { StoryViewType } from '../../components/StoryListItem';
import type { StoryViewType } from '../../types/Stories';
import type { SyncType } from '../../jobs/helpers/syncHelpers';
import * as log from '../../logging/log';
import dataInterface from '../../sql/Client';
import { DAY } from '../../util/durations';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { UUID } from '../../types/UUID';
@ -24,6 +25,7 @@ import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { replaceIndex } from '../../util/replaceIndex';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import { showToast } from '../../util/showToast';
import {
hasNotResolved,
@ -41,6 +43,7 @@ export type StoryDataType = {
messageId: string;
} & Pick<
MessageAttributesType,
| 'canReplyToStory'
| 'conversationId'
| 'deletedForEveryone'
| 'reactions'
@ -48,6 +51,7 @@ export type StoryDataType = {
| 'sendStateByConversationId'
| 'source'
| 'sourceUuid'
| 'storyDistributionListId'
| 'timestamp'
| 'type'
>;
@ -65,6 +69,7 @@ export type StoriesStateType = {
// Actions
const DOE_STORY = 'stories/DOE';
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
@ -72,6 +77,11 @@ export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL';
const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
type DOEStoryActionType = {
type: typeof DOE_STORY;
payload: string;
};
type LoadStoryRepliesActionType = {
type: typeof LOAD_STORY_REPLIES;
payload: {
@ -108,6 +118,7 @@ type ToggleViewActionType = {
};
export type StoriesActionType =
| DOEStoryActionType
| LoadStoryRepliesActionType
| MarkStoryReadActionType
| MessageChangedActionType
@ -120,6 +131,7 @@ export type StoriesActionType =
// Action Creators
export const actions = {
deleteStoryForEveryone,
loadStoryReplies,
markStoryRead,
queueStoryDownload,
@ -131,6 +143,56 @@ export const actions = {
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
function deleteStoryForEveryone(
story: StoryViewType
): ThunkAction<void, RootStateType, unknown, DOEStoryActionType> {
return (dispatch, getState) => {
if (!story.sendState) {
return;
}
const conversationIds = new Set(
story.sendState.map(({ recipient }) => recipient.id)
);
// Find stories that were sent to other distribution lists so that we don't
// send a DOE request to the members of those lists.
const { stories } = getState().stories;
stories.forEach(item => {
if (item.timestamp !== story.timestamp) {
return;
}
if (!item.sendStateByConversationId) {
return;
}
Object.keys(item.sendStateByConversationId).forEach(conversationId => {
conversationIds.delete(conversationId);
});
});
conversationIds.forEach(cid => {
const conversation = window.ConversationController.get(cid);
if (!conversation) {
return;
}
sendDeleteForEveryoneMessage(conversation.attributes, {
deleteForEveryoneDuration: DAY,
id: story.messageId,
timestamp: story.timestamp,
});
});
dispatch({
type: DOE_STORY,
payload: story.messageId,
});
};
}
function loadStoryReplies(
conversationId: string,
messageId: string
@ -200,7 +262,7 @@ function markStoryRead(
await dataInterface.addNewStoryRead({
authorId: message.attributes.sourceUuid,
conversationId: message.attributes.conversationId,
storyId: new UUID(messageId).toString(),
storyId: UUID.fromString(messageId),
storyReadDate,
});
@ -219,16 +281,15 @@ function queueStoryDownload(
unknown,
NoopActionType | ResolveAttachmentUrlActionType
> {
return async dispatch => {
const story = await getMessageById(storyId);
return async (dispatch, getState) => {
const { stories } = getState().stories;
const story = stories.find(item => item.messageId === storyId);
if (!story) {
return;
}
const storyAttributes: MessageAttributesType = story.attributes;
const { attachments } = storyAttributes;
const attachment = attachments && attachments[0];
const { attachment } = story;
if (!attachment) {
log.warn('queueStoryDownload: No attachment found for story', {
@ -264,11 +325,15 @@ function queueStoryDownload(
return;
}
// We want to ensure that we re-hydrate the story reply context with the
// completed attachment download.
story.set({ storyReplyContext: undefined });
const message = await getMessageById(storyId);
await queueAttachmentDownloads(story.attributes);
if (message) {
// We want to ensure that we re-hydrate the story reply context with the
// completed attachment download.
message.set({ storyReplyContext: undefined });
await queueAttachmentDownloads(message.attributes);
}
dispatch({
type: 'NOOP',
@ -390,6 +455,7 @@ export function reducer(
if (action.type === STORY_CHANGED) {
const newStory = pick(action.payload, [
'attachment',
'canReplyToStory',
'conversationId',
'deletedForEveryone',
'messageId',
@ -398,6 +464,7 @@ export function reducer(
'sendStateByConversationId',
'source',
'sourceUuid',
'storyDistributionListId',
'timestamp',
'type',
]);
@ -416,10 +483,18 @@ export function reducer(
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
const reactionsChanged =
prevStory.reactions?.length !== newStory.reactions?.length;
const hasBeenDeleted =
!prevStory.deletedForEveryone && newStory.deletedForEveryone;
const hasSendStateChanged = !isEqual(
prevStory.sendStateByConversationId,
newStory.sendStateByConversationId
);
const shouldReplace =
isDownloadingAttachment ||
hasAttachmentDownloaded ||
hasBeenDeleted ||
hasSendStateChanged ||
readStatusChanged ||
reactionsChanged;
if (!shouldReplace) {
@ -552,5 +627,23 @@ export function reducer(
};
}
if (action.type === DOE_STORY) {
const prevStoryIndex = state.stories.findIndex(
existingStory => existingStory.messageId === action.payload
);
if (prevStoryIndex < 0) {
return state;
}
return {
...state,
stories: replaceIndex(state.stories, prevStoryIndex, {
...state.stories[prevStoryIndex],
deletedForEveryone: true,
}),
};
}
return state;
}

View File

@ -0,0 +1,102 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUIDStringType } from '../../types/UUID';
import { useBoundActions } from '../../hooks/useBoundActions';
// State
export type StoryDistributionListDataType = {
id: UUIDStringType;
name: string;
allowsReplies: boolean;
isBlockList: boolean;
};
export type StoryDistributionListStateType = {
distributionLists: Array<StoryDistributionListDataType>;
};
// Actions
export const CREATE_LIST = 'storyDistributionLists/CREATE_LIST';
export const MODIFY_LIST = 'storyDistributionLists/MODIFY_LIST';
type CreateListActionType = {
type: typeof CREATE_LIST;
payload: StoryDistributionListDataType;
};
export type ModifyListActionType = {
type: typeof MODIFY_LIST;
payload: StoryDistributionListDataType;
};
type StoryDistributionListsActionType =
| CreateListActionType
| ModifyListActionType;
// Action Creators
function createDistributionList(
distributionList: StoryDistributionListDataType
): CreateListActionType {
return {
type: CREATE_LIST,
payload: distributionList,
};
}
function modifyDistributionList(
distributionList: StoryDistributionListDataType
): ModifyListActionType {
return {
type: MODIFY_LIST,
payload: distributionList,
};
}
export const actions = {
createDistributionList,
modifyDistributionList,
};
export const useStoryDistributionListsActions = (): typeof actions =>
useBoundActions(actions);
// Reducer
export function getEmptyState(): StoryDistributionListStateType {
return {
distributionLists: [],
};
}
export function reducer(
state: Readonly<StoryDistributionListStateType> = getEmptyState(),
action: Readonly<StoryDistributionListsActionType>
): StoryDistributionListStateType {
if (action.type === MODIFY_LIST) {
const { payload } = action;
const distributionLists = [...state.distributionLists];
const existingList = distributionLists.find(list => list.id === payload.id);
if (existingList) {
Object.assign(existingList, payload);
} else {
distributionLists.concat(payload);
}
return {
distributionLists: [...distributionLists],
};
}
if (action.type === CREATE_LIST) {
return {
distributionLists: [...state.distributionLists, action.payload],
};
}
return state;
}

View File

@ -17,6 +17,7 @@ import { getEmptyState as preferredReactions } from './ducks/preferredReactions'
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
import { getEmptyState as search } from './ducks/search';
import { getEmptyState as getStoriesEmptyState } from './ducks/stories';
import { getEmptyState as getStoryDistributionListsEmptyState } from './ducks/storyDistributionLists';
import { getEmptyState as updates } from './ducks/updates';
import { getEmptyState as user } from './ducks/user';
@ -24,6 +25,7 @@ import type { StateType } from './reducer';
import type { BadgesStateType } from './ducks/badges';
import type { StoryDataType } from './ducks/stories';
import type { StoryDistributionListDataType } from './ducks/storyDistributionLists';
import { getInitialState as stickers } from '../types/Stickers';
import type { MenuOptionsType } from '../types/menu';
import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis';
@ -32,11 +34,13 @@ import type { MainWindowStatsType } from '../windows/context';
export function getInitialState({
badges,
stories,
storyDistributionLists,
mainWindowStats,
menuOptions,
}: {
badges: BadgesStateType;
stories: Array<StoryDataType>;
storyDistributionLists: Array<StoryDistributionListDataType>;
mainWindowStats: MainWindowStatsType;
menuOptions: MenuOptionsType;
}): StateType {
@ -101,6 +105,10 @@ export function getInitialState({
...getStoriesEmptyState(),
stories,
},
storyDistributionLists: {
...getStoryDistributionListsEmptyState(),
distributionLists: storyDistributionLists || [],
},
updates: updates(),
user: {
...user(),

View File

@ -23,6 +23,7 @@ import { reducer as safetyNumber } from './ducks/safetyNumber';
import { reducer as search } from './ducks/search';
import { reducer as stickers } from './ducks/stickers';
import { reducer as stories } from './ducks/stories';
import { reducer as storyDistributionLists } from './ducks/storyDistributionLists';
import { reducer as updates } from './ducks/updates';
import { reducer as user } from './ducks/user';
@ -47,6 +48,7 @@ export const reducer = combineReducers({
search,
stickers,
stories,
storyDistributionLists,
updates,
user,
});

View File

@ -1,19 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import type { Store } from 'redux';
import type { SmartForwardMessageModalProps } from '../smart/ForwardMessageModal';
import { SmartForwardMessageModal } from '../smart/ForwardMessageModal';
export const createForwardMessageModal = (
store: Store,
props: SmartForwardMessageModalProps
): React.ReactElement => (
<Provider store={store}>
<SmartForwardMessageModal {...props} />
</Provider>
);

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { identity, isEqual, isNumber, isObject, map, omit, pick } from 'lodash';
import { createSelectorCreator } from 'reselect';
import { createSelector, createSelectorCreator } from 'reselect';
import filesize from 'filesize';
import getDirection from 'direction';
@ -50,6 +50,20 @@ import { isMoreRecentThan } from '../../util/timestamp';
import * as iterables from '../../util/iterables';
import { strictAssert } from '../../util/assert';
import { getAccountSelector } from './accounts';
import {
getContactNameColorSelector,
getConversationSelector,
getSelectedMessage,
isMissingRequiredProfileSharing,
} from './conversations';
import {
getRegionCode,
getUserConversationId,
getUserNumber,
getUserUuid,
} from './user';
import type {
ConversationType,
MessageWithUIFieldsType,
@ -61,7 +75,6 @@ import type {
GetConversationByIdType,
ContactNameColorSelectorType,
} from './conversations';
import { isMissingRequiredProfileSharing } from './conversations';
import {
SendStatus,
isDelivered,
@ -91,7 +104,7 @@ type FormattedContact = Partial<ConversationType> &
| 'type'
| 'unblurredAvatarPath'
>;
type PropsForMessage = Omit<PropsData, 'interactionMode'>;
export type PropsForMessage = Omit<PropsData, 'interactionMode'>;
type PropsForUnsupportedMessage = {
canProcessNow: boolean;
contact: FormattedContact;
@ -761,6 +774,44 @@ export const getPropsForMessage: (
}
);
// This is getPropsForMessage but wrapped in reselect's createSelector so that
// we can derive all of the selector dependencies that getPropsForMessage
// requires and you won't have to pass them in. For use within a smart/connected
// component that has access to selectors.
export const getMessagePropsSelector = createSelector(
getConversationSelector,
getUserConversationId,
getUserUuid,
getUserNumber,
getRegionCode,
getAccountSelector,
getContactNameColorSelector,
getSelectedMessage,
(
conversationSelector,
ourConversationId,
ourUuid,
ourNumber,
regionCode,
accountSelector,
contactNameColorSelector,
selectedMessage
) =>
(message: MessageWithUIFieldsType) => {
return getPropsForMessage(message, {
accountSelector,
contactNameColorSelector,
conversationSelector,
ourConversationId,
ourNumber,
ourUuid,
regionCode,
selectedMessageCounter: selectedMessage?.counter,
selectedMessageId: selectedMessage?.id,
});
}
);
export const getBubblePropsForMessage = createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
@ -1535,7 +1586,10 @@ function processQuoteAttachment(
function canReplyOrReact(
message: Pick<
MessageWithUIFieldsType,
'deletedForEveryone' | 'sendStateByConversationId' | 'type'
| 'canReplyToStory'
| 'deletedForEveryone'
| 'sendStateByConversationId'
| 'type'
>,
ourConversationId: string | undefined,
conversation: undefined | Readonly<ConversationType>
@ -1576,10 +1630,14 @@ function canReplyOrReact(
// If we get past all the other checks above then we can always reply or
// react if the message type is "incoming" | "story"
if (isIncoming(message) || isStory(message)) {
if (isIncoming(message)) {
return true;
}
if (isStory(message)) {
return Boolean(message.canReplyToStory);
}
// Fail safe.
return false;
}
@ -1587,6 +1645,7 @@ function canReplyOrReact(
export function canReply(
message: Pick<
MessageWithUIFieldsType,
| 'canReplyToStory'
| 'conversationId'
| 'deletedForEveryone'
| 'sendStateByConversationId'

View File

@ -6,21 +6,25 @@ import { pick } from 'lodash';
import type { GetConversationByIdType } from './conversations';
import type { ConversationType } from '../ducks/conversations';
import type { MessageReactionType } from '../../model-types.d';
import type {
ConversationStoryType,
MyStoryType,
ReplyStateType,
StorySendStateType,
StoryViewType,
} from '../../components/StoryListItem';
import type { MessageReactionType } from '../../model-types.d';
import type { ReplyStateType } from '../../types/Stories';
} from '../../types/Stories';
import type { StateType } from '../reducer';
import type { StoryDataType, StoriesStateType } from '../ducks/stories';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SendStatus } from '../../messages/MessageSendState';
import { canReply } from './message';
import {
getContactNameColorSelector,
getConversationSelector,
getMe,
} from './conversations';
import { getDistributionListSelector } from './storyDistributionLists';
import { getUserConversationId } from './user';
export const getStoriesState = (state: StateType): StoriesStateType =>
@ -31,13 +35,13 @@ export const shouldShowStoriesView = createSelector(
({ isShowingStoriesView }): boolean => isShowingStoriesView
);
function getNewestStory(x: ConversationStoryType): StoryViewType {
function getNewestStory(x: ConversationStoryType | MyStoryType): StoryViewType {
return x.stories[x.stories.length - 1];
}
function sortByRecencyAndUnread(
a: ConversationStoryType,
b: ConversationStoryType
a: ConversationStoryType | MyStoryType,
b: ConversationStoryType | MyStoryType
): number {
const storyA = getNewestStory(a);
const storyB = getNewestStory(b);
@ -86,11 +90,11 @@ function getAvatarData(
]);
}
function getConversationStory(
function getStoryView(
conversationSelector: GetConversationByIdType,
story: StoryDataType,
ourConversationId?: string
): ConversationStoryType {
): StoryViewType {
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'acceptedMessageRequest',
'avatarPath',
@ -105,6 +109,28 @@ function getConversationStory(
'title',
]);
const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']);
return {
attachment,
canReply: canReply(story, ourConversationId, conversationSelector),
isUnread: story.readStatus === ReadStatus.Unread,
messageId: story.messageId,
sender,
timestamp,
};
}
function getConversationStory(
conversationSelector: GetConversationByIdType,
story: StoryDataType,
ourConversationId?: string
): ConversationStoryType {
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'hideStory',
'id',
]);
const conversation = pick(conversationSelector(story.conversationId), [
'acceptedMessageRequest',
'avatarPath',
@ -116,16 +142,11 @@ function getConversationStory(
'title',
]);
const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']);
const storyView: StoryViewType = {
attachment,
canReply: canReply(story, ourConversationId, conversationSelector),
isUnread: story.readStatus === ReadStatus.Unread,
messageId: story.messageId,
sender,
timestamp,
};
const storyView = getStoryView(
conversationSelector,
story,
ourConversationId
);
return {
conversationId: conversation.id,
@ -239,29 +260,96 @@ export const getStoryReplies = createSelector(
export const getStories = createSelector(
getConversationSelector,
getUserConversationId,
getDistributionListSelector,
getStoriesState,
getUserConversationId,
shouldShowStoriesView,
(
conversationSelector,
ourConversationId,
distributionListSelector,
{ stories }: Readonly<StoriesStateType>,
ourConversationId,
isShowingStoriesView
): {
hiddenStories: Array<ConversationStoryType>;
myStories: Array<MyStoryType>;
stories: Array<ConversationStoryType>;
} => {
if (!isShowingStoriesView) {
return {
hiddenStories: [],
myStories: [],
stories: [],
};
}
const storiesById = new Map<string, ConversationStoryType>();
const hiddenStoriesById = new Map<string, ConversationStoryType>();
const myStoriesById = new Map<string, MyStoryType>();
const storiesById = new Map<string, ConversationStoryType>();
stories.forEach(story => {
if (story.deletedForEveryone) {
return;
}
if (story.sendStateByConversationId && story.storyDistributionListId) {
const list = distributionListSelector(story.storyDistributionListId);
if (!list) {
return;
}
const storyView = getStoryView(
conversationSelector,
story,
ourConversationId
);
const sendState: Array<StorySendStateType> = [];
const { sendStateByConversationId } = story;
let views = 0;
Object.keys(story.sendStateByConversationId).forEach(recipientId => {
const recipient = conversationSelector(recipientId);
const recipientSendState = sendStateByConversationId[recipient.id];
if (recipientSendState.status === SendStatus.Viewed) {
views += 1;
}
sendState.push({
...recipientSendState,
recipient: pick(recipient, [
'acceptedMessageRequest',
'avatarPath',
'color',
'id',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
]),
});
});
const existingMyStory = myStoriesById.get(list.id) || { stories: [] };
myStoriesById.set(list.id, {
distributionId: list.id,
distributionName: list.name,
stories: [
...existingMyStory.stories,
{
...storyView,
sendState,
views,
},
],
});
return;
}
const conversationStory = getConversationStory(
conversationSelector,
story,
@ -269,6 +357,7 @@ export const getStories = createSelector(
);
let storiesMap: Map<string, ConversationStoryType>;
if (conversationStory.isHidden) {
storiesMap = hiddenStoriesById;
} else {
@ -293,6 +382,9 @@ export const getStories = createSelector(
hiddenStories: Array.from(hiddenStoriesById.values()).sort(
sortByRecencyAndUnread
),
myStories: Array.from(myStoriesById.values()).sort(
sortByRecencyAndUnread
),
stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread),
};
}

View File

@ -0,0 +1,18 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type { StoryDistributionListDataType } from '../ducks/storyDistributionLists';
const getDistributionLists = (
state: StateType
): Array<StoryDistributionListDataType> =>
state.storyDistributionLists.distributionLists;
export const getDistributionListSelector = createSelector(
getDistributionLists,
distributionLists => (id: string) =>
distributionLists.find(list => list.id === id)
);

View File

@ -1,87 +1,124 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import type { AttachmentType } from '../../types/Attachment';
import React from 'react';
import { useSelector } from 'react-redux';
import type { BodyRangeType } from '../../types/Util';
import type { DataPropsType } from '../../components/ForwardMessageModal';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { ForwardMessagePropsType } from '../ducks/globalModals';
import type { StateType } from '../reducer';
import * as log from '../../logging/log';
import { ForwardMessageModal } from '../../components/ForwardMessageModal';
import { LinkPreviewSourceType } from '../../types/LinkPreview';
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
import { getAllComposableConversations } from '../selectors/conversations';
import { getEmojiSkinTone } from '../selectors/items';
import { getIntl, getTheme, getRegionCode } from '../selectors/user';
import { getLinkPreview } from '../selectors/linkPreviews';
import { getMessageById } from '../../messages/getMessageById';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { mapDispatchToProps } from '../actions';
import { maybeForwardMessage } from '../../util/maybeForwardMessage';
import {
maybeGrabLinkPreview,
resetLinkPreview,
} from '../../services/LinkPreview';
import { selectRecentEmojis } from '../selectors/emojis';
import { showToast } from '../../util/showToast';
import { useActions as useEmojiActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useLinkPreviewActions } from '../ducks/linkPreviews';
export type SmartForwardMessageModalProps = {
attachments?: Array<AttachmentType>;
doForwardMessage: (
selectedContacts: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => void;
hasContact: boolean;
isSticker: boolean;
messageBody?: string;
onClose: () => void;
onEditorStateChange: (
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => unknown;
onTextTooLong: () => void;
};
export function SmartForwardMessageModal(): JSX.Element | null {
const forwardMessageProps = useSelector<
StateType,
ForwardMessagePropsType | undefined
>(state => state.globalModals.forwardMessageProps);
const candidateConversations = useSelector(getAllComposableConversations);
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const i18n = useSelector(getIntl);
const linkPreviewForSource = useSelector(getLinkPreview);
const recentEmojis = useSelector(selectRecentEmojis);
const regionCode = useSelector(getRegionCode);
const skinTone = useSelector(getEmojiSkinTone);
const theme = useSelector(getTheme);
const mapStateToProps = (
state: StateType,
props: SmartForwardMessageModalProps
): DataPropsType => {
const {
attachments,
doForwardMessage,
hasContact,
isSticker,
messageBody,
onClose,
onEditorStateChange,
onTextTooLong,
} = props;
const { removeLinkPreview } = useLinkPreviewActions();
const { onUseEmoji: onPickEmoji } = useEmojiActions();
const { onSetSkinTone } = useItemsActions();
const { toggleForwardMessageModal } = useGlobalModalActions();
const candidateConversations = getAllComposableConversations(state);
const recentEmojis = selectRecentEmojis(state);
const skinTone = getEmojiSkinTone(state);
const linkPreviewForSource = getLinkPreview(state);
if (!forwardMessageProps) {
return null;
}
return {
attachments,
candidateConversations,
doForwardMessage,
getPreferredBadge: getPreferredBadgeSelector(state),
hasContact,
i18n: getIntl(state),
isSticker,
linkPreview: linkPreviewForSource(
LinkPreviewSourceType.ForwardMessageModal
),
messageBody,
onClose,
onEditorStateChange,
recentEmojis,
skinTone,
onTextTooLong,
theme: getTheme(state),
regionCode: getRegionCode(state),
};
};
const { attachments = [] } = forwardMessageProps;
const smart = connect(mapStateToProps, {
...mapDispatchToProps,
onPickEmoji: mapDispatchToProps.onUseEmoji,
});
function closeModal() {
resetLinkPreview();
toggleForwardMessageModal();
}
export const SmartForwardMessageModal = smart(ForwardMessageModal);
return (
<ForwardMessageModal
attachments={attachments}
candidateConversations={candidateConversations}
doForwardMessage={async (
conversationIds,
messageBody,
includedAttachments,
linkPreview
) => {
try {
const message = await getMessageById(forwardMessageProps.id);
if (!message) {
throw new Error('No message found');
}
const didForwardSuccessfully = await maybeForwardMessage(
message.attributes,
conversationIds,
messageBody,
includedAttachments,
linkPreview
);
if (didForwardSuccessfully) {
closeModal();
}
} catch (err) {
log.warn('doForwardMessage', err && err.stack ? err.stack : err);
}
}}
getPreferredBadge={getPreferredBadge}
hasContact={Boolean(forwardMessageProps.contact)}
i18n={i18n}
isSticker={Boolean(forwardMessageProps.isSticker)}
linkPreview={linkPreviewForSource(
LinkPreviewSourceType.ForwardMessageModal
)}
messageBody={forwardMessageProps.text}
onClose={closeModal}
onEditorStateChange={(
messageText: string,
_: Array<BodyRangeType>,
caretLocation?: number
) => {
if (!attachments.length) {
maybeGrabLinkPreview(
messageText,
LinkPreviewSourceType.ForwardMessageModal,
caretLocation
);
}
}}
onPickEmoji={onPickEmoji}
onSetSkinTone={onSetSkinTone}
onTextTooLong={() => showToast(ToastMessageBodyTooLong)}
recentEmojis={recentEmojis}
regionCode={regionCode}
removeLinkPreview={removeLinkPreview}
skinTone={skinTone}
theme={theme}
/>
);
}

View File

@ -6,8 +6,9 @@ import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
import type { StateType } from '../reducer';
import { SmartProfileEditorModal } from './ProfileEditorModal';
import { SmartContactModal } from './ContactModal';
import { SmartForwardMessageModal } from './ForwardMessageModal';
import { SmartProfileEditorModal } from './ProfileEditorModal';
import { SmartSafetyNumberModal } from './SafetyNumberModal';
import { getIntl } from '../selectors/user';
@ -20,6 +21,10 @@ function renderContactModal(): JSX.Element {
return <SmartContactModal />;
}
function renderForwardMessageModal(): JSX.Element {
return <SmartForwardMessageModal />;
}
const mapStateToProps = (state: StateType) => {
const i18n = getIntl(state);
@ -27,6 +32,7 @@ const mapStateToProps = (state: StateType) => {
...state.globalModals,
i18n,
renderContactModal,
renderForwardMessageModal,
renderProfileEditor,
renderSafetyNumber: () => (
<SmartSafetyNumberModal

View File

@ -11,11 +11,14 @@ import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
import { SmartStoryCreator } from './StoryCreator';
import { SmartStoryViewer } from './StoryViewer';
import { Stories } from '../../components/Stories';
import { getIntl } from '../selectors/user';
import { getMe } from '../selectors/conversations';
import { getIntl, getUserConversationId } from '../selectors/user';
import { getPreferredLeftPaneWidth } from '../selectors/items';
import { getStories } from '../selectors/stories';
import { useStoriesActions } from '../ducks/stories';
import { saveAttachment } from '../../util/saveAttachment';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoriesActions } from '../ducks/stories';
function renderStoryCreator({
onClose,
@ -28,6 +31,7 @@ function renderStoryViewer({
onClose,
onNextUserStories,
onPrevUserStories,
storyToView,
}: SmartStoryViewerPropsType): JSX.Element {
return (
<SmartStoryViewer
@ -35,6 +39,7 @@ function renderStoryViewer({
onClose={onClose}
onNextUserStories={onNextUserStories}
onPrevUserStories={onPrevUserStories}
storyToView={storyToView}
/>
);
}
@ -42,6 +47,7 @@ function renderStoryViewer({
export function SmartStories(): JSX.Element | null {
const storiesActions = useStoriesActions();
const { showConversation, toggleHideStories } = useConversationsActions();
const { toggleForwardMessageModal } = useGlobalModalActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
@ -53,7 +59,10 @@ export function SmartStories(): JSX.Element | null {
getPreferredLeftPaneWidth
);
const { hiddenStories, stories } = useSelector(getStories);
const { hiddenStories, myStories, stories } = useSelector(getStories);
const ourConversationId = useSelector(getUserConversationId);
const me = useSelector(getMe);
if (!isShowingStoriesView) {
return null;
@ -63,6 +72,17 @@ export function SmartStories(): JSX.Element | null {
<Stories
hiddenStories={hiddenStories}
i18n={i18n}
me={me}
myStories={myStories}
onForwardStory={storyId => {
toggleForwardMessageModal(storyId);
}}
onSaveStory={story => {
if (story.attachment) {
saveAttachment(story.attachment, story.timestamp);
}
}}
ourConversationId={String(ourConversationId)}
preferredWidthFromStorage={preferredWidthFromStorage}
renderStoryCreator={renderStoryCreator}
renderStoryViewer={renderStoryViewer}

View File

@ -7,6 +7,7 @@ import { useSelector } from 'react-redux';
import type { GetStoriesByConversationIdType } from '../selectors/stories';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import type { StoryViewType } from '../../types/Stories';
import { StoryViewer } from '../../components/StoryViewer';
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
import {
@ -28,8 +29,9 @@ import { useStoriesActions } from '../ducks/stories';
export type PropsType = {
conversationId: string;
onClose: () => unknown;
onNextUserStories: () => unknown;
onPrevUserStories: () => unknown;
onNextUserStories?: () => unknown;
onPrevUserStories?: () => unknown;
storyToView?: StoryViewType;
};
export function SmartStoryViewer({
@ -37,6 +39,7 @@ export function SmartStoryViewer({
onClose,
onNextUserStories,
onPrevUserStories,
storyToView,
}: PropsType): JSX.Element | null {
const storiesActions = useStoriesActions();
const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions();
@ -54,7 +57,9 @@ export function SmartStoryViewer({
GetStoriesByConversationIdType
>(getStoriesSelector);
const { group, stories } = getStoriesByConversationId(conversationId);
const { group, stories } = storyToView
? { group: undefined, stories: [storyToView] }
: getStoriesByConversationId(conversationId);
const recentEmojis = useRecentEmojis();
const skinTone = useSelector<StateType, number>(getEmojiSkinTone);

View File

@ -20,6 +20,7 @@ import type { actions as safetyNumber } from './ducks/safetyNumber';
import type { actions as search } from './ducks/search';
import type { actions as stickers } from './ducks/stickers';
import type { actions as stories } from './ducks/stories';
import type { actions as storyDistributionLists } from './ducks/storyDistributionLists';
import type { actions as updates } from './ducks/updates';
import type { actions as user } from './ducks/user';
@ -43,6 +44,7 @@ export type ReduxActions = {
search: typeof search;
stickers: typeof stickers;
stories: typeof stories;
storyDistributionLists: typeof storyDistributionLists;
updates: typeof updates;
user: typeof user;
};

View File

@ -0,0 +1,74 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as uuid } from 'uuid';
import type { AttachmentType } from '../../types/Attachment';
import type { ConversationType } from '../../state/ducks/conversations';
import type {
ConversationStoryType,
MyStoryType,
StoryViewType,
} from '../../types/Stories';
import * as durations from '../../util/durations';
import { getDefaultConversation } from './getDefaultConversation';
import { fakeAttachment, fakeThumbnail } from './fakeAttachment';
import { MY_STORIES_ID } from '../../types/Stories';
function getAttachmentWithThumbnail(url: string): AttachmentType {
return fakeAttachment({
url,
thumbnail: fakeThumbnail(url),
});
}
export function getFakeMyStory(id?: string, name?: string): MyStoryType {
const storyCount = Math.ceil(Math.random() * 6 + 1);
return {
distributionId: id || uuid(),
distributionName:
name || id === MY_STORIES_ID ? 'My Stories' : 'Private Distribution List',
stories: Array.from(Array(storyCount), () => ({
...getFakeStoryView(),
sendState: [],
views: Math.floor(Math.random() * 20),
})),
};
}
export function getFakeStoryView(
attachmentUrl?: string,
timestamp?: number
): StoryViewType {
const sender = getDefaultConversation();
return {
attachment: getAttachmentWithThumbnail(
attachmentUrl || '/fixtures/tina-rolf-269345-unsplash.jpg'
),
hasReplies: Math.random() > 0.5,
isUnread: Math.random() > 0.5,
messageId: uuid(),
sender,
timestamp: timestamp || Date.now() - 2 * durations.MINUTE,
};
}
export function getFakeStory({
attachmentUrl,
group,
timestamp,
}: {
attachmentUrl?: string;
group?: ConversationType;
timestamp?: number;
}): ConversationStoryType {
const storyView = getFakeStoryView(attachmentUrl, timestamp);
return {
conversationId: storyView.sender.id,
group,
stories: [storyView],
};
}

View File

@ -4,7 +4,6 @@
import { assert } from 'chai';
import dataInterface from '../../sql/Client';
import { getRandomBytes } from '../../Crypto';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
@ -19,6 +18,7 @@ const {
getAllStoryDistributionsWithMembers,
modifyStoryDistribution,
modifyStoryDistributionMembers,
modifyStoryDistributionWithMembers,
} = dataInterface;
function getUuid(): UUIDStringType {
@ -34,14 +34,19 @@ describe('sql/storyDistribution', () => {
const list: StoryDistributionWithMembersType = {
id: getUuid(),
name: 'My Story',
avatarUrlPath: getUuid(),
avatarKey: getRandomBytes(128),
allowsReplies: true,
isBlockList: false,
members: [getUuid(), getUuid()],
senderKeyInfo: {
createdAtDate: Date.now(),
distributionId: getUuid(),
memberDevices: [],
},
storageID: getUuid(),
storageVersion: 1,
storageNeedsSync: false,
storageUnknownFields: undefined,
deletedAtTimestamp: undefined,
};
await createNewStoryDistribution(list);
@ -66,14 +71,19 @@ describe('sql/storyDistribution', () => {
const list: StoryDistributionWithMembersType = {
id: getUuid(),
name: 'My Story',
avatarUrlPath: getUuid(),
avatarKey: getRandomBytes(128),
allowsReplies: true,
isBlockList: false,
members: [UUID_1, UUID_2],
senderKeyInfo: {
createdAtDate: Date.now(),
distributionId: getUuid(),
memberDevices: [],
},
storageID: getUuid(),
storageVersion: 1,
storageNeedsSync: false,
storageUnknownFields: undefined,
deletedAtTimestamp: undefined,
};
await createNewStoryDistribution(list);
@ -84,8 +94,6 @@ describe('sql/storyDistribution', () => {
const updated = {
...list,
name: 'Updated story',
avatarKey: getRandomBytes(128),
avatarUrlPath: getUuid(),
senderKeyInfo: {
createdAtDate: Date.now() + 10,
distributionId: getUuid(),
@ -117,14 +125,19 @@ describe('sql/storyDistribution', () => {
const list: StoryDistributionWithMembersType = {
id: getUuid(),
name: 'My Story',
avatarUrlPath: getUuid(),
avatarKey: getRandomBytes(128),
allowsReplies: true,
isBlockList: false,
members: [UUID_1, UUID_2],
senderKeyInfo: {
createdAtDate: Date.now(),
distributionId: getUuid(),
memberDevices: [],
},
storageID: getUuid(),
storageVersion: 1,
storageNeedsSync: false,
storageUnknownFields: undefined,
deletedAtTimestamp: undefined,
};
await createNewStoryDistribution(list);
@ -148,20 +161,69 @@ describe('sql/storyDistribution', () => {
});
});
it('adds and removes with modifyStoryDistributionWithMembers', async () => {
const UUID_1 = getUuid();
const UUID_2 = getUuid();
const UUID_3 = getUuid();
const UUID_4 = getUuid();
const list: StoryDistributionWithMembersType = {
id: getUuid(),
name: 'My Story',
allowsReplies: true,
isBlockList: false,
members: [UUID_1, UUID_2],
senderKeyInfo: {
createdAtDate: Date.now(),
distributionId: getUuid(),
memberDevices: [],
},
storageID: getUuid(),
storageVersion: 1,
storageNeedsSync: false,
storageUnknownFields: undefined,
deletedAtTimestamp: undefined,
};
await createNewStoryDistribution(list);
assert.lengthOf(await _getAllStoryDistributions(), 1);
assert.lengthOf(await _getAllStoryDistributionMembers(), 2);
await modifyStoryDistributionWithMembers(list, {
toAdd: [UUID_3, UUID_4],
toRemove: [UUID_1],
});
assert.lengthOf(await _getAllStoryDistributions(), 1);
assert.lengthOf(await _getAllStoryDistributionMembers(), 3);
const allHydratedLists = await getAllStoryDistributionsWithMembers();
assert.lengthOf(allHydratedLists, 1);
assert.deepEqual(allHydratedLists[0], {
...list,
members: [UUID_2, UUID_3, UUID_4],
});
});
it('eliminates duplicates without complaint in createNewStoryDistribution', async () => {
const UUID_1 = getUuid();
const UUID_2 = getUuid();
const list: StoryDistributionWithMembersType = {
id: getUuid(),
name: 'My Story',
avatarUrlPath: getUuid(),
avatarKey: getRandomBytes(128),
allowsReplies: true,
isBlockList: false,
members: [UUID_1, UUID_1, UUID_2],
senderKeyInfo: {
createdAtDate: Date.now(),
distributionId: getUuid(),
memberDevices: [],
},
storageID: getUuid(),
storageVersion: 1,
storageNeedsSync: false,
storageUnknownFields: undefined,
deletedAtTimestamp: undefined,
};
await createNewStoryDistribution(list);

View File

@ -108,10 +108,26 @@ describe('both/state/ducks/stories', () => {
attachments: [attachment],
};
const rootState = getEmptyRootState();
const getState = () => ({
...rootState,
stories: {
...rootState.stories,
stories: [
{
...messageAttributes,
attachment: messageAttributes.attachments[0],
messageId: messageAttributes.id,
},
],
},
});
window.MessageController.register(storyId, messageAttributes);
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
await queueStoryDownload(storyId)(dispatch, getState, null);
const action = dispatch.getCall(0).args[0];
@ -164,10 +180,26 @@ describe('both/state/ducks/stories', () => {
],
};
const rootState = getEmptyRootState();
const getState = () => ({
...rootState,
stories: {
...rootState.stories,
stories: [
{
...messageAttributes,
attachment: messageAttributes.attachments[0],
messageId: messageAttributes.id,
},
],
},
});
window.MessageController.register(storyId, messageAttributes);
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
await queueStoryDownload(storyId)(dispatch, getState, null);
sinon.assert.calledWith(dispatch, {
type: 'NOOP',

View File

@ -1788,7 +1788,8 @@ export default class MessageReceiver
private async handleStoryMessage(
envelope: UnsealedEnvelope,
msg: Proto.IStoryMessage
msg: Proto.IStoryMessage,
sentMessage?: ProcessedSent
): Promise<void> {
const logId = this.getEnvelopeId(envelope);
log.info('MessageReceiver.handleStoryMessage', logId);
@ -1846,6 +1847,68 @@ export default class MessageReceiver
return;
}
const message = {
attachments,
canReplyToStory: Boolean(msg.allowsReplies),
expireTimer,
flags: 0,
groupV2,
isStory: true,
isViewOnce: false,
timestamp: envelope.timestamp,
};
if (sentMessage) {
const { storyMessageRecipients } = sentMessage;
const recipients = storyMessageRecipients ?? [];
const isAllowedToReply = new Map<string, boolean>();
const distributionListToSentUuid = new Map<string, Set<string>>();
recipients.forEach(recipient => {
const { destinationUuid } = recipient;
if (!destinationUuid) {
return;
}
recipient.distributionListIds?.forEach(listId => {
const sentUuids: Set<string> =
distributionListToSentUuid.get(listId) || new Set();
sentUuids.add(destinationUuid);
distributionListToSentUuid.set(listId, sentUuids);
});
isAllowedToReply.set(
destinationUuid,
Boolean(recipient.isAllowedToReply)
);
});
distributionListToSentUuid.forEach((sentToUuids, listId) => {
const ev = new SentEvent(
{
destinationUuid: dropNull(sentMessage.destinationUuid),
timestamp: envelope.timestamp,
serverTimestamp: envelope.serverTimestamp,
unidentifiedStatus: Array.from(sentToUuids).map(
destinationUuid => ({
destinationUuid,
isAllowedToReplyToStory: isAllowedToReply.get(destinationUuid),
})
),
message,
isRecipientUpdate: Boolean(sentMessage.isRecipientUpdate),
receivedAtCounter: envelope.receivedAtCounter,
receivedAtDate: envelope.receivedAtDate,
storyDistributionListId: listId,
},
this.removeFromCache.bind(this, envelope)
);
this.dispatchAndWait(ev);
});
return;
}
const ev = new MessageEvent(
{
source: envelope.source,
@ -1857,15 +1920,7 @@ export default class MessageReceiver
unidentifiedDeliveryReceived: Boolean(
envelope.unidentifiedDeliveryReceived
),
message: {
attachments,
expireTimer,
flags: 0,
groupV2,
isStory: true,
isViewOnce: false,
timestamp: envelope.timestamp,
},
message,
receivedAtCounter: envelope.receivedAtCounter,
receivedAtDate: envelope.receivedAtDate,
},
@ -2415,6 +2470,15 @@ export default class MessageReceiver
if (syncMessage.sent) {
const sentMessage = syncMessage.sent;
if (sentMessage.storyMessage) {
this.handleStoryMessage(
envelope,
sentMessage.storyMessage,
sentMessage
);
return;
}
if (!sentMessage || !sentMessage.message) {
throw new Error(
'MessageReceiver.handleSyncMessage: sync sent message was missing message'

View File

@ -219,6 +219,7 @@ export type ProcessedDataMessage = {
groupCallUpdate?: ProcessedGroupCallUpdate;
storyContext?: ProcessedStoryContext;
giftBadge?: ProcessedGiftBadge;
canReplyToStory?: boolean;
};
export type ProcessedUnidentifiedDeliveryStatus = Omit<
@ -226,6 +227,7 @@ export type ProcessedUnidentifiedDeliveryStatus = Omit<
'destinationUuid'
> & {
destinationUuid?: string;
isAllowedToReplyToStory?: boolean;
};
export type ProcessedSent = Omit<

View File

@ -193,6 +193,7 @@ export type SentEventData = Readonly<{
receivedAtCounter: number;
receivedAtDate: number;
expirationStartTimestamp?: number;
storyDistributionListId?: string;
}>;
export class SentEvent extends ConfirmableEvent {

View File

@ -1,8 +1,10 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from './Attachment';
import type { ContactNameColorType } from './Colors';
import type { ConversationType } from '../state/ducks/conversations';
import type { SendStatus } from '../messages/MessageSendState';
export type ReplyType = Pick<
ConversationType,
@ -27,3 +29,73 @@ export type ReplyStateType = {
messageId: string;
replies: Array<ReplyType>;
};
export type ConversationStoryType = {
conversationId: string;
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
stories: Array<StoryViewType>;
};
export type StorySendStateType = {
isAllowedToReplyToStory?: boolean;
recipient: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
status: SendStatus;
updatedAt?: number;
};
export type StoryViewType = {
attachment?: AttachmentType;
canReply?: boolean;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
isHidden?: boolean;
isUnread?: boolean;
messageId: string;
sender: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'firstName'
| 'id'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
sendState?: Array<StorySendStateType>;
timestamp: number;
views?: number;
};
export type MyStoryType = {
distributionId: string;
distributionName: string;
stories: Array<StoryViewType>;
};
export const MY_STORIES_ID = '00000000-0000-0000-0000-000000000000';

View File

@ -77,4 +77,8 @@ export class UUID {
}
return new UUID(`${padded}-0000-4000-8000-${'0'.repeat(12)}`);
}
public static fromString(value: string): UUIDStringType {
return new UUID(value).toString();
}
}

View File

@ -0,0 +1,27 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import { getRecipients } from './getRecipients';
import { strictAssert } from './assert';
// Recipients includes only the people we'll actually send to for this conversation
export function getRecipientConversationIds(
conversationAttrs: ConversationAttributesType
): Set<string> {
const recipients = getRecipients(conversationAttrs);
const conversationIds = recipients.map(identifier => {
const conversation = window.ConversationController.getOrCreate(
identifier,
'private'
);
strictAssert(
conversation,
'getRecipientConversationIds should have created conversation!'
);
return conversation.id;
});
return new Set(conversationIds);
}

51
ts/util/getRecipients.ts Normal file
View File

@ -0,0 +1,51 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { compact, uniq } from 'lodash';
import type { ConversationAttributesType } from '../model-types.d';
import { getConversationMembers } from './getConversationMembers';
import { getSendTarget } from './getSendTarget';
import { isDirectConversation, isMe } from './whatTypeOfConversation';
import { isNotNil } from './isNotNil';
export function getRecipients(
conversationAttributes: ConversationAttributesType,
{
includePendingMembers,
extraConversationsForSend,
}: {
includePendingMembers?: boolean;
extraConversationsForSend?: Array<string>;
} = {}
): Array<string> {
if (isDirectConversation(conversationAttributes)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [getSendTarget(conversationAttributes)!];
}
const members = getConversationMembers(conversationAttributes, {
includePendingMembers,
});
// There are cases where we need to send to someone we just removed from the group, to
// let them know that we removed them. In that case, we need to send to more than
// are currently in the group.
const extraConversations = extraConversationsForSend
? extraConversationsForSend
.map(id => window.ConversationController.get(id)?.attributes)
.filter(isNotNil)
: [];
const uniqueMembers = extraConversations.length
? uniq([...members, ...extraConversations])
: members;
// Eliminate ourself
return compact(
uniqueMembers.map(memberAttrs =>
isMe(memberAttrs) ? null : getSendTarget(memberAttrs)
)
);
}

11
ts/util/getSendTarget.ts Normal file
View File

@ -0,0 +1,11 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
export function getSendTarget({
uuid,
e164,
}: Pick<ConversationAttributesType, 'uuid' | 'e164'>): string | undefined {
return uuid || e164;
}

View File

@ -0,0 +1,193 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment';
import type { ConversationModel } from '../models/conversations';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { MessageAttributesType } from '../model-types.d';
import * as log from '../logging/log';
import { getMessageIdForLogging } from './idForLogging';
import { markAllAsApproved } from './markAllAsApproved';
import { markAllAsVerifiedDefault } from './markAllAsVerifiedDefault';
import { resetLinkPreview } from '../services/LinkPreview';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
export async function maybeForwardMessage(
messageAttributes: MessageAttributesType,
conversationIds: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
): Promise<boolean> {
const idForLogging = getMessageIdForLogging(messageAttributes);
log.info(`maybeForwardMessage/${idForLogging}: Starting...`);
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(`${attachment.fileName}/${attachment.contentType}`);
});
}
const conversations = conversationIds.map(id =>
window.ConversationController.get(id)
);
const cannotSend = conversations.some(
conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
);
if (cannotSend) {
throw new Error('Cannot send to group');
}
// Verify that all contacts that we're forwarding
// to are verified and trusted
const unverifiedContacts: Array<ConversationModel> = [];
const untrustedContacts: Array<ConversationModel> = [];
await Promise.all(
conversations.map(async conversation => {
if (conversation) {
await conversation.updateVerified();
const unverifieds = conversation.getUnverified();
if (unverifieds.length) {
unverifieds.forEach(unverifiedConversation =>
unverifiedContacts.push(unverifiedConversation)
);
}
const untrusted = conversation.getUntrusted();
if (untrusted.length) {
untrusted.forEach(untrustedConversation =>
untrustedContacts.push(untrustedConversation)
);
}
}
})
);
// If there are any unverified or untrusted contacts, show the
// SendAnywayDialog and if we're fine with sending then mark all as
// verified and trusted and continue the send.
const iffyConversations = [...unverifiedContacts, ...untrustedContacts];
if (iffyConversations.length) {
const forwardMessageModal = document.querySelector<HTMLElement>(
'.module-ForwardMessageModal'
);
if (forwardMessageModal) {
forwardMessageModal.style.display = 'none';
}
const sendAnyway = await new Promise(resolve => {
showSafetyNumberChangeDialog({
contacts: iffyConversations,
reject: () => {
resolve(false);
},
resolve: () => {
resolve(true);
},
});
});
if (!sendAnyway) {
if (forwardMessageModal) {
forwardMessageModal.style.display = 'block';
}
return false;
}
let verifyPromise: Promise<void> | undefined;
let approvePromise: Promise<void> | undefined;
if (unverifiedContacts.length) {
verifyPromise = markAllAsVerifiedDefault(unverifiedContacts);
}
if (untrustedContacts.length) {
approvePromise = markAllAsApproved(untrustedContacts);
}
await Promise.all([verifyPromise, approvePromise]);
}
const sendMessageOptions = { dontClearDraft: true };
const baseTimestamp = Date.now();
const {
loadAttachmentData,
loadContactData,
loadPreviewData,
loadStickerData,
} = window.Signal.Migrations;
// Actually send the message
// load any sticker data, attachments, or link previews that we need to
// send along with the message and do the send to each conversation.
await Promise.all(
conversations.map(async (conversation, offset) => {
const timestamp = baseTimestamp + offset;
if (conversation) {
const { sticker, contact } = messageAttributes;
if (sticker) {
const stickerWithData = await loadStickerData(sticker);
const stickerNoPath = stickerWithData
? {
...stickerWithData,
data: {
...stickerWithData.data,
path: undefined,
},
}
: undefined;
conversation.enqueueMessageForSend(
{
body: undefined,
attachments: [],
sticker: stickerNoPath,
},
{ ...sendMessageOptions, timestamp }
);
} else if (contact?.length) {
const contactWithHydratedAvatar = await loadContactData(contact);
conversation.enqueueMessageForSend(
{
body: undefined,
attachments: [],
contact: contactWithHydratedAvatar,
},
{ ...sendMessageOptions, timestamp }
);
} else {
const preview = linkPreview
? await loadPreviewData([linkPreview])
: [];
const attachmentsWithData = await Promise.all(
(attachments || []).map(async item => ({
...(await loadAttachmentData(item)),
path: undefined,
}))
);
const attachmentsToSend = attachmentsWithData.filter(
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
conversation.enqueueMessageForSend(
{
body: messageBody || undefined,
attachments: attachmentsToSend,
preview,
},
{ ...sendMessageOptions, timestamp }
);
}
}
})
);
// Cancel any link still pending, even if it didn't make it into the message
resetLinkPreview();
return true;
}

32
ts/util/saveAttachment.ts Normal file
View File

@ -0,0 +1,32 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment';
import * as Attachment from '../types/Attachment';
import { showToast } from './showToast';
import { ToastFileSaved } from '../components/ToastFileSaved';
export async function saveAttachment(
attachment: AttachmentType,
timestamp = Date.now(),
index = 0
): Promise<void> {
const { openFileInFolder, readAttachmentData, saveAttachmentToDisk } =
window.Signal.Migrations;
const fullPath = await Attachment.save({
attachment,
index: index + 1,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
});
if (fullPath) {
showToast(ToastFileSaved, {
onOpenFile: () => {
openFileInFolder(fullPath);
},
});
}
}

View File

@ -0,0 +1,90 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import * as Errors from '../types/errors';
import * as durations from './durations';
import * as log from '../logging/log';
import { DeleteModel } from '../messageModifiers/Deletes';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { deleteForEveryone } from './deleteForEveryone';
import { getConversationIdForLogging } from './idForLogging';
import { getMessageById } from '../messages/getMessageById';
import { getRecipientConversationIds } from './getRecipientConversationIds';
import { getRecipients } from './getRecipients';
import { repeat, zipObject } from './iterables';
const THREE_HOURS = durations.HOUR * 3;
export async function sendDeleteForEveryoneMessage(
conversationAttributes: ConversationAttributesType,
options: {
deleteForEveryoneDuration?: number;
id: string;
timestamp: number;
}
): Promise<void> {
const {
deleteForEveryoneDuration,
timestamp: targetTimestamp,
id: messageId,
} = options;
const message = await getMessageById(messageId);
if (!message) {
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
}
const messageModel = window.MessageController.register(messageId, message);
const timestamp = Date.now();
if (
timestamp - targetTimestamp >
(deleteForEveryoneDuration || THREE_HOURS)
) {
throw new Error('Cannot send DOE for a message older than three hours');
}
messageModel.set({
deletedForEveryoneSendStatus: zipObject(
getRecipientConversationIds(conversationAttributes),
repeat(false)
),
});
try {
const jobData: ConversationQueueJobData = {
type: conversationQueueJobEnum.enum.DeleteForEveryone,
conversationId: conversationAttributes.id,
messageId,
recipients: getRecipients(conversationAttributes),
revision: conversationAttributes.revision,
targetTimestamp,
};
await conversationJobQueue.add(jobData, async jobToInsert => {
const idForLogging = getConversationIdForLogging(conversationAttributes);
log.info(
`sendDeleteForEveryoneMessage: saving message ${idForLogging} and job ${jobToInsert.id}`
);
await window.Signal.Data.saveMessage(messageModel.attributes, {
jobToInsert,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
});
} catch (error) {
log.error(
'sendDeleteForEveryoneMessage: Failed to queue delete for everyone',
Errors.toLogFormat(error)
);
throw error;
}
const deleteModel = new DeleteModel({
targetSentTimestamp: targetTimestamp,
serverTimestamp: Date.now(),
fromId: window.ConversationController.getOurConversationIdOrThrow(),
});
await deleteForEveryone(messageModel, deleteModel);
}

View File

@ -12,10 +12,6 @@ import { Lightbox } from '../components/Lightbox';
let lightboxMountNode: HTMLElement | undefined;
export function isLightboxOpen(): boolean {
return Boolean(lightboxMountNode);
}
export function closeLightbox(): void {
if (!lightboxMountNode) {
return;

View File

@ -11,7 +11,6 @@ import { render } from 'mustache';
import type { AttachmentType } from '../types/Attachment';
import { isGIF } from '../types/Attachment';
import * as Attachment from '../types/Attachment';
import * as Stickers from '../types/Stickers';
import type { BodyRangeType, BodyRangesType } from '../types/Util';
import type { MIMEType } from '../types/MIME';
@ -22,7 +21,6 @@ import type {
ConversationModelCollectionType,
QuotedMessageType,
} from '../model-types.d';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem';
import type { MessageModel } from '../models/messages';
import { getMessageById } from '../messages/getMessageById';
@ -41,7 +39,6 @@ import { findAndFormatContact } from '../util/findAndFormatContact';
import { getPreferredBadgeSelector } from '../state/selectors/badges';
import {
canReply,
getAttachmentsForMessage,
isIncoming,
isOutgoing,
isTapToView,
@ -73,7 +70,6 @@ import { ToastConversationUnarchived } from '../components/ToastConversationUnar
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import { ToastExpired } from '../components/ToastExpired';
import { ToastFileSaved } from '../components/ToastFileSaved';
import { ToastFileSize } from '../components/ToastFileSize';
import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
import { ToastLeftGroup } from '../components/ToastLeftGroup';
@ -116,6 +112,8 @@ import {
} from '../services/LinkPreview';
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { closeLightbox, showLightbox } from '../util/showLightbox';
import { saveAttachment } from '../util/saveAttachment';
import { sendDeleteForEveryoneMessage } from '../util/sendDeleteForEveryoneMessage';
type AttachmentOptions = {
messageId: string;
@ -133,13 +131,6 @@ const {
deleteTempFile,
getAbsoluteAttachmentPath,
getAbsoluteTempPath,
loadAttachmentData,
loadContactData,
loadPreviewData,
loadStickerData,
openFileInFolder,
readAttachmentData,
saveAttachmentToDisk,
upgradeMessageSchema,
} = window.Signal.Migrations;
@ -231,7 +222,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View;
private forwardMessageModal?: Backbone.View;
private lightboxView?: ReactWrapperView;
private migrationDialog?: Backbone.View;
private stickerPreviewModalView?: Backbone.View;
@ -1285,239 +1275,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
async showForwardMessageModal(messageId: string): Promise<void> {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`showForwardMessageModal: Message ${messageId} missing!`);
}
// We need to give it a fresh object because it's memoized by the root object!
const rawAttachments = getAttachmentsForMessage({ ...message.attributes });
const attachments = rawAttachments.filter(attachment =>
Boolean(attachment.url)
);
const doForwardMessage = async (
conversationIds: Array<string>,
messageBody?: string,
includedAttachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => {
try {
const didForwardSuccessfully = await this.maybeForwardMessage(
message,
conversationIds,
messageBody,
includedAttachments,
linkPreview
);
if (didForwardSuccessfully && this.forwardMessageModal) {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
} catch (err) {
log.warn('doForwardMessage', err && err.stack ? err.stack : err);
}
};
this.forwardMessageModal = new ReactWrapperView({
JSX: window.Signal.State.Roots.createForwardMessageModal(
window.reduxStore,
{
attachments,
doForwardMessage,
hasContact: Boolean(message.get('contact')?.length),
isSticker: Boolean(message.get('sticker')),
messageBody: message.getRawText(),
onClose: () => {
if (this.forwardMessageModal) {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
resetLinkPreview();
},
onEditorStateChange: (
messageText: string,
_: Array<BodyRangeType>,
caretLocation?: number
) => {
if (!attachments.length) {
maybeGrabLinkPreview(
messageText,
LinkPreviewSourceType.ForwardMessageModal,
caretLocation
);
}
},
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
}
),
});
this.forwardMessageModal.render();
}
async maybeForwardMessage(
message: MessageModel,
conversationIds: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
): Promise<boolean> {
log.info(`maybeForwardMessage/${message.idForLogging()}: Starting...`);
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(
`${attachment.fileName}/${attachment.contentType}`
);
});
}
const conversations = conversationIds.map(id =>
window.ConversationController.get(id)
);
const cannotSend = conversations.some(
conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
);
if (cannotSend) {
throw new Error('Cannot send to group');
}
// Verify that all contacts that we're forwarding
// to are verified and trusted
const unverifiedContacts: Array<ConversationModel> = [];
const untrustedContacts: Array<ConversationModel> = [];
await Promise.all(
conversations.map(async conversation => {
if (conversation) {
await conversation.updateVerified();
const unverifieds = conversation.getUnverified();
if (unverifieds.length) {
unverifieds.forEach(unverifiedConversation =>
unverifiedContacts.push(unverifiedConversation)
);
}
const untrusted = conversation.getUntrusted();
if (untrusted.length) {
untrusted.forEach(untrustedConversation =>
untrustedContacts.push(untrustedConversation)
);
}
}
})
);
// If there are any unverified or untrusted contacts, show the
// SendAnywayDialog and if we're fine with sending then mark all as
// verified and trusted and continue the send.
const iffyConversations = [...unverifiedContacts, ...untrustedContacts];
if (iffyConversations.length) {
const forwardMessageModal = document.querySelector<HTMLElement>(
'.module-ForwardMessageModal'
);
if (forwardMessageModal) {
forwardMessageModal.style.display = 'none';
}
const sendAnyway = await this.showSendAnywayDialog(iffyConversations);
if (!sendAnyway) {
if (forwardMessageModal) {
forwardMessageModal.style.display = 'block';
}
return false;
}
let verifyPromise: Promise<void> | undefined;
let approvePromise: Promise<void> | undefined;
if (unverifiedContacts.length) {
verifyPromise = markAllAsVerifiedDefault(unverifiedContacts);
}
if (untrustedContacts.length) {
approvePromise = markAllAsApproved(untrustedContacts);
}
await Promise.all([verifyPromise, approvePromise]);
}
const sendMessageOptions = { dontClearDraft: true };
const baseTimestamp = Date.now();
// Actually send the message
// load any sticker data, attachments, or link previews that we need to
// send along with the message and do the send to each conversation.
await Promise.all(
conversations.map(async (conversation, offset) => {
const timestamp = baseTimestamp + offset;
if (conversation) {
const sticker = message.get('sticker');
const contact = message.get('contact');
if (sticker) {
const stickerWithData = await loadStickerData(sticker);
const stickerNoPath = stickerWithData
? {
...stickerWithData,
data: {
...stickerWithData.data,
path: undefined,
},
}
: undefined;
conversation.enqueueMessageForSend(
{
body: undefined,
attachments: [],
sticker: stickerNoPath,
},
{ ...sendMessageOptions, timestamp }
);
} else if (contact?.length) {
const contactWithHydratedAvatar = await loadContactData(contact);
conversation.enqueueMessageForSend(
{
body: undefined,
attachments: [],
contact: contactWithHydratedAvatar,
},
{ ...sendMessageOptions, timestamp }
);
} else {
const preview = linkPreview
? await loadPreviewData([linkPreview])
: [];
const attachmentsWithData = await Promise.all(
(attachments || []).map(async item => ({
...(await loadAttachmentData(item)),
path: undefined,
}))
);
const attachmentsToSend = attachmentsWithData.filter(
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
conversation.enqueueMessageForSend(
{
body: messageBody || undefined,
attachments: attachmentsToSend,
preview,
},
{ ...sendMessageOptions, timestamp }
);
}
}
})
);
// Cancel any link still pending, even if it didn't make it into the message
resetLinkPreview();
return true;
window.reduxActions.globalModals.toggleForwardMessageModal(messageId);
}
showAllMedia(): void {
@ -1632,30 +1390,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
});
});
const saveAttachment = async ({
attachment,
message,
}: {
attachment: AttachmentType;
message: Pick<MessageAttributesType, 'sent_at'>;
}) => {
const timestamp = message.sent_at;
const fullPath = await Attachment.save({
attachment,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
});
if (fullPath) {
showToast(ToastFileSaved, {
onOpenFile: () => {
openFileInFolder(fullPath);
},
});
}
};
const onItemClick = async ({
message,
attachment,
@ -1663,7 +1397,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}: ItemClickEvent) => {
switch (type) {
case 'documents': {
saveAttachment({ message, attachment });
saveAttachment(attachment, message.sent_at);
break;
}
@ -1842,20 +1576,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
return;
}
const fullPath = await Attachment.save({
attachment,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
});
if (fullPath) {
showToast(ToastFileSaved, {
onOpenFile: () => {
openFileInFolder(fullPath);
},
});
}
return saveAttachment(attachment, timestamp);
}
async displayTapToViewMessage(messageId: string): Promise<void> {
@ -1975,7 +1696,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
okText: window.i18n('delete'),
resolve: async () => {
try {
await this.model.sendDeleteForEveryoneMessage({
await sendDeleteForEveryoneMessage(this.model.attributes, {
id: message.id,
timestamp: message.get('sent_at'),
});
@ -2028,21 +1749,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
message: MediaItemMessageType;
index: number;
}) => {
const fullPath = await Attachment.save({
attachment,
index: index + 1,
readAttachmentData,
saveAttachmentToDisk,
timestamp: message.sent_at,
});
if (fullPath) {
showToast(ToastFileSaved, {
onOpenFile: () => {
openFileInFolder(fullPath);
},
});
}
return saveAttachment(attachment, message.sent_at, index + 1);
};
const selectedIndex = media.findIndex(

2
ts/window.d.ts vendored
View File

@ -39,7 +39,6 @@ import { createStore } from './state/createStore';
import { createApp } from './state/roots/createApp';
import { createChatColorPicker } from './state/roots/createChatColorPicker';
import { createConversationDetails } from './state/roots/createConversationDetails';
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
@ -180,7 +179,6 @@ export type SignalCoreType = {
createApp: typeof createApp;
createChatColorPicker: typeof createChatColorPicker;
createConversationDetails: typeof createConversationDetails;
createForwardMessageModal: typeof createForwardMessageModal;
createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
createGroupV2JoinModal: typeof createGroupV2JoinModal;