Sync my stories with primary device
This commit is contained in:
parent
7554d8326a
commit
9155784d56
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -164,4 +164,8 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__my-stories {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
]),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
158
ts/sql/Server.ts
158
ts/sql/Server.ts
|
@ -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> {
|
||||
|
|
|
@ -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!');
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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'
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
);
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -193,6 +193,7 @@ export type SentEventData = Readonly<{
|
|||
receivedAtCounter: number;
|
||||
receivedAtDate: number;
|
||||
expirationStartTimestamp?: number;
|
||||
storyDistributionListId?: string;
|
||||
}>;
|
||||
|
||||
export class SentEvent extends ConfirmableEvent {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue