Sync my stories with primary device
This commit is contained in:
parent
7554d8326a
commit
9155784d56
|
@ -7315,6 +7315,10 @@
|
||||||
"message": "No longer available",
|
"message": "No longer available",
|
||||||
"description": "Label for when a story is not found"
|
"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": {
|
"WhatsNew__modal-title": {
|
||||||
"message": "What's New",
|
"message": "What's New",
|
||||||
"description": "Title for the whats new modal"
|
"description": "Title for the whats new modal"
|
||||||
|
|
|
@ -335,6 +335,7 @@ message StoryMessage {
|
||||||
AttachmentPointer fileAttachment = 3;
|
AttachmentPointer fileAttachment = 3;
|
||||||
TextAttachment textAttachment = 4;
|
TextAttachment textAttachment = 4;
|
||||||
}
|
}
|
||||||
|
optional bool allowsReplies = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message TextAttachment {
|
message TextAttachment {
|
||||||
|
@ -386,6 +387,12 @@ message SyncMessage {
|
||||||
optional bool unidentified = 2;
|
optional bool unidentified = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message StoryMessageRecipient {
|
||||||
|
optional string destinationUuid = 1;
|
||||||
|
repeated string distributionListIds = 2;
|
||||||
|
optional bool isAllowedToReply = 3;
|
||||||
|
}
|
||||||
|
|
||||||
optional string destination = 1;
|
optional string destination = 1;
|
||||||
optional string destinationUuid = 7;
|
optional string destinationUuid = 7;
|
||||||
optional uint64 timestamp = 2;
|
optional uint64 timestamp = 2;
|
||||||
|
@ -393,6 +400,8 @@ message SyncMessage {
|
||||||
optional uint64 expirationStartTimestamp = 4;
|
optional uint64 expirationStartTimestamp = 4;
|
||||||
repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5;
|
repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5;
|
||||||
optional bool isRecipientUpdate = 6 [default = false];
|
optional bool isRecipientUpdate = 6 [default = false];
|
||||||
|
optional StoryMessage storyMessage = 8;
|
||||||
|
repeated StoryMessageRecipient storyMessageRecipients = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Contacts {
|
message Contacts {
|
||||||
|
|
|
@ -39,6 +39,7 @@ message ManifestRecord {
|
||||||
GROUPV1 = 2;
|
GROUPV1 = 2;
|
||||||
GROUPV2 = 3;
|
GROUPV2 = 3;
|
||||||
ACCOUNT = 4;
|
ACCOUNT = 4;
|
||||||
|
STORY_DISTRIBUTION_LIST = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional bytes raw = 1;
|
optional bytes raw = 1;
|
||||||
|
@ -57,6 +58,7 @@ message StorageRecord {
|
||||||
GroupV1Record groupV1 = 2;
|
GroupV1Record groupV1 = 2;
|
||||||
GroupV2Record groupV2 = 3;
|
GroupV2Record groupV2 = 3;
|
||||||
AccountRecord account = 4;
|
AccountRecord account = 4;
|
||||||
|
StoryDistributionListRecord storyDistributionList = 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,3 +149,12 @@ message AccountRecord {
|
||||||
optional bool displayBadgesOnProfile = 23;
|
optional bool displayBadgesOnProfile = 23;
|
||||||
optional bool keepMutedChatsArchived = 25;
|
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;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 6px 8px;
|
padding: 6px;
|
||||||
|
margin: 0 2px;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
|
|
||||||
&--container {
|
&--container {
|
||||||
|
|
|
@ -25,15 +25,7 @@
|
||||||
|
|
||||||
&__preview {
|
&__preview {
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
background-color: $color-gray-60;
|
|
||||||
background-size: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
height: 72px;
|
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
overflow: hidden;
|
|
||||||
width: 46px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__timestamp {
|
&__timestamp {
|
||||||
|
|
|
@ -164,4 +164,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__my-stories {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,10 @@ import { IdleDetector } from './IdleDetector';
|
||||||
import { expiringMessagesDeletionService } from './services/expiringMessagesDeletion';
|
import { expiringMessagesDeletionService } from './services/expiringMessagesDeletion';
|
||||||
import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService';
|
import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService';
|
||||||
import { getStoriesForRedux, loadStories } from './services/storyLoader';
|
import { getStoriesForRedux, loadStories } from './services/storyLoader';
|
||||||
|
import {
|
||||||
|
getDistributionListsForRedux,
|
||||||
|
loadDistributionLists,
|
||||||
|
} from './services/distributionListLoader';
|
||||||
import { senderCertificateService } from './services/senderCertificate';
|
import { senderCertificateService } from './services/senderCertificate';
|
||||||
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
||||||
import * as KeyboardLayout from './services/keyboardLayout';
|
import * as KeyboardLayout from './services/keyboardLayout';
|
||||||
|
@ -977,6 +981,7 @@ export async function startApp(): Promise<void> {
|
||||||
loadRecentEmojis(),
|
loadRecentEmojis(),
|
||||||
loadInitialBadgesState(),
|
loadInitialBadgesState(),
|
||||||
loadStories(),
|
loadStories(),
|
||||||
|
loadDistributionLists(),
|
||||||
window.textsecure.storage.protocol.hydrateCaches(),
|
window.textsecure.storage.protocol.hydrateCaches(),
|
||||||
(async () => {
|
(async () => {
|
||||||
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
||||||
|
@ -1021,9 +1026,10 @@ export async function startApp(): Promise<void> {
|
||||||
const convoCollection = window.getConversations();
|
const convoCollection = window.getConversations();
|
||||||
const initialState = getInitialState({
|
const initialState = getInitialState({
|
||||||
badges: initialBadgesState,
|
badges: initialBadgesState,
|
||||||
stories: getStoriesForRedux(),
|
|
||||||
mainWindowStats,
|
mainWindowStats,
|
||||||
menuOptions,
|
menuOptions,
|
||||||
|
stories: getStoriesForRedux(),
|
||||||
|
storyDistributionLists: getDistributionListsForRedux(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = window.Signal.State.createStore(initialState);
|
const store = window.Signal.State.createStore(initialState);
|
||||||
|
@ -1072,6 +1078,10 @@ export async function startApp(): Promise<void> {
|
||||||
search: bindActionCreators(actionCreators.search, store.dispatch),
|
search: bindActionCreators(actionCreators.search, store.dispatch),
|
||||||
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
|
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
|
||||||
stories: bindActionCreators(actionCreators.stories, store.dispatch),
|
stories: bindActionCreators(actionCreators.stories, store.dispatch),
|
||||||
|
storyDistributionLists: bindActionCreators(
|
||||||
|
actionCreators.storyDistributionLists,
|
||||||
|
store.dispatch
|
||||||
|
),
|
||||||
updates: bindActionCreators(actionCreators.updates, store.dispatch),
|
updates: bindActionCreators(actionCreators.updates, store.dispatch),
|
||||||
user: bindActionCreators(actionCreators.user, store.dispatch),
|
user: bindActionCreators(actionCreators.user, store.dispatch),
|
||||||
};
|
};
|
||||||
|
@ -3091,7 +3101,7 @@ export async function startApp(): Promise<void> {
|
||||||
unidentifiedStatus.reduce(
|
unidentifiedStatus.reduce(
|
||||||
(
|
(
|
||||||
result: SendStateByConversationId,
|
result: SendStateByConversationId,
|
||||||
{ destinationUuid, destination }
|
{ destinationUuid, destination, isAllowedToReplyToStory }
|
||||||
) => {
|
) => {
|
||||||
const conversationId = window.ConversationController.ensureContactIds(
|
const conversationId = window.ConversationController.ensureContactIds(
|
||||||
{
|
{
|
||||||
|
@ -3106,6 +3116,7 @@ export async function startApp(): Promise<void> {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
[conversationId]: {
|
[conversationId]: {
|
||||||
|
isAllowedToReplyToStory,
|
||||||
status: SendStatus.Sent,
|
status: SendStatus.Sent,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
},
|
},
|
||||||
|
@ -3130,6 +3141,9 @@ export async function startApp(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new window.Whisper.Message({
|
return new window.Whisper.Message({
|
||||||
|
canReplyToStory: data.message.isStory
|
||||||
|
? data.message.canReplyToStory
|
||||||
|
: undefined,
|
||||||
conversationId: descriptor.id,
|
conversationId: descriptor.id,
|
||||||
expirationStartTimestamp: Math.min(
|
expirationStartTimestamp: Math.min(
|
||||||
data.expirationStartTimestamp || timestamp,
|
data.expirationStartTimestamp || timestamp,
|
||||||
|
@ -3146,7 +3160,8 @@ export async function startApp(): Promise<void> {
|
||||||
sourceDevice: data.device,
|
sourceDevice: data.device,
|
||||||
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
|
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
|
||||||
timestamp,
|
timestamp,
|
||||||
type: 'outgoing',
|
type: data.message.isStory ? 'story' : 'outgoing',
|
||||||
|
storyDistributionListId: data.storyDistributionListId,
|
||||||
unidentifiedDeliveries,
|
unidentifiedDeliveries,
|
||||||
} as Partial<MessageAttributesType> as WhatIsThis);
|
} as Partial<MessageAttributesType> as WhatIsThis);
|
||||||
}
|
}
|
||||||
|
@ -3384,20 +3399,23 @@ export async function startApp(): Promise<void> {
|
||||||
`Did not receive receivedAtCounter for message: ${data.timestamp}`
|
`Did not receive receivedAtCounter for message: ${data.timestamp}`
|
||||||
);
|
);
|
||||||
return new window.Whisper.Message({
|
return new window.Whisper.Message({
|
||||||
source: data.source,
|
canReplyToStory: data.message.isStory
|
||||||
sourceUuid: data.sourceUuid,
|
? data.message.canReplyToStory
|
||||||
sourceDevice: data.sourceDevice,
|
: undefined,
|
||||||
|
conversationId: descriptor.id,
|
||||||
|
readStatus: ReadStatus.Unread,
|
||||||
|
received_at: data.receivedAtCounter,
|
||||||
|
received_at_ms: data.receivedAtDate,
|
||||||
|
seenStatus: SeenStatus.Unseen,
|
||||||
sent_at: data.timestamp,
|
sent_at: data.timestamp,
|
||||||
serverGuid: data.serverGuid,
|
serverGuid: data.serverGuid,
|
||||||
serverTimestamp: data.serverTimestamp,
|
serverTimestamp: data.serverTimestamp,
|
||||||
received_at: data.receivedAtCounter,
|
source: data.source,
|
||||||
received_at_ms: data.receivedAtDate,
|
sourceDevice: data.sourceDevice,
|
||||||
conversationId: descriptor.id,
|
sourceUuid: data.sourceUuid,
|
||||||
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
|
||||||
type: data.message.isStory ? 'story' : 'incoming',
|
|
||||||
readStatus: ReadStatus.Unread,
|
|
||||||
seenStatus: SeenStatus.Unseen,
|
|
||||||
timestamp: data.timestamp,
|
timestamp: data.timestamp,
|
||||||
|
type: data.message.isStory ? 'story' : 'incoming',
|
||||||
|
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
||||||
} as Partial<MessageAttributesType> as WhatIsThis);
|
} as Partial<MessageAttributesType> as WhatIsThis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -86,55 +86,63 @@ export function ContextMenuPopper<T>({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={theme ? themeClassName(theme) : undefined}>
|
<FocusTrap
|
||||||
<div
|
focusTrapOptions={{
|
||||||
className="ContextMenu__popper"
|
allowOutsideClick: true,
|
||||||
ref={setPopperElement}
|
}}
|
||||||
style={styles.popper}
|
>
|
||||||
{...attributes.popper}
|
<div className={theme ? themeClassName(theme) : undefined}>
|
||||||
>
|
<div
|
||||||
{title && <div className="ContextMenu__title">{title}</div>}
|
className="ContextMenu__popper"
|
||||||
{menuOptions.map((option, index) => (
|
ref={setPopperElement}
|
||||||
<button
|
style={styles.popper}
|
||||||
aria-label={option.label}
|
{...attributes.popper}
|
||||||
className={classNames({
|
>
|
||||||
ContextMenu__option: true,
|
{title && <div className="ContextMenu__title">{title}</div>}
|
||||||
'ContextMenu__option--focused': focusedIndex === index,
|
{menuOptions.map((option, index) => (
|
||||||
})}
|
<button
|
||||||
key={option.label}
|
aria-label={option.label}
|
||||||
type="button"
|
className={classNames({
|
||||||
onClick={() => {
|
ContextMenu__option: true,
|
||||||
option.onClick(option.value);
|
'ContextMenu__option--focused': focusedIndex === index,
|
||||||
onClose();
|
})}
|
||||||
}}
|
key={option.label}
|
||||||
>
|
type="button"
|
||||||
<div className="ContextMenu__option--container">
|
onClick={() => {
|
||||||
{option.icon && (
|
option.onClick(option.value);
|
||||||
<div
|
onClose();
|
||||||
className={classNames(
|
}}
|
||||||
'ContextMenu__option--icon',
|
>
|
||||||
option.icon
|
<div className="ContextMenu__option--container">
|
||||||
)}
|
{option.icon && (
|
||||||
/>
|
<div
|
||||||
)}
|
className={classNames(
|
||||||
<div>
|
'ContextMenu__option--icon',
|
||||||
<div className="ContextMenu__option--title">{option.label}</div>
|
option.icon
|
||||||
{option.description && (
|
)}
|
||||||
<div className="ContextMenu__option--description">
|
/>
|
||||||
{option.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="ContextMenu__option--title">
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
{option.description && (
|
||||||
|
<div className="ContextMenu__option--description">
|
||||||
|
{option.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{typeof value !== 'undefined' &&
|
||||||
{typeof value !== 'undefined' &&
|
typeof option.value !== 'undefined' &&
|
||||||
typeof option.value !== 'undefined' &&
|
value === option.value ? (
|
||||||
value === option.value ? (
|
<div className="ContextMenu__option--selected" />
|
||||||
<div className="ContextMenu__option--selected" />
|
) : null}
|
||||||
) : null}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FocusTrap>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -214,22 +222,16 @@ export function ContextMenu<T>({
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
{menuShowing && (
|
{menuShowing && (
|
||||||
<FocusTrap
|
<ContextMenuPopper
|
||||||
focusTrapOptions={{
|
focusedIndex={focusedIndex}
|
||||||
allowOutsideClick: true,
|
isMenuShowing={menuShowing}
|
||||||
}}
|
menuOptions={menuOptions}
|
||||||
>
|
onClose={() => setMenuShowing(false)}
|
||||||
<ContextMenuPopper
|
popperOptions={popperOptions}
|
||||||
focusedIndex={focusedIndex}
|
referenceElement={referenceElement}
|
||||||
isMenuShowing={menuShowing}
|
title={title}
|
||||||
menuOptions={menuOptions}
|
value={value}
|
||||||
onClose={() => setMenuShowing(false)}
|
/>
|
||||||
popperOptions={popperOptions}
|
|
||||||
referenceElement={referenceElement}
|
|
||||||
title={title}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
</FocusTrap>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {
|
import type {
|
||||||
ContactModalStateType,
|
ContactModalStateType,
|
||||||
|
ForwardMessagePropsType,
|
||||||
UserNotFoundModalStateType,
|
UserNotFoundModalStateType,
|
||||||
} from '../state/ducks/globalModals';
|
} from '../state/ducks/globalModals';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
@ -18,6 +19,9 @@ type PropsType = {
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState?: ContactModalStateType;
|
contactModalState?: ContactModalStateType;
|
||||||
renderContactModal: () => JSX.Element;
|
renderContactModal: () => JSX.Element;
|
||||||
|
// ForwardMessageModal
|
||||||
|
forwardMessageProps?: ForwardMessagePropsType;
|
||||||
|
renderForwardMessageModal: () => JSX.Element;
|
||||||
// ProfileEditor
|
// ProfileEditor
|
||||||
isProfileEditorVisible: boolean;
|
isProfileEditorVisible: boolean;
|
||||||
renderProfileEditor: () => JSX.Element;
|
renderProfileEditor: () => JSX.Element;
|
||||||
|
@ -37,6 +41,9 @@ export const GlobalModalContainer = ({
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState,
|
contactModalState,
|
||||||
renderContactModal,
|
renderContactModal,
|
||||||
|
// ForwardMessageModal
|
||||||
|
forwardMessageProps,
|
||||||
|
renderForwardMessageModal,
|
||||||
// ProfileEditor
|
// ProfileEditor
|
||||||
isProfileEditorVisible,
|
isProfileEditorVisible,
|
||||||
renderProfileEditor,
|
renderProfileEditor,
|
||||||
|
@ -94,5 +101,9 @@ export const GlobalModalContainer = ({
|
||||||
return <WhatsNewModal hideWhatsNewModal={hideWhatsNewModal} i18n={i18n} />;
|
return <WhatsNewModal hideWhatsNewModal={hideWhatsNewModal} i18n={i18n} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (forwardMessageProps) {
|
||||||
|
return renderForwardMessageModal();
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
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 type { Meta, Story } from '@storybook/react';
|
||||||
import React from '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 type { PropsType } from './Stories';
|
||||||
import { Stories } from './Stories';
|
import { Stories } from './Stories';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
import {
|
import {
|
||||||
fakeAttachment,
|
getFakeMyStory,
|
||||||
fakeThumbnail,
|
getFakeStory,
|
||||||
} from '../test-both/helpers/fakeAttachment';
|
} from '../test-both/helpers/getFakeStory';
|
||||||
import * as durations from '../util/durations';
|
import * as durations from '../util/durations';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
@ -24,119 +20,136 @@ const i18n = setupI18n('en', enMessages);
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/Stories',
|
title: 'Components/Stories',
|
||||||
component: 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;
|
} 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} />;
|
const Template: Story<PropsType> = args => <Stories {...args} />;
|
||||||
|
|
||||||
export const Blank = Template.bind({});
|
export const Blank = Template.bind({});
|
||||||
Blank.args = {
|
Blank.args = {};
|
||||||
...getDefaultProps(),
|
|
||||||
stories: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Many = Template.bind({});
|
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 FocusTrap from 'focus-trap-react';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
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 { LocalizerType } from '../types/Util';
|
||||||
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
|
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
|
||||||
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
|
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 { StoriesPane } from './StoriesPane';
|
||||||
import { Theme, themeClassName } from '../util/theme';
|
import { Theme, themeClassName } from '../util/theme';
|
||||||
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
|
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
|
||||||
import * as log from '../logging/log';
|
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
|
deleteStoryForEveryone: (story: StoryViewType) => unknown;
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
me: ConversationType;
|
||||||
|
myStories: Array<MyStoryType>;
|
||||||
|
onForwardStory: (storyId: string) => unknown;
|
||||||
|
onSaveStory: (story: StoryViewType) => unknown;
|
||||||
|
ourConversationId: string;
|
||||||
preferredWidthFromStorage: number;
|
preferredWidthFromStorage: number;
|
||||||
queueStoryDownload: (storyId: string) => unknown;
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
|
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
|
||||||
|
@ -28,8 +42,14 @@ export type PropsType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Stories = ({
|
export const Stories = ({
|
||||||
|
deleteStoryForEveryone,
|
||||||
hiddenStories,
|
hiddenStories,
|
||||||
i18n,
|
i18n,
|
||||||
|
me,
|
||||||
|
myStories,
|
||||||
|
onForwardStory,
|
||||||
|
onSaveStory,
|
||||||
|
ourConversationId,
|
||||||
preferredWidthFromStorage,
|
preferredWidthFromStorage,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
renderStoryCreator,
|
renderStoryCreator,
|
||||||
|
@ -100,6 +120,7 @@ export const Stories = ({
|
||||||
}, [conversationIdToView, stories]);
|
}, [conversationIdToView, stories]);
|
||||||
|
|
||||||
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
|
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
|
||||||
|
const [isMyStories, setIsMyStories] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
|
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
|
||||||
|
@ -116,26 +137,49 @@ export const Stories = ({
|
||||||
})}
|
})}
|
||||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||||
<div className="Stories__pane" style={{ width }}>
|
<div className="Stories__pane" style={{ width }}>
|
||||||
<StoriesPane
|
{isMyStories && myStories.length ? (
|
||||||
hiddenStories={hiddenStories}
|
<MyStories
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onAddStory={() => setIsShowingStoryCreator(true)}
|
myStories={myStories}
|
||||||
onStoryClicked={clickedIdToView => {
|
onBack={() => setIsMyStories(false)}
|
||||||
const storyIndex = stories.findIndex(
|
onDelete={deleteStoryForEveryone}
|
||||||
x => x.conversationId === clickedIdToView
|
onForward={onForwardStory}
|
||||||
);
|
onSave={onSaveStory}
|
||||||
log.info('stories.onStoryClicked', {
|
ourConversationId={ourConversationId}
|
||||||
storyIndex,
|
queueStoryDownload={queueStoryDownload}
|
||||||
length: stories.length,
|
renderStoryViewer={renderStoryViewer}
|
||||||
});
|
/>
|
||||||
setConversationIdToView(clickedIdToView);
|
) : (
|
||||||
}}
|
<StoriesPane
|
||||||
queueStoryDownload={queueStoryDownload}
|
hiddenStories={hiddenStories}
|
||||||
showConversation={showConversation}
|
i18n={i18n}
|
||||||
stories={stories}
|
me={me}
|
||||||
toggleHideStories={toggleHideStories}
|
myStories={myStories}
|
||||||
toggleStoriesView={toggleStoriesView}
|
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>
|
</div>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
<div className="Stories__placeholder">
|
<div className="Stories__placeholder">
|
||||||
|
|
|
@ -5,9 +5,17 @@ import Fuse from 'fuse.js';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
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 { LocalizerType } from '../types/Util';
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
import { MyStoriesButton } from './MyStoriesButton';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { StoryListItem } from './StoryListItem';
|
import { StoryListItem } from './StoryListItem';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
|
@ -47,14 +55,19 @@ function search(
|
||||||
.map(result => result.item);
|
.map(result => result.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNewestStory(story: ConversationStoryType): StoryViewType {
|
function getNewestStory(
|
||||||
|
story: ConversationStoryType | MyStoryType
|
||||||
|
): StoryViewType {
|
||||||
return story.stories[story.stories.length - 1];
|
return story.stories[story.stories.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
me: ConversationType;
|
||||||
|
myStories: Array<MyStoryType>;
|
||||||
onAddStory: () => unknown;
|
onAddStory: () => unknown;
|
||||||
|
onMyStoriesClicked: () => unknown;
|
||||||
onStoryClicked: (conversationId: string) => unknown;
|
onStoryClicked: (conversationId: string) => unknown;
|
||||||
queueStoryDownload: (storyId: string) => unknown;
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
|
@ -66,7 +79,10 @@ export type PropsType = {
|
||||||
export const StoriesPane = ({
|
export const StoriesPane = ({
|
||||||
hiddenStories,
|
hiddenStories,
|
||||||
i18n,
|
i18n,
|
||||||
|
me,
|
||||||
|
myStories,
|
||||||
onAddStory,
|
onAddStory,
|
||||||
|
onMyStoriesClicked,
|
||||||
onStoryClicked,
|
onStoryClicked,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
showConversation,
|
showConversation,
|
||||||
|
@ -116,6 +132,16 @@ export const StoriesPane = ({
|
||||||
placeholder={i18n('search')}
|
placeholder={i18n('search')}
|
||||||
value={searchTerm}
|
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
|
<div
|
||||||
className={classNames('Stories__pane__list', {
|
className={classNames('Stories__pane__list', {
|
||||||
'Stories__pane__list--empty': !stories.length,
|
'Stories__pane__list--empty': !stories.length,
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Meta, Story } from '@storybook/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { action } from '@storybook/addon-actions';
|
|
||||||
|
|
||||||
import type { PropsType } from './StoryListItem';
|
import type { PropsType } from './StoryListItem';
|
||||||
import { StoryListItem } from './StoryListItem';
|
import { StoryListItem } from './StoryListItem';
|
||||||
|
@ -18,72 +18,41 @@ const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/StoryListItem',
|
title: 'Components/StoryListItem',
|
||||||
};
|
component: StoryListItem,
|
||||||
|
argTypes: {
|
||||||
function getDefaultProps(): PropsType {
|
i18n: {
|
||||||
return {
|
defaultValue: i18n,
|
||||||
i18n,
|
|
||||||
onClick: action('onClick'),
|
|
||||||
onGoToConversation: action('onGoToConversation'),
|
|
||||||
onHideStory: action('onHideStory'),
|
|
||||||
queueStoryDownload: action('queueStoryDownload'),
|
|
||||||
story: {
|
|
||||||
messageId: '123',
|
|
||||||
sender: getDefaultConversation(),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
},
|
||||||
};
|
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 => (
|
const Template: Story<PropsType> = args => <StoryListItem {...args} />;
|
||||||
<StoryListItem
|
|
||||||
{...getDefaultProps()}
|
|
||||||
story={{
|
|
||||||
messageId: '123',
|
|
||||||
sender: getDefaultConversation({ isMe: true }),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const MyStoryMany = (): JSX.Element => (
|
export const SomeonesStory = Template.bind({});
|
||||||
<StoryListItem
|
SomeonesStory.args = {
|
||||||
{...getDefaultProps()}
|
group: getDefaultConversation({ title: 'Sports Group' }),
|
||||||
story={{
|
story: {
|
||||||
attachment: fakeAttachment({
|
attachment: fakeAttachment({
|
||||||
thumbnail: fakeThumbnail(
|
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
|
||||||
'/fixtures/nathan-anderson-316188-unsplash.jpg'
|
}),
|
||||||
),
|
hasReplies: true,
|
||||||
}),
|
isUnread: true,
|
||||||
messageId: '123',
|
messageId: '123',
|
||||||
sender: getDefaultConversation({ isMe: true }),
|
sender: getDefaultConversation(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}}
|
},
|
||||||
hasMultiple
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
MyStoryMany.story = {
|
|
||||||
name: 'My Story (many)',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = {
|
SomeonesStory.story = {
|
||||||
name: "Someone's story",
|
name: "Someone's story",
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,9 +3,8 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
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 { Avatar, AvatarSize, AvatarStoryRing } from './Avatar';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { ContextMenuPopper } from './ContextMenu';
|
import { ContextMenuPopper } from './ContextMenu';
|
||||||
|
@ -13,53 +12,7 @@ import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
import { StoryImage } from './StoryImage';
|
import { StoryImage } from './StoryImage';
|
||||||
import { getAvatarColor } from '../types/Colors';
|
import { getAvatarColor } from '../types/Colors';
|
||||||
|
|
||||||
export type ConversationStoryType = {
|
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
||||||
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'
|
|
||||||
> & {
|
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onClick: () => unknown;
|
onClick: () => unknown;
|
||||||
onGoToConversation: (conversationId: string) => unknown;
|
onGoToConversation: (conversationId: string) => unknown;
|
||||||
|
@ -70,7 +23,6 @@ export type PropsType = Pick<
|
||||||
|
|
||||||
export const StoryListItem = ({
|
export const StoryListItem = ({
|
||||||
group,
|
group,
|
||||||
hasMultiple,
|
|
||||||
i18n,
|
i18n,
|
||||||
isHidden,
|
isHidden,
|
||||||
onClick,
|
onClick,
|
||||||
|
@ -129,9 +81,7 @@ export const StoryListItem = ({
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
if (!isMe) {
|
setIsShowingContextMenu(true);
|
||||||
setIsShowingContextMenu(true);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
ref={setReferenceElement}
|
ref={setReferenceElement}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
@ -153,49 +103,25 @@ export const StoryListItem = ({
|
||||||
title={title}
|
title={title}
|
||||||
/>
|
/>
|
||||||
<div className="StoryListItem__info">
|
<div className="StoryListItem__info">
|
||||||
{isMe ? (
|
<>
|
||||||
<>
|
<div className="StoryListItem__info--title">
|
||||||
<div className="StoryListItem__info--title">
|
{group
|
||||||
{i18n('Stories__mine')}
|
? i18n('Stories__from-to-group', {
|
||||||
</div>
|
name: title,
|
||||||
{!attachment && (
|
group: group.title,
|
||||||
<div className="StoryListItem__info--timestamp">
|
})
|
||||||
{i18n('Stories__add')}
|
: title}
|
||||||
</div>
|
</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}
|
{repliesElement}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="StoryListItem__previews">
|
||||||
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" />}
|
|
||||||
<StoryImage
|
<StoryImage
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|
|
@ -16,14 +16,14 @@ import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
||||||
import type { ReplyStateType } from '../types/Stories';
|
import type { ReplyStateType, StoryViewType } from '../types/Stories';
|
||||||
import type { StoryViewType } from './StoryListItem';
|
|
||||||
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { ContextMenuPopper } from './ContextMenu';
|
import { ContextMenuPopper } from './ContextMenu';
|
||||||
import { Intl } from './Intl';
|
import { Intl } from './Intl';
|
||||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
import { StoryImage } from './StoryImage';
|
import { StoryImage } from './StoryImage';
|
||||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||||
import { Theme } from '../util/theme';
|
import { Theme } from '../util/theme';
|
||||||
|
@ -56,8 +56,8 @@ export type PropsType = {
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
onGoToConversation: (conversationId: string) => unknown;
|
onGoToConversation: (conversationId: string) => unknown;
|
||||||
onHideStory: (conversationId: string) => unknown;
|
onHideStory: (conversationId: string) => unknown;
|
||||||
onNextUserStories: () => unknown;
|
onNextUserStories?: () => unknown;
|
||||||
onPrevUserStories: () => unknown;
|
onPrevUserStories?: () => unknown;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onSetSkinTone: (tone: number) => unknown;
|
||||||
onTextTooLong: () => unknown;
|
onTextTooLong: () => unknown;
|
||||||
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
||||||
|
@ -76,7 +76,6 @@ export type PropsType = {
|
||||||
skinTone?: number;
|
skinTone?: number;
|
||||||
stories: Array<StoryViewType>;
|
stories: Array<StoryViewType>;
|
||||||
toggleHasAllStoriesMuted: () => unknown;
|
toggleHasAllStoriesMuted: () => unknown;
|
||||||
views?: Array<string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAPTION_BUFFER = 20;
|
const CAPTION_BUFFER = 20;
|
||||||
|
@ -116,7 +115,6 @@ export const StoryViewer = ({
|
||||||
skinTone,
|
skinTone,
|
||||||
stories,
|
stories,
|
||||||
toggleHasAllStoriesMuted,
|
toggleHasAllStoriesMuted,
|
||||||
views,
|
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||||
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
||||||
|
@ -128,7 +126,8 @@ export const StoryViewer = ({
|
||||||
|
|
||||||
const visibleStory = stories[currentStoryIndex];
|
const visibleStory = stories[currentStoryIndex];
|
||||||
|
|
||||||
const { attachment, canReply, isHidden, messageId, timestamp } = visibleStory;
|
const { attachment, canReply, isHidden, messageId, sendState, timestamp } =
|
||||||
|
visibleStory;
|
||||||
const {
|
const {
|
||||||
acceptedMessageRequest,
|
acceptedMessageRequest,
|
||||||
avatarPath,
|
avatarPath,
|
||||||
|
@ -202,7 +201,7 @@ export const StoryViewer = ({
|
||||||
setCurrentStoryIndex(currentStoryIndex + 1);
|
setCurrentStoryIndex(currentStoryIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
setCurrentStoryIndex(0);
|
setCurrentStoryIndex(0);
|
||||||
onNextUserStories();
|
onNextUserStories?.();
|
||||||
}
|
}
|
||||||
}, [currentStoryIndex, onNextUserStories, stories.length]);
|
}, [currentStoryIndex, onNextUserStories, stories.length]);
|
||||||
|
|
||||||
|
@ -210,7 +209,7 @@ export const StoryViewer = ({
|
||||||
// for the prior user's stories.
|
// for the prior user's stories.
|
||||||
const showPrevStory = useCallback(() => {
|
const showPrevStory = useCallback(() => {
|
||||||
if (currentStoryIndex === 0) {
|
if (currentStoryIndex === 0) {
|
||||||
onPrevUserStories();
|
onPrevUserStories?.();
|
||||||
} else {
|
} else {
|
||||||
setCurrentStoryIndex(currentStoryIndex - 1);
|
setCurrentStoryIndex(currentStoryIndex - 1);
|
||||||
}
|
}
|
||||||
|
@ -378,9 +377,13 @@ export const StoryViewer = ({
|
||||||
|
|
||||||
const replies =
|
const replies =
|
||||||
replyState && replyState.messageId === messageId ? replyState.replies : [];
|
replyState && replyState.messageId === messageId ? replyState.replies : [];
|
||||||
|
const views = sendState
|
||||||
const viewCount = (views || []).length;
|
? sendState.filter(({ status }) => status === SendStatus.Viewed)
|
||||||
|
: [];
|
||||||
const replyCount = replies.length;
|
const replyCount = replies.length;
|
||||||
|
const viewCount = views.length;
|
||||||
|
|
||||||
|
const shouldShowContextMenu = !sendState;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||||
|
@ -390,18 +393,20 @@ export const StoryViewer = ({
|
||||||
style={{ background: getStoryBackground(attachment) }}
|
style={{ background: getStoryBackground(attachment) }}
|
||||||
/>
|
/>
|
||||||
<div className="StoryViewer__content">
|
<div className="StoryViewer__content">
|
||||||
<button
|
{onPrevUserStories && (
|
||||||
aria-label={i18n('back')}
|
<button
|
||||||
className={classNames(
|
aria-label={i18n('back')}
|
||||||
'StoryViewer__arrow StoryViewer__arrow--left',
|
className={classNames(
|
||||||
{
|
'StoryViewer__arrow StoryViewer__arrow--left',
|
||||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
{
|
||||||
}
|
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
||||||
)}
|
}
|
||||||
onClick={showPrevStory}
|
)}
|
||||||
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
onClick={showPrevStory}
|
||||||
type="button"
|
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
||||||
/>
|
type="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="StoryViewer__protection StoryViewer__protection--top" />
|
<div className="StoryViewer__protection StoryViewer__protection--top" />
|
||||||
<div className="StoryViewer__container">
|
<div className="StoryViewer__container">
|
||||||
<StoryImage
|
<StoryImage
|
||||||
|
@ -532,13 +537,15 @@ export const StoryViewer = ({
|
||||||
onClick={toggleHasAllStoriesMuted}
|
onClick={toggleHasAllStoriesMuted}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
<button
|
{shouldShowContextMenu && (
|
||||||
aria-label={i18n('MyStories__more')}
|
<button
|
||||||
className="StoryViewer__more"
|
aria-label={i18n('MyStories__more')}
|
||||||
onClick={() => setIsShowingContextMenu(true)}
|
className="StoryViewer__more"
|
||||||
ref={setReferenceElement}
|
onClick={() => setIsShowingContextMenu(true)}
|
||||||
type="button"
|
ref={setReferenceElement}
|
||||||
/>
|
type="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="StoryViewer__progress">
|
<div className="StoryViewer__progress">
|
||||||
|
@ -619,18 +626,20 @@ export const StoryViewer = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{onNextUserStories && (
|
||||||
aria-label={i18n('forward')}
|
<button
|
||||||
className={classNames(
|
aria-label={i18n('forward')}
|
||||||
'StoryViewer__arrow StoryViewer__arrow--right',
|
className={classNames(
|
||||||
{
|
'StoryViewer__arrow StoryViewer__arrow--right',
|
||||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
{
|
||||||
}
|
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
||||||
)}
|
}
|
||||||
onClick={showNextStory}
|
)}
|
||||||
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
onClick={showNextStory}
|
||||||
type="button"
|
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
||||||
/>
|
type="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
|
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('close')}
|
aria-label={i18n('close')}
|
||||||
|
@ -696,7 +705,7 @@ export const StoryViewer = ({
|
||||||
replies={replies}
|
replies={replies}
|
||||||
skinTone={skinTone}
|
skinTone={skinTone}
|
||||||
storyPreviewAttachment={attachment}
|
storyPreviewAttachment={attachment}
|
||||||
views={[]}
|
views={views}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasConfirmHideStory && (
|
{hasConfirmHideStory && (
|
||||||
|
|
|
@ -8,6 +8,7 @@ import type { PropsType } from './StoryViewsNRepliesModal';
|
||||||
import * as durations from '../util/durations';
|
import * as durations from '../util/durations';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import { IMAGE_JPEG } from '../types/MIME';
|
import { IMAGE_JPEG } from '../types/MIME';
|
||||||
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||||
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
|
@ -56,24 +57,29 @@ function getViewsAndReplies() {
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
{
|
{
|
||||||
...p1,
|
recipient: p1,
|
||||||
timestamp: Date.now() - 20 * durations.MINUTE,
|
status: SendStatus.Viewed,
|
||||||
|
updatedAt: Date.now() - 20 * durations.MINUTE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...p2,
|
recipient: p2,
|
||||||
timestamp: Date.now() - 25 * durations.MINUTE,
|
status: SendStatus.Viewed,
|
||||||
|
updatedAt: Date.now() - 25 * durations.MINUTE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...p3,
|
recipient: p3,
|
||||||
timestamp: Date.now() - 15 * durations.MINUTE,
|
status: SendStatus.Viewed,
|
||||||
|
updatedAt: Date.now() - 15 * durations.MINUTE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...p4,
|
recipient: p4,
|
||||||
timestamp: Date.now() - 5 * durations.MINUTE,
|
status: SendStatus.Viewed,
|
||||||
|
updatedAt: Date.now() - 5 * durations.MINUTE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...p5,
|
recipient: p5,
|
||||||
timestamp: Date.now() - 30 * durations.MINUTE,
|
status: SendStatus.Viewed,
|
||||||
|
updatedAt: Date.now() - 30 * durations.MINUTE,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -6,13 +6,11 @@ import classNames from 'classnames';
|
||||||
import { usePopper } from 'react-popper';
|
import { usePopper } from 'react-popper';
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
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 { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
import type { InputApi } from './CompositionInput';
|
import type { InputApi } from './CompositionInput';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
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 { Avatar, AvatarSize } from './Avatar';
|
||||||
import { CompositionInput } from './CompositionInput';
|
import { CompositionInput } from './CompositionInput';
|
||||||
import { ContactName } from './conversation/ContactName';
|
import { ContactName } from './conversation/ContactName';
|
||||||
|
@ -29,21 +27,6 @@ import { ThemeType } from '../types/Util';
|
||||||
import { getAvatarColor } from '../types/Colors';
|
import { getAvatarColor } from '../types/Colors';
|
||||||
import { getStoryReplyText } from '../util/getStoryReplyText';
|
import { getStoryReplyText } from '../util/getStoryReplyText';
|
||||||
|
|
||||||
type ViewType = Pick<
|
|
||||||
ConversationType,
|
|
||||||
| 'acceptedMessageRequest'
|
|
||||||
| 'avatarPath'
|
|
||||||
| 'color'
|
|
||||||
| 'isMe'
|
|
||||||
| 'name'
|
|
||||||
| 'profileName'
|
|
||||||
| 'sharedGroupNames'
|
|
||||||
| 'title'
|
|
||||||
> & {
|
|
||||||
contactNameColor?: ContactNameColorType;
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
Replies = 'Replies',
|
Replies = 'Replies',
|
||||||
Views = 'Views',
|
Views = 'Views',
|
||||||
|
@ -71,7 +54,7 @@ export type PropsType = {
|
||||||
replies: Array<ReplyType>;
|
replies: Array<ReplyType>;
|
||||||
skinTone?: number;
|
skinTone?: number;
|
||||||
storyPreviewAttachment?: AttachmentType;
|
storyPreviewAttachment?: AttachmentType;
|
||||||
views: Array<ViewType>;
|
views: Array<StorySendStateType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoryViewsNRepliesModal = ({
|
export const StoryViewsNRepliesModal = ({
|
||||||
|
@ -328,34 +311,33 @@ export const StoryViewsNRepliesModal = ({
|
||||||
const viewsElement = views.length ? (
|
const viewsElement = views.length ? (
|
||||||
<div className="StoryViewsNRepliesModal__views">
|
<div className="StoryViewsNRepliesModal__views">
|
||||||
{views.map(view => (
|
{views.map(view => (
|
||||||
<div className="StoryViewsNRepliesModal__view" key={view.timestamp}>
|
<div className="StoryViewsNRepliesModal__view" key={view.recipient.id}>
|
||||||
<div>
|
<div>
|
||||||
<Avatar
|
<Avatar
|
||||||
acceptedMessageRequest={view.acceptedMessageRequest}
|
acceptedMessageRequest={view.recipient.acceptedMessageRequest}
|
||||||
avatarPath={view.avatarPath}
|
avatarPath={view.recipient.avatarPath}
|
||||||
badge={undefined}
|
badge={undefined}
|
||||||
color={getAvatarColor(view.color)}
|
color={getAvatarColor(view.recipient.color)}
|
||||||
conversationType="direct"
|
conversationType="direct"
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isMe={Boolean(view.isMe)}
|
isMe={Boolean(view.recipient.isMe)}
|
||||||
name={view.name}
|
name={view.recipient.name}
|
||||||
profileName={view.profileName}
|
profileName={view.recipient.profileName}
|
||||||
sharedGroupNames={view.sharedGroupNames || []}
|
sharedGroupNames={view.recipient.sharedGroupNames || []}
|
||||||
size={AvatarSize.TWENTY_EIGHT}
|
size={AvatarSize.TWENTY_EIGHT}
|
||||||
title={view.title}
|
title={view.recipient.title}
|
||||||
/>
|
/>
|
||||||
<span className="StoryViewsNRepliesModal__view--name">
|
<span className="StoryViewsNRepliesModal__view--name">
|
||||||
<ContactName
|
<ContactName title={view.recipient.title} />
|
||||||
contactNameColor={view.contactNameColor}
|
|
||||||
title={view.title}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MessageTimestamp
|
{view.updatedAt && (
|
||||||
i18n={i18n}
|
<MessageTimestamp
|
||||||
module="StoryViewsNRepliesModal__view--timestamp"
|
i18n={i18n}
|
||||||
timestamp={view.timestamp}
|
module="StoryViewsNRepliesModal__view--timestamp"
|
||||||
/>
|
timestamp={view.updatedAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -133,12 +133,92 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
|
||||||
return receipts;
|
return receipts;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onReceipt(receipt: MessageReceiptModel): Promise<void> {
|
private async updateMessageSendState(
|
||||||
const type = receipt.get('type');
|
receipt: MessageReceiptModel,
|
||||||
|
message: MessageModel
|
||||||
|
): Promise<void> {
|
||||||
const messageSentAt = receipt.get('messageSentAt');
|
const messageSentAt = receipt.get('messageSentAt');
|
||||||
const receiptTimestamp = receipt.get('receiptTimestamp');
|
const receiptTimestamp = receipt.get('receiptTimestamp');
|
||||||
const sourceConversationId = receipt.get('sourceConversationId');
|
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 sourceUuid = receipt.get('sourceUuid');
|
||||||
|
const type = receipt.get('type');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||||
|
@ -150,86 +230,36 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
|
||||||
sourceUuid,
|
sourceUuid,
|
||||||
messages
|
messages
|
||||||
);
|
);
|
||||||
if (!message) {
|
|
||||||
log.info(
|
if (message) {
|
||||||
'No message for receipt',
|
await this.updateMessageSendState(receipt, message);
|
||||||
type,
|
} else {
|
||||||
sourceConversationId,
|
// We didn't find any messages but maybe it's a story sent message
|
||||||
messageSentAt
|
const targetMessages = messages.filter(
|
||||||
|
item =>
|
||||||
|
item.storyDistributionListId &&
|
||||||
|
item.sendStateByConversationId &&
|
||||||
|
!item.deletedForEveryone &&
|
||||||
|
Boolean(item.sendStateByConversationId[sourceConversationId])
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldSendStateByConversationId =
|
// Nope, no target message was found
|
||||||
message.get('sendStateByConversationId') || {};
|
if (!targetMessages.length) {
|
||||||
const oldSendState = getOwn(
|
log.info(
|
||||||
oldSendStateByConversationId,
|
'No message for receipt',
|
||||||
sourceConversationId
|
type,
|
||||||
) ?? { status: SendStatus.Sent, updatedAt: undefined };
|
sourceConversationId,
|
||||||
|
messageSentAt
|
||||||
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}`
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
targetMessages.map(msg => {
|
||||||
|
const model = window.MessageController.register(msg.id, msg);
|
||||||
|
return this.updateMessageSendState(receipt, model);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.remove(receipt);
|
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.
|
* The timestamp may be undefined if reading old data, which did not store a timestamp.
|
||||||
*/
|
*/
|
||||||
export type SendState = Readonly<{
|
export type SendState = Readonly<{
|
||||||
|
isAllowedToReplyToStory?: boolean;
|
||||||
status:
|
status:
|
||||||
| SendStatus.Pending
|
| SendStatus.Pending
|
||||||
| SendStatus.Failed
|
| SendStatus.Failed
|
||||||
|
|
|
@ -120,6 +120,7 @@ export type MessageAttributesType = {
|
||||||
bodyAttachment?: AttachmentType;
|
bodyAttachment?: AttachmentType;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: BodyRangesType;
|
||||||
callHistoryDetails?: CallHistoryDetailsFromDiskType;
|
callHistoryDetails?: CallHistoryDetailsFromDiskType;
|
||||||
|
canReplyToStory?: boolean;
|
||||||
changedId?: string;
|
changedId?: string;
|
||||||
dataMessage?: Uint8Array | null;
|
dataMessage?: Uint8Array | null;
|
||||||
decrypted_at?: number;
|
decrypted_at?: number;
|
||||||
|
@ -147,6 +148,7 @@ export type MessageAttributesType = {
|
||||||
requiredProtocolVersion?: number;
|
requiredProtocolVersion?: number;
|
||||||
retryOptions?: RetryOptions;
|
retryOptions?: RetryOptions;
|
||||||
sourceDevice?: number;
|
sourceDevice?: number;
|
||||||
|
storyDistributionListId?: string;
|
||||||
storyId?: string;
|
storyId?: string;
|
||||||
storyReplyContext?: StoryReplyContextType;
|
storyReplyContext?: StoryReplyContextType;
|
||||||
supportedVersionAtReceive?: unknown;
|
supportedVersionAtReceive?: unknown;
|
||||||
|
|
|
@ -107,9 +107,7 @@ import {
|
||||||
conversationJobQueue,
|
conversationJobQueue,
|
||||||
conversationQueueJobEnum,
|
conversationQueueJobEnum,
|
||||||
} from '../jobs/conversationJobQueue';
|
} from '../jobs/conversationJobQueue';
|
||||||
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
|
||||||
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
||||||
import { DeleteModel } from '../messageModifiers/Deletes';
|
|
||||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||||
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
||||||
import { getProfile } from '../util/getProfile';
|
import { getProfile } from '../util/getProfile';
|
||||||
|
@ -124,6 +122,8 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||||
import { TimelineMessageLoadingState } from '../util/timelineUtil';
|
import { TimelineMessageLoadingState } from '../util/timelineUtil';
|
||||||
import { SeenStatus } from '../MessageSeenStatus';
|
import { SeenStatus } from '../MessageSeenStatus';
|
||||||
import { getConversationIdForLogging } from '../util/idForLogging';
|
import { getConversationIdForLogging } from '../util/idForLogging';
|
||||||
|
import { getSendTarget } from '../util/getSendTarget';
|
||||||
|
import { getRecipients } from '../util/getRecipients';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
@ -148,7 +148,6 @@ const {
|
||||||
getNewerMessagesByConversation,
|
getNewerMessagesByConversation,
|
||||||
} = window.Signal.Data;
|
} = window.Signal.Data;
|
||||||
|
|
||||||
const THREE_HOURS = durations.HOUR * 3;
|
|
||||||
const FIVE_MINUTES = durations.MINUTE * 5;
|
const FIVE_MINUTES = durations.MINUTE * 5;
|
||||||
|
|
||||||
const JOB_REPORTING_THRESHOLD_MS = 25;
|
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
|
// 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.
|
// just one bit of data. If we have a UUID, we'll send using it.
|
||||||
getSendTarget(): string | undefined {
|
getSendTarget(): string | undefined {
|
||||||
return this.get('uuid') || this.get('e164');
|
return getSendTarget(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
getContactCollection(): Backbone.Collection<ConversationModel> {
|
getContactCollection(): Backbone.Collection<ConversationModel> {
|
||||||
|
@ -3615,32 +3614,10 @@ export class ConversationModel extends window.Backbone
|
||||||
includePendingMembers?: boolean;
|
includePendingMembers?: boolean;
|
||||||
extraConversationsForSend?: Array<string>;
|
extraConversationsForSend?: Array<string>;
|
||||||
} = {}): Array<string> {
|
} = {}): Array<string> {
|
||||||
if (isDirectConversation(this.attributes)) {
|
return getRecipients(this.attributes, {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
includePendingMembers,
|
||||||
return [this.getSendTarget()!];
|
extraConversationsForSend,
|
||||||
}
|
});
|
||||||
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Members is all people in the group
|
// 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));
|
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(
|
async getQuoteAttachment(
|
||||||
attachments?: Array<WhatIsThis>,
|
attachments?: Array<WhatIsThis>,
|
||||||
preview?: Array<WhatIsThis>,
|
preview?: Array<WhatIsThis>,
|
||||||
|
@ -3848,65 +3807,6 @@ export class ConversationModel extends window.Backbone
|
||||||
window.reduxActions.stickers.useSticker(packId, stickerId);
|
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> {
|
async sendProfileKeyUpdate(): Promise<void> {
|
||||||
if (isMe(this.attributes)) {
|
if (isMe(this.attributes)) {
|
||||||
return;
|
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,
|
mergeContactRecord,
|
||||||
mergeGroupV1Record,
|
mergeGroupV1Record,
|
||||||
mergeGroupV2Record,
|
mergeGroupV2Record,
|
||||||
|
mergeStoryDistributionListRecord,
|
||||||
toAccountRecord,
|
toAccountRecord,
|
||||||
toContactRecord,
|
toContactRecord,
|
||||||
toGroupV1Record,
|
toGroupV1Record,
|
||||||
toGroupV2Record,
|
toGroupV2Record,
|
||||||
|
toStoryDistributionListRecord,
|
||||||
} from './storageRecordOps';
|
} from './storageRecordOps';
|
||||||
import type { MergeResultType } from './storageRecordOps';
|
import type { MergeResultType } from './storageRecordOps';
|
||||||
import { MAX_READ_KEYS } from './storageConstants';
|
import { MAX_READ_KEYS } from './storageConstants';
|
||||||
|
@ -67,6 +69,7 @@ const validRecordTypes = new Set([
|
||||||
2, // GROUPV1
|
2, // GROUPV1
|
||||||
3, // GROUPV2
|
3, // GROUPV2
|
||||||
4, // ACCOUNT
|
4, // ACCOUNT
|
||||||
|
5, // STORY_DISTRIBUTION_LIST
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const backOff = new BackOff([
|
const backOff = new BackOff([
|
||||||
|
@ -99,10 +102,10 @@ function redactExtendedStorageID({
|
||||||
return redactStorageID(storageID, storageVersion);
|
return redactStorageID(storageID, storageVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptRecord(
|
function encryptRecord(
|
||||||
storageID: string | undefined,
|
storageID: string | undefined,
|
||||||
storageRecord: Proto.IStorageRecord
|
storageRecord: Proto.IStorageRecord
|
||||||
): Promise<Proto.StorageItem> {
|
): Proto.StorageItem {
|
||||||
const storageItem = new Proto.StorageItem();
|
const storageItem = new Proto.StorageItem();
|
||||||
|
|
||||||
const storageKeyBuffer = storageID
|
const storageKeyBuffer = storageID
|
||||||
|
@ -161,11 +164,80 @@ async function generateManifest(
|
||||||
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
||||||
const newItems: Set<Proto.IStorageItem> = 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();
|
const conversations = window.getConversations();
|
||||||
for (let i = 0; i < conversations.length; i += 1) {
|
for (let i = 0; i < conversations.length; i += 1) {
|
||||||
const conversation = conversations.models[i];
|
const conversation = conversations.models[i];
|
||||||
const identifier = new Proto.ManifestRecord.Identifier();
|
|
||||||
|
|
||||||
|
let identifierType;
|
||||||
let storageRecord;
|
let storageRecord;
|
||||||
|
|
||||||
const conversationType = typeofConversation(conversation.attributes);
|
const conversationType = typeofConversation(conversation.attributes);
|
||||||
|
@ -173,7 +245,7 @@ async function generateManifest(
|
||||||
storageRecord = new Proto.StorageRecord();
|
storageRecord = new Proto.StorageRecord();
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
storageRecord.account = await toAccountRecord(conversation);
|
storageRecord.account = await toAccountRecord(conversation);
|
||||||
identifier.type = ITEM_TYPE.ACCOUNT;
|
identifierType = ITEM_TYPE.ACCOUNT;
|
||||||
} else if (conversationType === ConversationTypes.Direct) {
|
} else if (conversationType === ConversationTypes.Direct) {
|
||||||
// Contacts must have UUID
|
// Contacts must have UUID
|
||||||
if (!conversation.get('uuid')) {
|
if (!conversation.get('uuid')) {
|
||||||
|
@ -207,17 +279,15 @@ async function generateManifest(
|
||||||
storageRecord = new Proto.StorageRecord();
|
storageRecord = new Proto.StorageRecord();
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
storageRecord.contact = await toContactRecord(conversation);
|
storageRecord.contact = await toContactRecord(conversation);
|
||||||
identifier.type = ITEM_TYPE.CONTACT;
|
identifierType = ITEM_TYPE.CONTACT;
|
||||||
} else if (conversationType === ConversationTypes.GroupV2) {
|
} else if (conversationType === ConversationTypes.GroupV2) {
|
||||||
storageRecord = new Proto.StorageRecord();
|
storageRecord = new Proto.StorageRecord();
|
||||||
// eslint-disable-next-line no-await-in-loop
|
storageRecord.groupV2 = toGroupV2Record(conversation);
|
||||||
storageRecord.groupV2 = await toGroupV2Record(conversation);
|
identifierType = ITEM_TYPE.GROUPV2;
|
||||||
identifier.type = ITEM_TYPE.GROUPV2;
|
|
||||||
} else if (conversationType === ConversationTypes.GroupV1) {
|
} else if (conversationType === ConversationTypes.GroupV1) {
|
||||||
storageRecord = new Proto.StorageRecord();
|
storageRecord = new Proto.StorageRecord();
|
||||||
// eslint-disable-next-line no-await-in-loop
|
storageRecord.groupV1 = toGroupV1Record(conversation);
|
||||||
storageRecord.groupV1 = await toGroupV1Record(conversation);
|
identifierType = ITEM_TYPE.GROUPV1;
|
||||||
identifier.type = ITEM_TYPE.GROUPV1;
|
|
||||||
} else {
|
} else {
|
||||||
log.warn(
|
log.warn(
|
||||||
`storageService.upload(${version}): ` +
|
`storageService.upload(${version}): ` +
|
||||||
|
@ -225,59 +295,20 @@ async function generateManifest(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storageRecord) {
|
if (!storageRecord || !identifierType) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStorageID = conversation.get('storageID');
|
const { isNewItem, storageID } = processStorageRecord({
|
||||||
const currentStorageVersion = conversation.get('storageVersion');
|
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) {
|
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(() => {
|
postUploadUpdateFunctions.push(() => {
|
||||||
conversation.set({
|
conversation.set({
|
||||||
needsStorageServiceSync: false,
|
needsStorageServiceSync: false,
|
||||||
|
@ -287,10 +318,36 @@ async function generateManifest(
|
||||||
updateConversation(conversation.attributes);
|
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> = (
|
const unknownRecordsArray: ReadonlyArray<UnknownRecord> = (
|
||||||
window.storage.get('storage-service-unknown-records') || []
|
window.storage.get('storage-service-unknown-records') || []
|
||||||
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
|
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
|
||||||
|
@ -785,6 +842,15 @@ async function mergeRecord(
|
||||||
storageVersion,
|
storageVersion,
|
||||||
storageRecord.account
|
storageRecord.account
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
itemType === ITEM_TYPE.STORY_DISTRIBUTION_LIST &&
|
||||||
|
storageRecord.storyDistributionList
|
||||||
|
) {
|
||||||
|
mergeResult = await mergeStoryDistributionListRecord(
|
||||||
|
storageID,
|
||||||
|
storageVersion,
|
||||||
|
storageRecord.storyDistributionList
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
isUnsupported = true;
|
isUnsupported = true;
|
||||||
log.warn(
|
log.warn(
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
import { isEqual, isNumber } from 'lodash';
|
import { isEqual, isNumber } from 'lodash';
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
|
|
||||||
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
|
import { bytesToUuid, deriveMasterKeyFromGroupV1 } from '../Crypto';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import {
|
import {
|
||||||
deriveGroupFields,
|
deriveGroupFields,
|
||||||
|
@ -39,6 +39,10 @@ import { isValidUuid, UUID, UUIDKind } from '../types/UUID';
|
||||||
import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji';
|
import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import * as log from '../logging/log';
|
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 =
|
type RecordClass =
|
||||||
| Proto.IAccountRecord
|
| Proto.IAccountRecord
|
||||||
|
@ -374,6 +378,35 @@ export function toGroupV2Record(
|
||||||
return groupV2Record;
|
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;
|
type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV1Record;
|
||||||
|
|
||||||
function applyMessageRequestState(
|
function applyMessageRequestState(
|
||||||
|
@ -1187,3 +1220,127 @@ export async function mergeAccountRecord(
|
||||||
details,
|
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(
|
export function getStoryDataFromMessageAttributes(
|
||||||
message: MessageAttributesType
|
message: MessageAttributesType
|
||||||
): StoryDataType | undefined {
|
): StoryDataType | undefined {
|
||||||
const { attachments } = message;
|
const { attachments, deletedForEveryone } = message;
|
||||||
const unresolvedAttachment = attachments ? attachments[0] : undefined;
|
const unresolvedAttachment = attachments ? attachments[0] : undefined;
|
||||||
if (!unresolvedAttachment) {
|
if (!unresolvedAttachment && !deletedForEveryone) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`getStoryDataFromMessageAttributes: ${message.id} does not have an attachment`
|
`getStoryDataFromMessageAttributes: ${message.id} does not have an attachment`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [attachment] = unresolvedAttachment.path
|
const [attachment] =
|
||||||
? getAttachmentsForMessage(message)
|
unresolvedAttachment && unresolvedAttachment.path
|
||||||
: [unresolvedAttachment];
|
? getAttachmentsForMessage(message)
|
||||||
|
: [unresolvedAttachment];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachment,
|
attachment,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
...pick(message, [
|
...pick(message, [
|
||||||
|
'canReplyToStory',
|
||||||
'conversationId',
|
'conversationId',
|
||||||
'deletedForEveryone',
|
'deletedForEveryone',
|
||||||
'reactions',
|
'reactions',
|
||||||
|
@ -45,6 +47,7 @@ export function getStoryDataFromMessageAttributes(
|
||||||
'sendStateByConversationId',
|
'sendStateByConversationId',
|
||||||
'source',
|
'source',
|
||||||
'sourceUuid',
|
'sourceUuid',
|
||||||
|
'storyDistributionListId',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
'type',
|
'type',
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -28,7 +28,6 @@ import { SystemTraySettingsCheckboxes } from './components/conversation/SystemTr
|
||||||
import { createChatColorPicker } from './state/roots/createChatColorPicker';
|
import { createChatColorPicker } from './state/roots/createChatColorPicker';
|
||||||
import { createConversationDetails } from './state/roots/createConversationDetails';
|
import { createConversationDetails } from './state/roots/createConversationDetails';
|
||||||
import { createApp } from './state/roots/createApp';
|
import { createApp } from './state/roots/createApp';
|
||||||
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
|
|
||||||
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
||||||
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
||||||
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
||||||
|
@ -417,7 +416,6 @@ export const setup = (options: {
|
||||||
createApp,
|
createApp,
|
||||||
createChatColorPicker,
|
createChatColorPicker,
|
||||||
createConversationDetails,
|
createConversationDetails,
|
||||||
createForwardMessageModal,
|
|
||||||
createGroupLinkManagement,
|
createGroupLinkManagement,
|
||||||
createGroupV1MigrationModal,
|
createGroupV1MigrationModal,
|
||||||
createGroupV2JoinModal,
|
createGroupV2JoinModal,
|
||||||
|
|
|
@ -293,6 +293,7 @@ const dataInterface: ClientInterface = {
|
||||||
getStoryDistributionWithMembers,
|
getStoryDistributionWithMembers,
|
||||||
modifyStoryDistribution,
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
|
modifyStoryDistributionWithMembers,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
|
|
||||||
_getAllStoryReads,
|
_getAllStoryReads,
|
||||||
|
@ -1634,6 +1635,15 @@ async function modifyStoryDistributionMembers(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await channels.modifyStoryDistributionMembers(id, options);
|
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> {
|
async function deleteStoryDistribution(id: UUIDStringType): Promise<void> {
|
||||||
await channels.deleteStoryDistribution(id);
|
await channels.deleteStoryDistribution(id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,10 +233,15 @@ export type DeleteSentProtoRecipientOptionsType = Readonly<{
|
||||||
export type StoryDistributionType = Readonly<{
|
export type StoryDistributionType = Readonly<{
|
||||||
id: UUIDStringType;
|
id: UUIDStringType;
|
||||||
name: string;
|
name: string;
|
||||||
|
deletedAtTimestamp?: number;
|
||||||
avatarUrlPath: string;
|
allowsReplies: boolean;
|
||||||
avatarKey: Uint8Array;
|
isBlockList: boolean;
|
||||||
senderKeyInfo: SenderKeyInfoType | undefined;
|
senderKeyInfo: SenderKeyInfoType | undefined;
|
||||||
|
|
||||||
|
storageID: string;
|
||||||
|
storageVersion: number;
|
||||||
|
storageUnknownFields?: Uint8Array | null;
|
||||||
|
storageNeedsSync: boolean;
|
||||||
}>;
|
}>;
|
||||||
export type StoryDistributionMemberType = Readonly<{
|
export type StoryDistributionMemberType = Readonly<{
|
||||||
listId: UUIDStringType;
|
listId: UUIDStringType;
|
||||||
|
@ -559,7 +564,14 @@ export type DataInterface = {
|
||||||
): Promise<StoryDistributionWithMembersType | undefined>;
|
): Promise<StoryDistributionWithMembersType | undefined>;
|
||||||
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
|
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
|
||||||
modifyStoryDistributionMembers(
|
modifyStoryDistributionMembers(
|
||||||
id: string,
|
listId: string,
|
||||||
|
options: {
|
||||||
|
toAdd: Array<UUIDStringType>;
|
||||||
|
toRemove: Array<UUIDStringType>;
|
||||||
|
}
|
||||||
|
): Promise<void>;
|
||||||
|
modifyStoryDistributionWithMembers(
|
||||||
|
distribution: StoryDistributionType,
|
||||||
options: {
|
options: {
|
||||||
toAdd: Array<UUIDStringType>;
|
toAdd: Array<UUIDStringType>;
|
||||||
toRemove: Array<UUIDStringType>;
|
toRemove: Array<UUIDStringType>;
|
||||||
|
|
158
ts/sql/Server.ts
158
ts/sql/Server.ts
|
@ -287,6 +287,7 @@ const dataInterface: ServerInterface = {
|
||||||
getStoryDistributionWithMembers,
|
getStoryDistributionWithMembers,
|
||||||
modifyStoryDistribution,
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
|
modifyStoryDistributionWithMembers,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
|
|
||||||
_getAllStoryReads,
|
_getAllStoryReads,
|
||||||
|
@ -4026,8 +4027,19 @@ async function getAllBadgeImageFileLocalPaths(): Promise<Set<string>> {
|
||||||
|
|
||||||
type StoryDistributionForDatabase = Readonly<
|
type StoryDistributionForDatabase = Readonly<
|
||||||
{
|
{
|
||||||
|
allowsReplies: 0 | 1;
|
||||||
|
deletedAtTimestamp: number | null;
|
||||||
|
isBlockList: 0 | 1;
|
||||||
senderKeyInfoJson: string | null;
|
senderKeyInfoJson: string | null;
|
||||||
} & Omit<StoryDistributionType, 'senderKeyInfo'>
|
storageNeedsSync: 0 | 1;
|
||||||
|
} & Omit<
|
||||||
|
StoryDistributionType,
|
||||||
|
| 'allowsReplies'
|
||||||
|
| 'deletedAtTimestamp'
|
||||||
|
| 'isBlockList'
|
||||||
|
| 'senderKeyInfo'
|
||||||
|
| 'storageNeedsSync'
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function hydrateStoryDistribution(
|
function hydrateStoryDistribution(
|
||||||
|
@ -4035,9 +4047,14 @@ function hydrateStoryDistribution(
|
||||||
): StoryDistributionType {
|
): StoryDistributionType {
|
||||||
return {
|
return {
|
||||||
...omit(fromDatabase, 'senderKeyInfoJson'),
|
...omit(fromDatabase, 'senderKeyInfoJson'),
|
||||||
|
allowsReplies: Boolean(fromDatabase.allowsReplies),
|
||||||
|
deletedAtTimestamp: fromDatabase.deletedAtTimestamp || undefined,
|
||||||
|
isBlockList: Boolean(fromDatabase.isBlockList),
|
||||||
senderKeyInfo: fromDatabase.senderKeyInfoJson
|
senderKeyInfo: fromDatabase.senderKeyInfoJson
|
||||||
? JSON.parse(fromDatabase.senderKeyInfoJson)
|
? JSON.parse(fromDatabase.senderKeyInfoJson)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
storageNeedsSync: Boolean(fromDatabase.storageNeedsSync),
|
||||||
|
storageUnknownFields: fromDatabase.storageUnknownFields || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function freezeStoryDistribution(
|
function freezeStoryDistribution(
|
||||||
|
@ -4045,9 +4062,14 @@ function freezeStoryDistribution(
|
||||||
): StoryDistributionForDatabase {
|
): StoryDistributionForDatabase {
|
||||||
return {
|
return {
|
||||||
...omit(story, 'senderKeyInfo'),
|
...omit(story, 'senderKeyInfo'),
|
||||||
|
allowsReplies: story.allowsReplies ? 1 : 0,
|
||||||
|
deletedAtTimestamp: story.deletedAtTimestamp || null,
|
||||||
|
isBlockList: story.isBlockList ? 1 : 0,
|
||||||
senderKeyInfoJson: story.senderKeyInfo
|
senderKeyInfoJson: story.senderKeyInfo
|
||||||
? JSON.stringify(story.senderKeyInfo)
|
? JSON.stringify(story.senderKeyInfo)
|
||||||
: null,
|
: null,
|
||||||
|
storageNeedsSync: story.storageNeedsSync ? 1 : 0,
|
||||||
|
storageUnknownFields: story.storageUnknownFields || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4087,15 +4109,25 @@ async function createNewStoryDistribution(
|
||||||
INSERT INTO storyDistributions(
|
INSERT INTO storyDistributions(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
avatarUrlPath,
|
deletedAtTimestamp,
|
||||||
avatarKey,
|
allowsReplies,
|
||||||
senderKeyInfoJson
|
isBlockList,
|
||||||
|
senderKeyInfoJson,
|
||||||
|
storageID,
|
||||||
|
storageVersion,
|
||||||
|
storageUnknownFields,
|
||||||
|
storageNeedsSync
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$id,
|
$id,
|
||||||
$name,
|
$name,
|
||||||
$avatarUrlPath,
|
$deletedAtTimestamp,
|
||||||
$avatarKey,
|
$allowsReplies,
|
||||||
$senderKeyInfoJson
|
$isBlockList,
|
||||||
|
$senderKeyInfoJson,
|
||||||
|
$storageID,
|
||||||
|
$storageVersion,
|
||||||
|
$storageUnknownFields,
|
||||||
|
$storageNeedsSync
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
).run(payload);
|
).run(payload);
|
||||||
|
@ -4163,24 +4195,91 @@ async function getStoryDistributionWithMembers(
|
||||||
members: members.map(({ uuid }) => uuid),
|
members: members.map(({ uuid }) => uuid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function modifyStoryDistribution(
|
function modifyStoryDistributionSync(
|
||||||
distribution: StoryDistributionType
|
db: Database,
|
||||||
): Promise<void> {
|
payload: StoryDistributionForDatabase
|
||||||
const payload = freezeStoryDistribution(distribution);
|
): void {
|
||||||
const db = getInstance();
|
|
||||||
prepare(
|
prepare(
|
||||||
db,
|
db,
|
||||||
`
|
`
|
||||||
UPDATE storyDistributions
|
UPDATE storyDistributions
|
||||||
SET
|
SET
|
||||||
name = $name,
|
name = $name,
|
||||||
avatarUrlPath = $avatarUrlPath,
|
deletedAtTimestamp = $deletedAtTimestamp,
|
||||||
avatarKey = $avatarKey,
|
allowsReplies = $allowsReplies,
|
||||||
senderKeyInfoJson = $senderKeyInfoJson
|
isBlockList = $isBlockList,
|
||||||
|
senderKeyInfoJson = $senderKeyInfoJson,
|
||||||
|
storageID = $storageID,
|
||||||
|
storageVersion = $storageVersion,
|
||||||
|
storageUnknownFields = $storageUnknownFields,
|
||||||
|
storageNeedsSync = $storageNeedsSync
|
||||||
WHERE id = $id
|
WHERE id = $id
|
||||||
`
|
`
|
||||||
).run(payload);
|
).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(
|
async function modifyStoryDistributionMembers(
|
||||||
listId: string,
|
listId: string,
|
||||||
{
|
{
|
||||||
|
@ -4191,34 +4290,7 @@ async function modifyStoryDistributionMembers(
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const memberInsertStatement = prepare(
|
modifyStoryDistributionMembersSync(db, listId, { toAdd, toRemove });
|
||||||
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 deleteStoryDistribution(id: UUIDStringType): Promise<void> {
|
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 updateToSchemaVersion58 from './58-update-unread';
|
||||||
import updateToSchemaVersion59 from './59-unprocessed-received-at-counter-index';
|
import updateToSchemaVersion59 from './59-unprocessed-received-at-counter-index';
|
||||||
import updateToSchemaVersion60 from './60-update-expiring-index';
|
import updateToSchemaVersion60 from './60-update-expiring-index';
|
||||||
|
import updateToSchemaVersion61 from './61-distribution-list-storage';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -1935,6 +1936,7 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion58,
|
updateToSchemaVersion58,
|
||||||
updateToSchemaVersion59,
|
updateToSchemaVersion59,
|
||||||
updateToSchemaVersion60,
|
updateToSchemaVersion60,
|
||||||
|
updateToSchemaVersion61,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
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 search } from './ducks/search';
|
||||||
import { actions as stickers } from './ducks/stickers';
|
import { actions as stickers } from './ducks/stickers';
|
||||||
import { actions as stories } from './ducks/stories';
|
import { actions as stories } from './ducks/stories';
|
||||||
|
import { actions as storyDistributionLists } from './ducks/storyDistributionLists';
|
||||||
import { actions as updates } from './ducks/updates';
|
import { actions as updates } from './ducks/updates';
|
||||||
import { actions as user } from './ducks/user';
|
import { actions as user } from './ducks/user';
|
||||||
import type { ReduxActions } from './types';
|
import type { ReduxActions } from './types';
|
||||||
|
@ -44,6 +45,7 @@ export const actionCreators: ReduxActions = {
|
||||||
search,
|
search,
|
||||||
stickers,
|
stickers,
|
||||||
stories,
|
stories,
|
||||||
|
storyDistributionLists,
|
||||||
updates,
|
updates,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
|
@ -68,6 +70,7 @@ export const mapDispatchToProps = {
|
||||||
...search,
|
...search,
|
||||||
...stickers,
|
...stickers,
|
||||||
...stories,
|
...stories,
|
||||||
|
...storyDistributionLists,
|
||||||
...updates,
|
...updates,
|
||||||
...user,
|
...user,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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
|
// State
|
||||||
|
|
||||||
|
export type ForwardMessagePropsType = Omit<PropsForMessage, 'renderingContext'>;
|
||||||
|
|
||||||
export type GlobalModalsStateType = {
|
export type GlobalModalsStateType = {
|
||||||
readonly contactModalState?: ContactModalStateType;
|
readonly contactModalState?: ContactModalStateType;
|
||||||
|
readonly forwardMessageProps?: ForwardMessagePropsType;
|
||||||
readonly isProfileEditorVisible: boolean;
|
readonly isProfileEditorVisible: boolean;
|
||||||
readonly isWhatsNewVisible: boolean;
|
readonly isWhatsNewVisible: boolean;
|
||||||
readonly profileEditorHasError: boolean;
|
readonly profileEditorHasError: boolean;
|
||||||
|
@ -16,10 +26,12 @@ export type GlobalModalsStateType = {
|
||||||
|
|
||||||
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
|
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
|
||||||
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_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 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';
|
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
|
||||||
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
||||||
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
||||||
|
@ -66,6 +78,11 @@ export type ShowUserNotFoundModalActionType = {
|
||||||
payload: UserNotFoundModalStateType;
|
payload: UserNotFoundModalStateType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToggleForwardMessageModalActionType = {
|
||||||
|
type: typeof TOGGLE_FORWARD_MESSAGE_MODAL;
|
||||||
|
payload: ForwardMessagePropsType | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
type ToggleProfileEditorActionType = {
|
type ToggleProfileEditorActionType = {
|
||||||
type: typeof TOGGLE_PROFILE_EDITOR;
|
type: typeof TOGGLE_PROFILE_EDITOR;
|
||||||
};
|
};
|
||||||
|
@ -86,6 +103,7 @@ export type GlobalModalsActionType =
|
||||||
| ShowWhatsNewModalActionType
|
| ShowWhatsNewModalActionType
|
||||||
| HideUserNotFoundModalActionType
|
| HideUserNotFoundModalActionType
|
||||||
| ShowUserNotFoundModalActionType
|
| ShowUserNotFoundModalActionType
|
||||||
|
| ToggleForwardMessageModalActionType
|
||||||
| ToggleProfileEditorActionType
|
| ToggleProfileEditorActionType
|
||||||
| ToggleProfileEditorErrorActionType
|
| ToggleProfileEditorErrorActionType
|
||||||
| ToggleSafetyNumberModalActionType;
|
| ToggleSafetyNumberModalActionType;
|
||||||
|
@ -99,11 +117,15 @@ export const actions = {
|
||||||
showWhatsNewModal,
|
showWhatsNewModal,
|
||||||
hideUserNotFoundModal,
|
hideUserNotFoundModal,
|
||||||
showUserNotFoundModal,
|
showUserNotFoundModal,
|
||||||
|
toggleForwardMessageModal,
|
||||||
toggleProfileEditor,
|
toggleProfileEditor,
|
||||||
toggleProfileEditorHasError,
|
toggleProfileEditorHasError,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGlobalModalActions = (): typeof actions =>
|
||||||
|
useBoundActions(actions);
|
||||||
|
|
||||||
function hideContactModal(): HideContactModalActionType {
|
function hideContactModal(): HideContactModalActionType {
|
||||||
return {
|
return {
|
||||||
type: HIDE_CONTACT_MODAL,
|
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 {
|
function toggleProfileEditor(): ToggleProfileEditorActionType {
|
||||||
return { type: TOGGLE_PROFILE_EDITOR };
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ThunkAction } from 'redux-thunk';
|
import type { ThunkAction } from 'redux-thunk';
|
||||||
import { pick } from 'lodash';
|
import { isEqual, pick } from 'lodash';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import type { BodyRangeType } from '../../types/Util';
|
import type { BodyRangeType } from '../../types/Util';
|
||||||
import type { MessageAttributesType } from '../../model-types.d';
|
import type { MessageAttributesType } from '../../model-types.d';
|
||||||
|
@ -12,10 +12,11 @@ import type {
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
import type { StoryViewType } from '../../components/StoryListItem';
|
import type { StoryViewType } from '../../types/Stories';
|
||||||
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import dataInterface from '../../sql/Client';
|
import dataInterface from '../../sql/Client';
|
||||||
|
import { DAY } from '../../util/durations';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
|
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
|
||||||
import { UUID } from '../../types/UUID';
|
import { UUID } from '../../types/UUID';
|
||||||
|
@ -24,6 +25,7 @@ import { getMessageById } from '../../messages/getMessageById';
|
||||||
import { markViewed } from '../../services/MessageUpdater';
|
import { markViewed } from '../../services/MessageUpdater';
|
||||||
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||||
import { replaceIndex } from '../../util/replaceIndex';
|
import { replaceIndex } from '../../util/replaceIndex';
|
||||||
|
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
|
||||||
import { showToast } from '../../util/showToast';
|
import { showToast } from '../../util/showToast';
|
||||||
import {
|
import {
|
||||||
hasNotResolved,
|
hasNotResolved,
|
||||||
|
@ -41,6 +43,7 @@ export type StoryDataType = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
} & Pick<
|
} & Pick<
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
| 'canReplyToStory'
|
||||||
| 'conversationId'
|
| 'conversationId'
|
||||||
| 'deletedForEveryone'
|
| 'deletedForEveryone'
|
||||||
| 'reactions'
|
| 'reactions'
|
||||||
|
@ -48,6 +51,7 @@ export type StoryDataType = {
|
||||||
| 'sendStateByConversationId'
|
| 'sendStateByConversationId'
|
||||||
| 'source'
|
| 'source'
|
||||||
| 'sourceUuid'
|
| 'sourceUuid'
|
||||||
|
| 'storyDistributionListId'
|
||||||
| 'timestamp'
|
| 'timestamp'
|
||||||
| 'type'
|
| 'type'
|
||||||
>;
|
>;
|
||||||
|
@ -65,6 +69,7 @@ export type StoriesStateType = {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
|
const DOE_STORY = 'stories/DOE';
|
||||||
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
|
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
|
||||||
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
|
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
|
||||||
const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
|
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 STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
||||||
|
|
||||||
|
type DOEStoryActionType = {
|
||||||
|
type: typeof DOE_STORY;
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
|
||||||
type LoadStoryRepliesActionType = {
|
type LoadStoryRepliesActionType = {
|
||||||
type: typeof LOAD_STORY_REPLIES;
|
type: typeof LOAD_STORY_REPLIES;
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -108,6 +118,7 @@ type ToggleViewActionType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StoriesActionType =
|
export type StoriesActionType =
|
||||||
|
| DOEStoryActionType
|
||||||
| LoadStoryRepliesActionType
|
| LoadStoryRepliesActionType
|
||||||
| MarkStoryReadActionType
|
| MarkStoryReadActionType
|
||||||
| MessageChangedActionType
|
| MessageChangedActionType
|
||||||
|
@ -120,6 +131,7 @@ export type StoriesActionType =
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
deleteStoryForEveryone,
|
||||||
loadStoryReplies,
|
loadStoryReplies,
|
||||||
markStoryRead,
|
markStoryRead,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
|
@ -131,6 +143,56 @@ export const actions = {
|
||||||
|
|
||||||
export const useStoriesActions = (): typeof actions => useBoundActions(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(
|
function loadStoryReplies(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string
|
messageId: string
|
||||||
|
@ -200,7 +262,7 @@ function markStoryRead(
|
||||||
await dataInterface.addNewStoryRead({
|
await dataInterface.addNewStoryRead({
|
||||||
authorId: message.attributes.sourceUuid,
|
authorId: message.attributes.sourceUuid,
|
||||||
conversationId: message.attributes.conversationId,
|
conversationId: message.attributes.conversationId,
|
||||||
storyId: new UUID(messageId).toString(),
|
storyId: UUID.fromString(messageId),
|
||||||
storyReadDate,
|
storyReadDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -219,16 +281,15 @@ function queueStoryDownload(
|
||||||
unknown,
|
unknown,
|
||||||
NoopActionType | ResolveAttachmentUrlActionType
|
NoopActionType | ResolveAttachmentUrlActionType
|
||||||
> {
|
> {
|
||||||
return async dispatch => {
|
return async (dispatch, getState) => {
|
||||||
const story = await getMessageById(storyId);
|
const { stories } = getState().stories;
|
||||||
|
const story = stories.find(item => item.messageId === storyId);
|
||||||
|
|
||||||
if (!story) {
|
if (!story) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storyAttributes: MessageAttributesType = story.attributes;
|
const { attachment } = story;
|
||||||
const { attachments } = storyAttributes;
|
|
||||||
const attachment = attachments && attachments[0];
|
|
||||||
|
|
||||||
if (!attachment) {
|
if (!attachment) {
|
||||||
log.warn('queueStoryDownload: No attachment found for story', {
|
log.warn('queueStoryDownload: No attachment found for story', {
|
||||||
|
@ -264,11 +325,15 @@ function queueStoryDownload(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We want to ensure that we re-hydrate the story reply context with the
|
const message = await getMessageById(storyId);
|
||||||
// completed attachment download.
|
|
||||||
story.set({ storyReplyContext: undefined });
|
|
||||||
|
|
||||||
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({
|
dispatch({
|
||||||
type: 'NOOP',
|
type: 'NOOP',
|
||||||
|
@ -390,6 +455,7 @@ export function reducer(
|
||||||
if (action.type === STORY_CHANGED) {
|
if (action.type === STORY_CHANGED) {
|
||||||
const newStory = pick(action.payload, [
|
const newStory = pick(action.payload, [
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'canReplyToStory',
|
||||||
'conversationId',
|
'conversationId',
|
||||||
'deletedForEveryone',
|
'deletedForEveryone',
|
||||||
'messageId',
|
'messageId',
|
||||||
|
@ -398,6 +464,7 @@ export function reducer(
|
||||||
'sendStateByConversationId',
|
'sendStateByConversationId',
|
||||||
'source',
|
'source',
|
||||||
'sourceUuid',
|
'sourceUuid',
|
||||||
|
'storyDistributionListId',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
'type',
|
'type',
|
||||||
]);
|
]);
|
||||||
|
@ -416,10 +483,18 @@ export function reducer(
|
||||||
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
|
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
|
||||||
const reactionsChanged =
|
const reactionsChanged =
|
||||||
prevStory.reactions?.length !== newStory.reactions?.length;
|
prevStory.reactions?.length !== newStory.reactions?.length;
|
||||||
|
const hasBeenDeleted =
|
||||||
|
!prevStory.deletedForEveryone && newStory.deletedForEveryone;
|
||||||
|
const hasSendStateChanged = !isEqual(
|
||||||
|
prevStory.sendStateByConversationId,
|
||||||
|
newStory.sendStateByConversationId
|
||||||
|
);
|
||||||
|
|
||||||
const shouldReplace =
|
const shouldReplace =
|
||||||
isDownloadingAttachment ||
|
isDownloadingAttachment ||
|
||||||
hasAttachmentDownloaded ||
|
hasAttachmentDownloaded ||
|
||||||
|
hasBeenDeleted ||
|
||||||
|
hasSendStateChanged ||
|
||||||
readStatusChanged ||
|
readStatusChanged ||
|
||||||
reactionsChanged;
|
reactionsChanged;
|
||||||
if (!shouldReplace) {
|
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;
|
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 safetyNumber } from './ducks/safetyNumber';
|
||||||
import { getEmptyState as search } from './ducks/search';
|
import { getEmptyState as search } from './ducks/search';
|
||||||
import { getEmptyState as getStoriesEmptyState } from './ducks/stories';
|
import { getEmptyState as getStoriesEmptyState } from './ducks/stories';
|
||||||
|
import { getEmptyState as getStoryDistributionListsEmptyState } from './ducks/storyDistributionLists';
|
||||||
import { getEmptyState as updates } from './ducks/updates';
|
import { getEmptyState as updates } from './ducks/updates';
|
||||||
import { getEmptyState as user } from './ducks/user';
|
import { getEmptyState as user } from './ducks/user';
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ import type { StateType } from './reducer';
|
||||||
|
|
||||||
import type { BadgesStateType } from './ducks/badges';
|
import type { BadgesStateType } from './ducks/badges';
|
||||||
import type { StoryDataType } from './ducks/stories';
|
import type { StoryDataType } from './ducks/stories';
|
||||||
|
import type { StoryDistributionListDataType } from './ducks/storyDistributionLists';
|
||||||
import { getInitialState as stickers } from '../types/Stickers';
|
import { getInitialState as stickers } from '../types/Stickers';
|
||||||
import type { MenuOptionsType } from '../types/menu';
|
import type { MenuOptionsType } from '../types/menu';
|
||||||
import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis';
|
import { getEmojiReducerState as emojis } from '../util/loadRecentEmojis';
|
||||||
|
@ -32,11 +34,13 @@ import type { MainWindowStatsType } from '../windows/context';
|
||||||
export function getInitialState({
|
export function getInitialState({
|
||||||
badges,
|
badges,
|
||||||
stories,
|
stories,
|
||||||
|
storyDistributionLists,
|
||||||
mainWindowStats,
|
mainWindowStats,
|
||||||
menuOptions,
|
menuOptions,
|
||||||
}: {
|
}: {
|
||||||
badges: BadgesStateType;
|
badges: BadgesStateType;
|
||||||
stories: Array<StoryDataType>;
|
stories: Array<StoryDataType>;
|
||||||
|
storyDistributionLists: Array<StoryDistributionListDataType>;
|
||||||
mainWindowStats: MainWindowStatsType;
|
mainWindowStats: MainWindowStatsType;
|
||||||
menuOptions: MenuOptionsType;
|
menuOptions: MenuOptionsType;
|
||||||
}): StateType {
|
}): StateType {
|
||||||
|
@ -101,6 +105,10 @@ export function getInitialState({
|
||||||
...getStoriesEmptyState(),
|
...getStoriesEmptyState(),
|
||||||
stories,
|
stories,
|
||||||
},
|
},
|
||||||
|
storyDistributionLists: {
|
||||||
|
...getStoryDistributionListsEmptyState(),
|
||||||
|
distributionLists: storyDistributionLists || [],
|
||||||
|
},
|
||||||
updates: updates(),
|
updates: updates(),
|
||||||
user: {
|
user: {
|
||||||
...user(),
|
...user(),
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { reducer as safetyNumber } from './ducks/safetyNumber';
|
||||||
import { reducer as search } from './ducks/search';
|
import { reducer as search } from './ducks/search';
|
||||||
import { reducer as stickers } from './ducks/stickers';
|
import { reducer as stickers } from './ducks/stickers';
|
||||||
import { reducer as stories } from './ducks/stories';
|
import { reducer as stories } from './ducks/stories';
|
||||||
|
import { reducer as storyDistributionLists } from './ducks/storyDistributionLists';
|
||||||
import { reducer as updates } from './ducks/updates';
|
import { reducer as updates } from './ducks/updates';
|
||||||
import { reducer as user } from './ducks/user';
|
import { reducer as user } from './ducks/user';
|
||||||
|
|
||||||
|
@ -47,6 +48,7 @@ export const reducer = combineReducers({
|
||||||
search,
|
search,
|
||||||
stickers,
|
stickers,
|
||||||
stories,
|
stories,
|
||||||
|
storyDistributionLists,
|
||||||
updates,
|
updates,
|
||||||
user,
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { identity, isEqual, isNumber, isObject, map, omit, pick } from 'lodash';
|
import { identity, isEqual, isNumber, isObject, map, omit, pick } from 'lodash';
|
||||||
import { createSelectorCreator } from 'reselect';
|
import { createSelector, createSelectorCreator } from 'reselect';
|
||||||
import filesize from 'filesize';
|
import filesize from 'filesize';
|
||||||
import getDirection from 'direction';
|
import getDirection from 'direction';
|
||||||
|
|
||||||
|
@ -50,6 +50,20 @@ import { isMoreRecentThan } from '../../util/timestamp';
|
||||||
import * as iterables from '../../util/iterables';
|
import * as iterables from '../../util/iterables';
|
||||||
import { strictAssert } from '../../util/assert';
|
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 {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
MessageWithUIFieldsType,
|
MessageWithUIFieldsType,
|
||||||
|
@ -61,7 +75,6 @@ import type {
|
||||||
GetConversationByIdType,
|
GetConversationByIdType,
|
||||||
ContactNameColorSelectorType,
|
ContactNameColorSelectorType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import { isMissingRequiredProfileSharing } from './conversations';
|
|
||||||
import {
|
import {
|
||||||
SendStatus,
|
SendStatus,
|
||||||
isDelivered,
|
isDelivered,
|
||||||
|
@ -91,7 +104,7 @@ type FormattedContact = Partial<ConversationType> &
|
||||||
| 'type'
|
| 'type'
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
>;
|
>;
|
||||||
type PropsForMessage = Omit<PropsData, 'interactionMode'>;
|
export type PropsForMessage = Omit<PropsData, 'interactionMode'>;
|
||||||
type PropsForUnsupportedMessage = {
|
type PropsForUnsupportedMessage = {
|
||||||
canProcessNow: boolean;
|
canProcessNow: boolean;
|
||||||
contact: FormattedContact;
|
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)(
|
export const getBubblePropsForMessage = createSelectorCreator(memoizeByRoot)(
|
||||||
// `memoizeByRoot` requirement
|
// `memoizeByRoot` requirement
|
||||||
identity,
|
identity,
|
||||||
|
@ -1535,7 +1586,10 @@ function processQuoteAttachment(
|
||||||
function canReplyOrReact(
|
function canReplyOrReact(
|
||||||
message: Pick<
|
message: Pick<
|
||||||
MessageWithUIFieldsType,
|
MessageWithUIFieldsType,
|
||||||
'deletedForEveryone' | 'sendStateByConversationId' | 'type'
|
| 'canReplyToStory'
|
||||||
|
| 'deletedForEveryone'
|
||||||
|
| 'sendStateByConversationId'
|
||||||
|
| 'type'
|
||||||
>,
|
>,
|
||||||
ourConversationId: string | undefined,
|
ourConversationId: string | undefined,
|
||||||
conversation: undefined | Readonly<ConversationType>
|
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
|
// If we get past all the other checks above then we can always reply or
|
||||||
// react if the message type is "incoming" | "story"
|
// react if the message type is "incoming" | "story"
|
||||||
if (isIncoming(message) || isStory(message)) {
|
if (isIncoming(message)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStory(message)) {
|
||||||
|
return Boolean(message.canReplyToStory);
|
||||||
|
}
|
||||||
|
|
||||||
// Fail safe.
|
// Fail safe.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -1587,6 +1645,7 @@ function canReplyOrReact(
|
||||||
export function canReply(
|
export function canReply(
|
||||||
message: Pick<
|
message: Pick<
|
||||||
MessageWithUIFieldsType,
|
MessageWithUIFieldsType,
|
||||||
|
| 'canReplyToStory'
|
||||||
| 'conversationId'
|
| 'conversationId'
|
||||||
| 'deletedForEveryone'
|
| 'deletedForEveryone'
|
||||||
| 'sendStateByConversationId'
|
| 'sendStateByConversationId'
|
||||||
|
|
|
@ -6,21 +6,25 @@ import { pick } from 'lodash';
|
||||||
|
|
||||||
import type { GetConversationByIdType } from './conversations';
|
import type { GetConversationByIdType } from './conversations';
|
||||||
import type { ConversationType } from '../ducks/conversations';
|
import type { ConversationType } from '../ducks/conversations';
|
||||||
|
import type { MessageReactionType } from '../../model-types.d';
|
||||||
import type {
|
import type {
|
||||||
ConversationStoryType,
|
ConversationStoryType,
|
||||||
|
MyStoryType,
|
||||||
|
ReplyStateType,
|
||||||
|
StorySendStateType,
|
||||||
StoryViewType,
|
StoryViewType,
|
||||||
} from '../../components/StoryListItem';
|
} from '../../types/Stories';
|
||||||
import type { MessageReactionType } from '../../model-types.d';
|
|
||||||
import type { ReplyStateType } from '../../types/Stories';
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { StoryDataType, StoriesStateType } from '../ducks/stories';
|
import type { StoryDataType, StoriesStateType } from '../ducks/stories';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
import { SendStatus } from '../../messages/MessageSendState';
|
||||||
import { canReply } from './message';
|
import { canReply } from './message';
|
||||||
import {
|
import {
|
||||||
getContactNameColorSelector,
|
getContactNameColorSelector,
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getMe,
|
getMe,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
|
import { getDistributionListSelector } from './storyDistributionLists';
|
||||||
import { getUserConversationId } from './user';
|
import { getUserConversationId } from './user';
|
||||||
|
|
||||||
export const getStoriesState = (state: StateType): StoriesStateType =>
|
export const getStoriesState = (state: StateType): StoriesStateType =>
|
||||||
|
@ -31,13 +35,13 @@ export const shouldShowStoriesView = createSelector(
|
||||||
({ isShowingStoriesView }): boolean => isShowingStoriesView
|
({ isShowingStoriesView }): boolean => isShowingStoriesView
|
||||||
);
|
);
|
||||||
|
|
||||||
function getNewestStory(x: ConversationStoryType): StoryViewType {
|
function getNewestStory(x: ConversationStoryType | MyStoryType): StoryViewType {
|
||||||
return x.stories[x.stories.length - 1];
|
return x.stories[x.stories.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortByRecencyAndUnread(
|
function sortByRecencyAndUnread(
|
||||||
a: ConversationStoryType,
|
a: ConversationStoryType | MyStoryType,
|
||||||
b: ConversationStoryType
|
b: ConversationStoryType | MyStoryType
|
||||||
): number {
|
): number {
|
||||||
const storyA = getNewestStory(a);
|
const storyA = getNewestStory(a);
|
||||||
const storyB = getNewestStory(b);
|
const storyB = getNewestStory(b);
|
||||||
|
@ -86,11 +90,11 @@ function getAvatarData(
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConversationStory(
|
function getStoryView(
|
||||||
conversationSelector: GetConversationByIdType,
|
conversationSelector: GetConversationByIdType,
|
||||||
story: StoryDataType,
|
story: StoryDataType,
|
||||||
ourConversationId?: string
|
ourConversationId?: string
|
||||||
): ConversationStoryType {
|
): StoryViewType {
|
||||||
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
|
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
|
||||||
'acceptedMessageRequest',
|
'acceptedMessageRequest',
|
||||||
'avatarPath',
|
'avatarPath',
|
||||||
|
@ -105,6 +109,28 @@ function getConversationStory(
|
||||||
'title',
|
'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), [
|
const conversation = pick(conversationSelector(story.conversationId), [
|
||||||
'acceptedMessageRequest',
|
'acceptedMessageRequest',
|
||||||
'avatarPath',
|
'avatarPath',
|
||||||
|
@ -116,16 +142,11 @@ function getConversationStory(
|
||||||
'title',
|
'title',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']);
|
const storyView = getStoryView(
|
||||||
|
conversationSelector,
|
||||||
const storyView: StoryViewType = {
|
story,
|
||||||
attachment,
|
ourConversationId
|
||||||
canReply: canReply(story, ourConversationId, conversationSelector),
|
);
|
||||||
isUnread: story.readStatus === ReadStatus.Unread,
|
|
||||||
messageId: story.messageId,
|
|
||||||
sender,
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
|
@ -239,29 +260,96 @@ export const getStoryReplies = createSelector(
|
||||||
|
|
||||||
export const getStories = createSelector(
|
export const getStories = createSelector(
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getUserConversationId,
|
getDistributionListSelector,
|
||||||
getStoriesState,
|
getStoriesState,
|
||||||
|
getUserConversationId,
|
||||||
shouldShowStoriesView,
|
shouldShowStoriesView,
|
||||||
(
|
(
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
ourConversationId,
|
distributionListSelector,
|
||||||
{ stories }: Readonly<StoriesStateType>,
|
{ stories }: Readonly<StoriesStateType>,
|
||||||
|
ourConversationId,
|
||||||
isShowingStoriesView
|
isShowingStoriesView
|
||||||
): {
|
): {
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
|
myStories: Array<MyStoryType>;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
} => {
|
} => {
|
||||||
if (!isShowingStoriesView) {
|
if (!isShowingStoriesView) {
|
||||||
return {
|
return {
|
||||||
hiddenStories: [],
|
hiddenStories: [],
|
||||||
|
myStories: [],
|
||||||
stories: [],
|
stories: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const storiesById = new Map<string, ConversationStoryType>();
|
|
||||||
const hiddenStoriesById = 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 => {
|
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(
|
const conversationStory = getConversationStory(
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
story,
|
story,
|
||||||
|
@ -269,6 +357,7 @@ export const getStories = createSelector(
|
||||||
);
|
);
|
||||||
|
|
||||||
let storiesMap: Map<string, ConversationStoryType>;
|
let storiesMap: Map<string, ConversationStoryType>;
|
||||||
|
|
||||||
if (conversationStory.isHidden) {
|
if (conversationStory.isHidden) {
|
||||||
storiesMap = hiddenStoriesById;
|
storiesMap = hiddenStoriesById;
|
||||||
} else {
|
} else {
|
||||||
|
@ -293,6 +382,9 @@ export const getStories = createSelector(
|
||||||
hiddenStories: Array.from(hiddenStoriesById.values()).sort(
|
hiddenStories: Array.from(hiddenStoriesById.values()).sort(
|
||||||
sortByRecencyAndUnread
|
sortByRecencyAndUnread
|
||||||
),
|
),
|
||||||
|
myStories: Array.from(myStoriesById.values()).sort(
|
||||||
|
sortByRecencyAndUnread
|
||||||
|
),
|
||||||
stories: Array.from(storiesById.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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
import React from 'react';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import { useSelector } from 'react-redux';
|
||||||
import type { BodyRangeType } from '../../types/Util';
|
import type { BodyRangeType } from '../../types/Util';
|
||||||
import type { DataPropsType } from '../../components/ForwardMessageModal';
|
import type { ForwardMessagePropsType } from '../ducks/globalModals';
|
||||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
import * as log from '../../logging/log';
|
||||||
import { ForwardMessageModal } from '../../components/ForwardMessageModal';
|
import { ForwardMessageModal } from '../../components/ForwardMessageModal';
|
||||||
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
||||||
|
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
|
||||||
import { getAllComposableConversations } from '../selectors/conversations';
|
import { getAllComposableConversations } from '../selectors/conversations';
|
||||||
import { getEmojiSkinTone } from '../selectors/items';
|
import { getEmojiSkinTone } from '../selectors/items';
|
||||||
import { getIntl, getTheme, getRegionCode } from '../selectors/user';
|
import { getIntl, getTheme, getRegionCode } from '../selectors/user';
|
||||||
import { getLinkPreview } from '../selectors/linkPreviews';
|
import { getLinkPreview } from '../selectors/linkPreviews';
|
||||||
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
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 { 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 = {
|
export function SmartForwardMessageModal(): JSX.Element | null {
|
||||||
attachments?: Array<AttachmentType>;
|
const forwardMessageProps = useSelector<
|
||||||
doForwardMessage: (
|
StateType,
|
||||||
selectedContacts: Array<string>,
|
ForwardMessagePropsType | undefined
|
||||||
messageBody?: string,
|
>(state => state.globalModals.forwardMessageProps);
|
||||||
attachments?: Array<AttachmentType>,
|
const candidateConversations = useSelector(getAllComposableConversations);
|
||||||
linkPreview?: LinkPreviewType
|
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||||
) => void;
|
const i18n = useSelector(getIntl);
|
||||||
hasContact: boolean;
|
const linkPreviewForSource = useSelector(getLinkPreview);
|
||||||
isSticker: boolean;
|
const recentEmojis = useSelector(selectRecentEmojis);
|
||||||
messageBody?: string;
|
const regionCode = useSelector(getRegionCode);
|
||||||
onClose: () => void;
|
const skinTone = useSelector(getEmojiSkinTone);
|
||||||
onEditorStateChange: (
|
const theme = useSelector(getTheme);
|
||||||
messageText: string,
|
|
||||||
bodyRanges: Array<BodyRangeType>,
|
|
||||||
caretLocation?: number
|
|
||||||
) => unknown;
|
|
||||||
onTextTooLong: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (
|
const { removeLinkPreview } = useLinkPreviewActions();
|
||||||
state: StateType,
|
const { onUseEmoji: onPickEmoji } = useEmojiActions();
|
||||||
props: SmartForwardMessageModalProps
|
const { onSetSkinTone } = useItemsActions();
|
||||||
): DataPropsType => {
|
const { toggleForwardMessageModal } = useGlobalModalActions();
|
||||||
const {
|
|
||||||
attachments,
|
|
||||||
doForwardMessage,
|
|
||||||
hasContact,
|
|
||||||
isSticker,
|
|
||||||
messageBody,
|
|
||||||
onClose,
|
|
||||||
onEditorStateChange,
|
|
||||||
onTextTooLong,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const candidateConversations = getAllComposableConversations(state);
|
if (!forwardMessageProps) {
|
||||||
const recentEmojis = selectRecentEmojis(state);
|
return null;
|
||||||
const skinTone = getEmojiSkinTone(state);
|
}
|
||||||
const linkPreviewForSource = getLinkPreview(state);
|
|
||||||
|
|
||||||
return {
|
const { attachments = [] } = forwardMessageProps;
|
||||||
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 smart = connect(mapStateToProps, {
|
function closeModal() {
|
||||||
...mapDispatchToProps,
|
resetLinkPreview();
|
||||||
onPickEmoji: mapDispatchToProps.onUseEmoji,
|
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 { mapDispatchToProps } from '../actions';
|
||||||
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
|
||||||
import { SmartContactModal } from './ContactModal';
|
import { SmartContactModal } from './ContactModal';
|
||||||
|
import { SmartForwardMessageModal } from './ForwardMessageModal';
|
||||||
|
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
||||||
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
||||||
|
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
|
@ -20,6 +21,10 @@ function renderContactModal(): JSX.Element {
|
||||||
return <SmartContactModal />;
|
return <SmartContactModal />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderForwardMessageModal(): JSX.Element {
|
||||||
|
return <SmartForwardMessageModal />;
|
||||||
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType) => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
const i18n = getIntl(state);
|
const i18n = getIntl(state);
|
||||||
|
|
||||||
|
@ -27,6 +32,7 @@ const mapStateToProps = (state: StateType) => {
|
||||||
...state.globalModals,
|
...state.globalModals,
|
||||||
i18n,
|
i18n,
|
||||||
renderContactModal,
|
renderContactModal,
|
||||||
|
renderForwardMessageModal,
|
||||||
renderProfileEditor,
|
renderProfileEditor,
|
||||||
renderSafetyNumber: () => (
|
renderSafetyNumber: () => (
|
||||||
<SmartSafetyNumberModal
|
<SmartSafetyNumberModal
|
||||||
|
|
|
@ -11,11 +11,14 @@ import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
|
||||||
import { SmartStoryCreator } from './StoryCreator';
|
import { SmartStoryCreator } from './StoryCreator';
|
||||||
import { SmartStoryViewer } from './StoryViewer';
|
import { SmartStoryViewer } from './StoryViewer';
|
||||||
import { Stories } from '../../components/Stories';
|
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 { getPreferredLeftPaneWidth } from '../selectors/items';
|
||||||
import { getStories } from '../selectors/stories';
|
import { getStories } from '../selectors/stories';
|
||||||
import { useStoriesActions } from '../ducks/stories';
|
import { saveAttachment } from '../../util/saveAttachment';
|
||||||
import { useConversationsActions } from '../ducks/conversations';
|
import { useConversationsActions } from '../ducks/conversations';
|
||||||
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
|
import { useStoriesActions } from '../ducks/stories';
|
||||||
|
|
||||||
function renderStoryCreator({
|
function renderStoryCreator({
|
||||||
onClose,
|
onClose,
|
||||||
|
@ -28,6 +31,7 @@ function renderStoryViewer({
|
||||||
onClose,
|
onClose,
|
||||||
onNextUserStories,
|
onNextUserStories,
|
||||||
onPrevUserStories,
|
onPrevUserStories,
|
||||||
|
storyToView,
|
||||||
}: SmartStoryViewerPropsType): JSX.Element {
|
}: SmartStoryViewerPropsType): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<SmartStoryViewer
|
<SmartStoryViewer
|
||||||
|
@ -35,6 +39,7 @@ function renderStoryViewer({
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onNextUserStories={onNextUserStories}
|
onNextUserStories={onNextUserStories}
|
||||||
onPrevUserStories={onPrevUserStories}
|
onPrevUserStories={onPrevUserStories}
|
||||||
|
storyToView={storyToView}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -42,6 +47,7 @@ function renderStoryViewer({
|
||||||
export function SmartStories(): JSX.Element | null {
|
export function SmartStories(): JSX.Element | null {
|
||||||
const storiesActions = useStoriesActions();
|
const storiesActions = useStoriesActions();
|
||||||
const { showConversation, toggleHideStories } = useConversationsActions();
|
const { showConversation, toggleHideStories } = useConversationsActions();
|
||||||
|
const { toggleForwardMessageModal } = useGlobalModalActions();
|
||||||
|
|
||||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||||
|
|
||||||
|
@ -53,7 +59,10 @@ export function SmartStories(): JSX.Element | null {
|
||||||
getPreferredLeftPaneWidth
|
getPreferredLeftPaneWidth
|
||||||
);
|
);
|
||||||
|
|
||||||
const { hiddenStories, stories } = useSelector(getStories);
|
const { hiddenStories, myStories, stories } = useSelector(getStories);
|
||||||
|
|
||||||
|
const ourConversationId = useSelector(getUserConversationId);
|
||||||
|
const me = useSelector(getMe);
|
||||||
|
|
||||||
if (!isShowingStoriesView) {
|
if (!isShowingStoriesView) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -63,6 +72,17 @@ export function SmartStories(): JSX.Element | null {
|
||||||
<Stories
|
<Stories
|
||||||
hiddenStories={hiddenStories}
|
hiddenStories={hiddenStories}
|
||||||
i18n={i18n}
|
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}
|
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||||
renderStoryCreator={renderStoryCreator}
|
renderStoryCreator={renderStoryCreator}
|
||||||
renderStoryViewer={renderStoryViewer}
|
renderStoryViewer={renderStoryViewer}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useSelector } from 'react-redux';
|
||||||
import type { GetStoriesByConversationIdType } from '../selectors/stories';
|
import type { GetStoriesByConversationIdType } from '../selectors/stories';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
import type { StoryViewType } from '../../types/Stories';
|
||||||
import { StoryViewer } from '../../components/StoryViewer';
|
import { StoryViewer } from '../../components/StoryViewer';
|
||||||
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
|
import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong';
|
||||||
import {
|
import {
|
||||||
|
@ -28,8 +29,9 @@ import { useStoriesActions } from '../ducks/stories';
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
onNextUserStories: () => unknown;
|
onNextUserStories?: () => unknown;
|
||||||
onPrevUserStories: () => unknown;
|
onPrevUserStories?: () => unknown;
|
||||||
|
storyToView?: StoryViewType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SmartStoryViewer({
|
export function SmartStoryViewer({
|
||||||
|
@ -37,6 +39,7 @@ export function SmartStoryViewer({
|
||||||
onClose,
|
onClose,
|
||||||
onNextUserStories,
|
onNextUserStories,
|
||||||
onPrevUserStories,
|
onPrevUserStories,
|
||||||
|
storyToView,
|
||||||
}: PropsType): JSX.Element | null {
|
}: PropsType): JSX.Element | null {
|
||||||
const storiesActions = useStoriesActions();
|
const storiesActions = useStoriesActions();
|
||||||
const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions();
|
const { onSetSkinTone, toggleHasAllStoriesMuted } = useItemsActions();
|
||||||
|
@ -54,7 +57,9 @@ export function SmartStoryViewer({
|
||||||
GetStoriesByConversationIdType
|
GetStoriesByConversationIdType
|
||||||
>(getStoriesSelector);
|
>(getStoriesSelector);
|
||||||
|
|
||||||
const { group, stories } = getStoriesByConversationId(conversationId);
|
const { group, stories } = storyToView
|
||||||
|
? { group: undefined, stories: [storyToView] }
|
||||||
|
: getStoriesByConversationId(conversationId);
|
||||||
|
|
||||||
const recentEmojis = useRecentEmojis();
|
const recentEmojis = useRecentEmojis();
|
||||||
const skinTone = useSelector<StateType, number>(getEmojiSkinTone);
|
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 search } from './ducks/search';
|
||||||
import type { actions as stickers } from './ducks/stickers';
|
import type { actions as stickers } from './ducks/stickers';
|
||||||
import type { actions as stories } from './ducks/stories';
|
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 updates } from './ducks/updates';
|
||||||
import type { actions as user } from './ducks/user';
|
import type { actions as user } from './ducks/user';
|
||||||
|
|
||||||
|
@ -43,6 +44,7 @@ export type ReduxActions = {
|
||||||
search: typeof search;
|
search: typeof search;
|
||||||
stickers: typeof stickers;
|
stickers: typeof stickers;
|
||||||
stories: typeof stories;
|
stories: typeof stories;
|
||||||
|
storyDistributionLists: typeof storyDistributionLists;
|
||||||
updates: typeof updates;
|
updates: typeof updates;
|
||||||
user: typeof user;
|
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 { assert } from 'chai';
|
||||||
|
|
||||||
import dataInterface from '../../sql/Client';
|
import dataInterface from '../../sql/Client';
|
||||||
import { getRandomBytes } from '../../Crypto';
|
|
||||||
import { UUID } from '../../types/UUID';
|
import { UUID } from '../../types/UUID';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
|
|
||||||
|
@ -19,6 +18,7 @@ const {
|
||||||
getAllStoryDistributionsWithMembers,
|
getAllStoryDistributionsWithMembers,
|
||||||
modifyStoryDistribution,
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
|
modifyStoryDistributionWithMembers,
|
||||||
} = dataInterface;
|
} = dataInterface;
|
||||||
|
|
||||||
function getUuid(): UUIDStringType {
|
function getUuid(): UUIDStringType {
|
||||||
|
@ -34,14 +34,19 @@ describe('sql/storyDistribution', () => {
|
||||||
const list: StoryDistributionWithMembersType = {
|
const list: StoryDistributionWithMembersType = {
|
||||||
id: getUuid(),
|
id: getUuid(),
|
||||||
name: 'My Story',
|
name: 'My Story',
|
||||||
avatarUrlPath: getUuid(),
|
allowsReplies: true,
|
||||||
avatarKey: getRandomBytes(128),
|
isBlockList: false,
|
||||||
members: [getUuid(), getUuid()],
|
members: [getUuid(), getUuid()],
|
||||||
senderKeyInfo: {
|
senderKeyInfo: {
|
||||||
createdAtDate: Date.now(),
|
createdAtDate: Date.now(),
|
||||||
distributionId: getUuid(),
|
distributionId: getUuid(),
|
||||||
memberDevices: [],
|
memberDevices: [],
|
||||||
},
|
},
|
||||||
|
storageID: getUuid(),
|
||||||
|
storageVersion: 1,
|
||||||
|
storageNeedsSync: false,
|
||||||
|
storageUnknownFields: undefined,
|
||||||
|
deletedAtTimestamp: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await createNewStoryDistribution(list);
|
await createNewStoryDistribution(list);
|
||||||
|
@ -66,14 +71,19 @@ describe('sql/storyDistribution', () => {
|
||||||
const list: StoryDistributionWithMembersType = {
|
const list: StoryDistributionWithMembersType = {
|
||||||
id: getUuid(),
|
id: getUuid(),
|
||||||
name: 'My Story',
|
name: 'My Story',
|
||||||
avatarUrlPath: getUuid(),
|
allowsReplies: true,
|
||||||
avatarKey: getRandomBytes(128),
|
isBlockList: false,
|
||||||
members: [UUID_1, UUID_2],
|
members: [UUID_1, UUID_2],
|
||||||
senderKeyInfo: {
|
senderKeyInfo: {
|
||||||
createdAtDate: Date.now(),
|
createdAtDate: Date.now(),
|
||||||
distributionId: getUuid(),
|
distributionId: getUuid(),
|
||||||
memberDevices: [],
|
memberDevices: [],
|
||||||
},
|
},
|
||||||
|
storageID: getUuid(),
|
||||||
|
storageVersion: 1,
|
||||||
|
storageNeedsSync: false,
|
||||||
|
storageUnknownFields: undefined,
|
||||||
|
deletedAtTimestamp: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await createNewStoryDistribution(list);
|
await createNewStoryDistribution(list);
|
||||||
|
@ -84,8 +94,6 @@ describe('sql/storyDistribution', () => {
|
||||||
const updated = {
|
const updated = {
|
||||||
...list,
|
...list,
|
||||||
name: 'Updated story',
|
name: 'Updated story',
|
||||||
avatarKey: getRandomBytes(128),
|
|
||||||
avatarUrlPath: getUuid(),
|
|
||||||
senderKeyInfo: {
|
senderKeyInfo: {
|
||||||
createdAtDate: Date.now() + 10,
|
createdAtDate: Date.now() + 10,
|
||||||
distributionId: getUuid(),
|
distributionId: getUuid(),
|
||||||
|
@ -117,14 +125,19 @@ describe('sql/storyDistribution', () => {
|
||||||
const list: StoryDistributionWithMembersType = {
|
const list: StoryDistributionWithMembersType = {
|
||||||
id: getUuid(),
|
id: getUuid(),
|
||||||
name: 'My Story',
|
name: 'My Story',
|
||||||
avatarUrlPath: getUuid(),
|
allowsReplies: true,
|
||||||
avatarKey: getRandomBytes(128),
|
isBlockList: false,
|
||||||
members: [UUID_1, UUID_2],
|
members: [UUID_1, UUID_2],
|
||||||
senderKeyInfo: {
|
senderKeyInfo: {
|
||||||
createdAtDate: Date.now(),
|
createdAtDate: Date.now(),
|
||||||
distributionId: getUuid(),
|
distributionId: getUuid(),
|
||||||
memberDevices: [],
|
memberDevices: [],
|
||||||
},
|
},
|
||||||
|
storageID: getUuid(),
|
||||||
|
storageVersion: 1,
|
||||||
|
storageNeedsSync: false,
|
||||||
|
storageUnknownFields: undefined,
|
||||||
|
deletedAtTimestamp: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await createNewStoryDistribution(list);
|
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 () => {
|
it('eliminates duplicates without complaint in createNewStoryDistribution', async () => {
|
||||||
const UUID_1 = getUuid();
|
const UUID_1 = getUuid();
|
||||||
const UUID_2 = getUuid();
|
const UUID_2 = getUuid();
|
||||||
const list: StoryDistributionWithMembersType = {
|
const list: StoryDistributionWithMembersType = {
|
||||||
id: getUuid(),
|
id: getUuid(),
|
||||||
name: 'My Story',
|
name: 'My Story',
|
||||||
avatarUrlPath: getUuid(),
|
allowsReplies: true,
|
||||||
avatarKey: getRandomBytes(128),
|
isBlockList: false,
|
||||||
members: [UUID_1, UUID_1, UUID_2],
|
members: [UUID_1, UUID_1, UUID_2],
|
||||||
senderKeyInfo: {
|
senderKeyInfo: {
|
||||||
createdAtDate: Date.now(),
|
createdAtDate: Date.now(),
|
||||||
distributionId: getUuid(),
|
distributionId: getUuid(),
|
||||||
memberDevices: [],
|
memberDevices: [],
|
||||||
},
|
},
|
||||||
|
storageID: getUuid(),
|
||||||
|
storageVersion: 1,
|
||||||
|
storageNeedsSync: false,
|
||||||
|
storageUnknownFields: undefined,
|
||||||
|
deletedAtTimestamp: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await createNewStoryDistribution(list);
|
await createNewStoryDistribution(list);
|
||||||
|
|
|
@ -108,10 +108,26 @@ describe('both/state/ducks/stories', () => {
|
||||||
attachments: [attachment],
|
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);
|
window.MessageController.register(storyId, messageAttributes);
|
||||||
|
|
||||||
const dispatch = sinon.spy();
|
const dispatch = sinon.spy();
|
||||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
await queueStoryDownload(storyId)(dispatch, getState, null);
|
||||||
|
|
||||||
const action = dispatch.getCall(0).args[0];
|
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);
|
window.MessageController.register(storyId, messageAttributes);
|
||||||
|
|
||||||
const dispatch = sinon.spy();
|
const dispatch = sinon.spy();
|
||||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
await queueStoryDownload(storyId)(dispatch, getState, null);
|
||||||
|
|
||||||
sinon.assert.calledWith(dispatch, {
|
sinon.assert.calledWith(dispatch, {
|
||||||
type: 'NOOP',
|
type: 'NOOP',
|
||||||
|
|
|
@ -1788,7 +1788,8 @@ export default class MessageReceiver
|
||||||
|
|
||||||
private async handleStoryMessage(
|
private async handleStoryMessage(
|
||||||
envelope: UnsealedEnvelope,
|
envelope: UnsealedEnvelope,
|
||||||
msg: Proto.IStoryMessage
|
msg: Proto.IStoryMessage,
|
||||||
|
sentMessage?: ProcessedSent
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const logId = this.getEnvelopeId(envelope);
|
const logId = this.getEnvelopeId(envelope);
|
||||||
log.info('MessageReceiver.handleStoryMessage', logId);
|
log.info('MessageReceiver.handleStoryMessage', logId);
|
||||||
|
@ -1846,6 +1847,68 @@ export default class MessageReceiver
|
||||||
return;
|
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(
|
const ev = new MessageEvent(
|
||||||
{
|
{
|
||||||
source: envelope.source,
|
source: envelope.source,
|
||||||
|
@ -1857,15 +1920,7 @@ export default class MessageReceiver
|
||||||
unidentifiedDeliveryReceived: Boolean(
|
unidentifiedDeliveryReceived: Boolean(
|
||||||
envelope.unidentifiedDeliveryReceived
|
envelope.unidentifiedDeliveryReceived
|
||||||
),
|
),
|
||||||
message: {
|
message,
|
||||||
attachments,
|
|
||||||
expireTimer,
|
|
||||||
flags: 0,
|
|
||||||
groupV2,
|
|
||||||
isStory: true,
|
|
||||||
isViewOnce: false,
|
|
||||||
timestamp: envelope.timestamp,
|
|
||||||
},
|
|
||||||
receivedAtCounter: envelope.receivedAtCounter,
|
receivedAtCounter: envelope.receivedAtCounter,
|
||||||
receivedAtDate: envelope.receivedAtDate,
|
receivedAtDate: envelope.receivedAtDate,
|
||||||
},
|
},
|
||||||
|
@ -2415,6 +2470,15 @@ export default class MessageReceiver
|
||||||
if (syncMessage.sent) {
|
if (syncMessage.sent) {
|
||||||
const sentMessage = syncMessage.sent;
|
const sentMessage = syncMessage.sent;
|
||||||
|
|
||||||
|
if (sentMessage.storyMessage) {
|
||||||
|
this.handleStoryMessage(
|
||||||
|
envelope,
|
||||||
|
sentMessage.storyMessage,
|
||||||
|
sentMessage
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sentMessage || !sentMessage.message) {
|
if (!sentMessage || !sentMessage.message) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'MessageReceiver.handleSyncMessage: sync sent message was missing message'
|
'MessageReceiver.handleSyncMessage: sync sent message was missing message'
|
||||||
|
|
|
@ -219,6 +219,7 @@ export type ProcessedDataMessage = {
|
||||||
groupCallUpdate?: ProcessedGroupCallUpdate;
|
groupCallUpdate?: ProcessedGroupCallUpdate;
|
||||||
storyContext?: ProcessedStoryContext;
|
storyContext?: ProcessedStoryContext;
|
||||||
giftBadge?: ProcessedGiftBadge;
|
giftBadge?: ProcessedGiftBadge;
|
||||||
|
canReplyToStory?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessedUnidentifiedDeliveryStatus = Omit<
|
export type ProcessedUnidentifiedDeliveryStatus = Omit<
|
||||||
|
@ -226,6 +227,7 @@ export type ProcessedUnidentifiedDeliveryStatus = Omit<
|
||||||
'destinationUuid'
|
'destinationUuid'
|
||||||
> & {
|
> & {
|
||||||
destinationUuid?: string;
|
destinationUuid?: string;
|
||||||
|
isAllowedToReplyToStory?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessedSent = Omit<
|
export type ProcessedSent = Omit<
|
||||||
|
|
|
@ -193,6 +193,7 @@ export type SentEventData = Readonly<{
|
||||||
receivedAtCounter: number;
|
receivedAtCounter: number;
|
||||||
receivedAtDate: number;
|
receivedAtDate: number;
|
||||||
expirationStartTimestamp?: number;
|
expirationStartTimestamp?: number;
|
||||||
|
storyDistributionListId?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export class SentEvent extends ConfirmableEvent {
|
export class SentEvent extends ConfirmableEvent {
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { AttachmentType } from './Attachment';
|
||||||
import type { ContactNameColorType } from './Colors';
|
import type { ContactNameColorType } from './Colors';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import type { SendStatus } from '../messages/MessageSendState';
|
||||||
|
|
||||||
export type ReplyType = Pick<
|
export type ReplyType = Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
@ -27,3 +29,73 @@ export type ReplyStateType = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
replies: Array<ReplyType>;
|
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)}`);
|
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;
|
let lightboxMountNode: HTMLElement | undefined;
|
||||||
|
|
||||||
export function isLightboxOpen(): boolean {
|
|
||||||
return Boolean(lightboxMountNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeLightbox(): void {
|
export function closeLightbox(): void {
|
||||||
if (!lightboxMountNode) {
|
if (!lightboxMountNode) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { render } from 'mustache';
|
||||||
|
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import { isGIF } from '../types/Attachment';
|
import { isGIF } from '../types/Attachment';
|
||||||
import * as Attachment from '../types/Attachment';
|
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
import type { BodyRangeType, BodyRangesType } from '../types/Util';
|
import type { BodyRangeType, BodyRangesType } from '../types/Util';
|
||||||
import type { MIMEType } from '../types/MIME';
|
import type { MIMEType } from '../types/MIME';
|
||||||
|
@ -22,7 +21,6 @@ import type {
|
||||||
ConversationModelCollectionType,
|
ConversationModelCollectionType,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
|
||||||
import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem';
|
import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem';
|
||||||
import type { MessageModel } from '../models/messages';
|
import type { MessageModel } from '../models/messages';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
|
@ -41,7 +39,6 @@ import { findAndFormatContact } from '../util/findAndFormatContact';
|
||||||
import { getPreferredBadgeSelector } from '../state/selectors/badges';
|
import { getPreferredBadgeSelector } from '../state/selectors/badges';
|
||||||
import {
|
import {
|
||||||
canReply,
|
canReply,
|
||||||
getAttachmentsForMessage,
|
|
||||||
isIncoming,
|
isIncoming,
|
||||||
isOutgoing,
|
isOutgoing,
|
||||||
isTapToView,
|
isTapToView,
|
||||||
|
@ -73,7 +70,6 @@ import { ToastConversationUnarchived } from '../components/ToastConversationUnar
|
||||||
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
|
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
|
||||||
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
|
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
|
||||||
import { ToastExpired } from '../components/ToastExpired';
|
import { ToastExpired } from '../components/ToastExpired';
|
||||||
import { ToastFileSaved } from '../components/ToastFileSaved';
|
|
||||||
import { ToastFileSize } from '../components/ToastFileSize';
|
import { ToastFileSize } from '../components/ToastFileSize';
|
||||||
import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
|
import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
|
||||||
import { ToastLeftGroup } from '../components/ToastLeftGroup';
|
import { ToastLeftGroup } from '../components/ToastLeftGroup';
|
||||||
|
@ -116,6 +112,8 @@ import {
|
||||||
} from '../services/LinkPreview';
|
} from '../services/LinkPreview';
|
||||||
import { LinkPreviewSourceType } from '../types/LinkPreview';
|
import { LinkPreviewSourceType } from '../types/LinkPreview';
|
||||||
import { closeLightbox, showLightbox } from '../util/showLightbox';
|
import { closeLightbox, showLightbox } from '../util/showLightbox';
|
||||||
|
import { saveAttachment } from '../util/saveAttachment';
|
||||||
|
import { sendDeleteForEveryoneMessage } from '../util/sendDeleteForEveryoneMessage';
|
||||||
|
|
||||||
type AttachmentOptions = {
|
type AttachmentOptions = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -133,13 +131,6 @@ const {
|
||||||
deleteTempFile,
|
deleteTempFile,
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
getAbsoluteTempPath,
|
getAbsoluteTempPath,
|
||||||
loadAttachmentData,
|
|
||||||
loadContactData,
|
|
||||||
loadPreviewData,
|
|
||||||
loadStickerData,
|
|
||||||
openFileInFolder,
|
|
||||||
readAttachmentData,
|
|
||||||
saveAttachmentToDisk,
|
|
||||||
upgradeMessageSchema,
|
upgradeMessageSchema,
|
||||||
} = window.Signal.Migrations;
|
} = window.Signal.Migrations;
|
||||||
|
|
||||||
|
@ -231,7 +222,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
// Sub-views
|
// Sub-views
|
||||||
private contactModalView?: Backbone.View;
|
private contactModalView?: Backbone.View;
|
||||||
private conversationView?: Backbone.View;
|
private conversationView?: Backbone.View;
|
||||||
private forwardMessageModal?: Backbone.View;
|
|
||||||
private lightboxView?: ReactWrapperView;
|
private lightboxView?: ReactWrapperView;
|
||||||
private migrationDialog?: Backbone.View;
|
private migrationDialog?: Backbone.View;
|
||||||
private stickerPreviewModalView?: Backbone.View;
|
private stickerPreviewModalView?: Backbone.View;
|
||||||
|
@ -1285,239 +1275,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async showForwardMessageModal(messageId: string): Promise<void> {
|
async showForwardMessageModal(messageId: string): Promise<void> {
|
||||||
const message = await getMessageById(messageId);
|
window.reduxActions.globalModals.toggleForwardMessageModal(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showAllMedia(): void {
|
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 ({
|
const onItemClick = async ({
|
||||||
message,
|
message,
|
||||||
attachment,
|
attachment,
|
||||||
|
@ -1663,7 +1397,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
}: ItemClickEvent) => {
|
}: ItemClickEvent) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'documents': {
|
case 'documents': {
|
||||||
saveAttachment({ message, attachment });
|
saveAttachment(attachment, message.sent_at);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1842,20 +1576,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = await Attachment.save({
|
return saveAttachment(attachment, timestamp);
|
||||||
attachment,
|
|
||||||
readAttachmentData,
|
|
||||||
saveAttachmentToDisk,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fullPath) {
|
|
||||||
showToast(ToastFileSaved, {
|
|
||||||
onOpenFile: () => {
|
|
||||||
openFileInFolder(fullPath);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async displayTapToViewMessage(messageId: string): Promise<void> {
|
async displayTapToViewMessage(messageId: string): Promise<void> {
|
||||||
|
@ -1975,7 +1696,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
okText: window.i18n('delete'),
|
okText: window.i18n('delete'),
|
||||||
resolve: async () => {
|
resolve: async () => {
|
||||||
try {
|
try {
|
||||||
await this.model.sendDeleteForEveryoneMessage({
|
await sendDeleteForEveryoneMessage(this.model.attributes, {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
timestamp: message.get('sent_at'),
|
timestamp: message.get('sent_at'),
|
||||||
});
|
});
|
||||||
|
@ -2028,21 +1749,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
message: MediaItemMessageType;
|
message: MediaItemMessageType;
|
||||||
index: number;
|
index: number;
|
||||||
}) => {
|
}) => {
|
||||||
const fullPath = await Attachment.save({
|
return saveAttachment(attachment, message.sent_at, index + 1);
|
||||||
attachment,
|
|
||||||
index: index + 1,
|
|
||||||
readAttachmentData,
|
|
||||||
saveAttachmentToDisk,
|
|
||||||
timestamp: message.sent_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fullPath) {
|
|
||||||
showToast(ToastFileSaved, {
|
|
||||||
onOpenFile: () => {
|
|
||||||
openFileInFolder(fullPath);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedIndex = media.findIndex(
|
const selectedIndex = media.findIndex(
|
||||||
|
|
|
@ -39,7 +39,6 @@ import { createStore } from './state/createStore';
|
||||||
import { createApp } from './state/roots/createApp';
|
import { createApp } from './state/roots/createApp';
|
||||||
import { createChatColorPicker } from './state/roots/createChatColorPicker';
|
import { createChatColorPicker } from './state/roots/createChatColorPicker';
|
||||||
import { createConversationDetails } from './state/roots/createConversationDetails';
|
import { createConversationDetails } from './state/roots/createConversationDetails';
|
||||||
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
|
|
||||||
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
||||||
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
||||||
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
||||||
|
@ -180,7 +179,6 @@ export type SignalCoreType = {
|
||||||
createApp: typeof createApp;
|
createApp: typeof createApp;
|
||||||
createChatColorPicker: typeof createChatColorPicker;
|
createChatColorPicker: typeof createChatColorPicker;
|
||||||
createConversationDetails: typeof createConversationDetails;
|
createConversationDetails: typeof createConversationDetails;
|
||||||
createForwardMessageModal: typeof createForwardMessageModal;
|
|
||||||
createGroupLinkManagement: typeof createGroupLinkManagement;
|
createGroupLinkManagement: typeof createGroupLinkManagement;
|
||||||
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
||||||
createGroupV2JoinModal: typeof createGroupV2JoinModal;
|
createGroupV2JoinModal: typeof createGroupV2JoinModal;
|
||||||
|
|
Loading…
Reference in New Issue