SafetyNumberChangeDialog: Introduce awareness of stories

This commit is contained in:
Scott Nonnenberg 2022-11-10 20:10:30 -08:00 committed by GitHub
parent 709588a874
commit 5100d17ed2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 2531 additions and 522 deletions

View file

@ -436,7 +436,7 @@
"description": "(deleted 2022/11/26) Shown on confirmation dialog when user attempts to send a message"
},
"safetyNumberChangeDialog__message": {
"message": "The following people may have reinstalled Signal or changed devices. Click a recipient to confirm the new safety number. This is optional.",
"message": "The following people may have reinstalled Signal or changed devices. Click a recipient to confirm their new safety number. This is optional.",
"description": "Shown on confirmation dialog when user attempts to send a message"
},
"safetyNumberChangeDialog__pending-messages": {
@ -451,6 +451,34 @@
"messageformat": "{count, plural, other {You have # connections who may have reinstalled Signal or changed devices. You can optionally review their safety numbers before sending.}}",
"description": "Shown during an attempted send when more than five contacts have changed their safety numbers"
},
"safetyNumberChangeDialog__post-review": {
"message": "All connections have been reviewed, click send to continue.",
"description": "Shown after reviewing large number of contacts"
},
"icu:safetyNumberChangeDialog__confirm-remove-all": {
"messageformat": "Are you sure you want to remove {count, plural, 1 {one recipient} other {# recipients}} from story {story}?",
"description": "Shown if user selects 'remove all' option to remove all potentially untrusted contacts from a given story"
},
"safetyNumberChangeDialog__remove-all": {
"message": "Remove all",
"description": "Shown in the context menu for a story header, to remove all contacts within from their parent list"
},
"safetyNumberChangeDialog__verify-number": {
"message": "Verify safety number",
"description": "Shown in the context menu for a story recipient header, to verify that they are still trusted"
},
"safetyNumberChangeDialog__remove": {
"message": "Remove from story",
"description": "Shown in the context menu for a story recipient, to remove this contact from from their parent list"
},
"safetyNumberChangeDialog__actions-contact": {
"message": "Actions for contact $contact$",
"description": "Label for button that opens context menu for contact"
},
"safetyNumberChangeDialog__actions-story": {
"message": "Actions for story $story$",
"description": "Label for button that opens context menu for story"
},
"identityKeyErrorOnSend": {
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change"
@ -463,6 +491,10 @@
"message": "Send",
"description": "Used on a warning dialog to make it clear that it might be risky to send the message."
},
"safetyNumberChangeDialog_done": {
"message": "Done",
"description": "Used when there are enough safety number changes to require an explicit review step, to signal that the review is complete."
},
"callAnyway": {
"message": "Call anyway",
"description": "Used on a warning dialog to make it clear that it might be risky to call the conversation."

View file

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_68)">
<path d="M5.25419 5.25419C5.48199 5.02638 5.85134 5.02638 6.07915 5.25419L8 7.17504L9.92085 5.25419C10.1487 5.02638 10.518 5.02638 10.7458 5.25419C10.9736 5.48199 10.9736 5.85134 10.7458 6.07915L8.82496 8L10.7458 9.92085C10.9736 10.1487 10.9736 10.518 10.7458 10.7458C10.518 10.9736 10.1487 10.9736 9.92085 10.7458L8 8.82496L6.07915 10.7458C5.85134 10.9736 5.48199 10.9736 5.25419 10.7458C5.02638 10.518 5.02638 10.1487 5.25419 9.92085L7.17504 8L5.25419 6.07915C5.02638 5.85134 5.02638 5.48199 5.25419 5.25419Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.75 8C0.75 3.99594 3.99594 0.75 8 0.75C12.0041 0.75 15.25 3.99594 15.25 8C15.25 12.0041 12.0041 15.25 8 15.25C3.99594 15.25 0.75 12.0041 0.75 8ZM8 1.91667C4.64027 1.91667 1.91667 4.64027 1.91667 8C1.91667 11.3597 4.64027 14.0833 8 14.0833C11.3597 14.0833 14.0833 11.3597 14.0833 8C14.0833 4.64027 11.3597 1.91667 8 1.91667Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1_68">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -65,22 +65,33 @@
@include dark-theme {
color: $color-gray-25;
}
&--narrow {
padding-left: 38px;
padding-right: 38px;
}
}
&__contacts {
list-style-type: none;
max-height: 300px;
padding: 0;
padding: 0px;
margin-block-end: 0px;
}
&__contact {
$contact: &;
&__row {
$row: &;
align-items: center;
display: flex;
flex-direction: row;
margin-bottom: 16px;
&__story-name {
@include font-body-1-bold;
flex-grow: 1;
margin-right: auto;
}
&--wrapper {
flex-grow: 1;
margin-left: 12px;
@ -106,7 +117,7 @@
}
}
&--view {
&__view {
@include button-reset;
@include button-secondary-blue-text;
@ -114,15 +125,119 @@
transition: opacity 150ms cubic-bezier(0.17, 0.17, 0, 1);
// Using keyboard/mouse classes directly; mixins were doing weird things
.mouse-mode #{$contact}:hover & {
.mouse-mode #{$row}:hover & {
opacity: 1;
}
.keyboard-mode #{$contact}:focus-within & {
.keyboard-mode #{$row}:focus-within & {
opacity: 1;
}
border-radius: 4px;
padding: 8px 14px;
}
&__chevron__option {
padding: 10px 15px;
.ContextMenu__popper--single-item & {
padding: 10px 15px;
}
&--container {
align-items: center;
}
}
&__chevron__button {
@include button-reset;
display: flex;
align-items: center;
flex-grow: 0;
flex-shrink: 0;
padding: 10px;
height: 16px;
width: 16px;
justify-content: center;
border-radius: 4px;
border: 2px solid transparent;
opacity: 0;
transition: opacity 150ms cubic-bezier(0.17, 0.17, 0, 1);
// Using keyboard/mouse classes directly; mixins were doing weird things
.mouse-mode #{$row}:hover & {
opacity: 1;
}
.keyboard-mode #{$row}:focus-within & {
opacity: 1;
}
@include keyboard-mode {
&:focus {
border-color: $color-ultramarine;
}
}
@include dark-keyboard-mode {
&:focus {
border-color: $color-ultramarine-light;
}
}
&::before {
content: '';
display: block;
height: 16px;
width: 16px;
flex-shrink: 0;
@include light-theme {
@include color-svg(
'../images/icons/v2/chevron-down-16.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/chevron-down-16.svg',
$color-gray-25
);
}
}
}
}
&__menu-icon {
&--delete {
@include light-theme {
@include color-svg(
'../images/icons/v2/x-circle-16.svg',
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/x-circle-16.svg',
$color-gray-05
);
}
}
&--verify {
@include light-theme {
@include color-svg(
'../images/icons/v2/safety-number-outline-24.svg',
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/safety-number-outline-24.svg',
$color-gray-05
);
}
}
}
}

View file

@ -356,7 +356,12 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
activeCall.conversationsWithSafetyNumberChanges.length ? (
<SafetyNumberChangeDialog
confirmText={i18n('continueCall')}
contacts={activeCall.conversationsWithSafetyNumberChanges}
contacts={[
{
story: undefined,
contacts: activeCall.conversationsWithSafetyNumberChanges,
},
]}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
onCancel={onSafetyNumberDialogCancel}

View file

@ -10,6 +10,7 @@ import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import { getFakeBadge } from '../test-both/helpers/getFakeBadge';
import { MY_STORY_ID } from '../types/Stories';
const i18n = setupI18n('en', enMessages);
@ -61,11 +62,17 @@ export const SingleContactDialog = (): JSX.Element => {
const theme = useTheme();
return (
<SafetyNumberChangeDialog
contacts={[contactWithAllData]}
contacts={[
{
story: undefined,
contacts: [contactWithAllData],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
@ -80,11 +87,17 @@ export const DifferentConfirmationText = (): JSX.Element => {
return (
<SafetyNumberChangeDialog
confirmText="You are awesome"
contacts={[contactWithAllData]}
contacts={[
{
story: undefined,
contacts: [contactWithAllData],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
@ -99,15 +112,20 @@ export const MultiContactDialog = (): JSX.Element => {
return (
<SafetyNumberChangeDialog
contacts={[
contactWithAllData,
contactWithJustProfileVerified,
contactWithJustNumberVerified,
contactWithNothing,
{
story: undefined,
contacts: [contactWithAllData, contactWithJustProfileVerified],
},
{
story: undefined,
contacts: [contactWithJustNumberVerified, contactWithNothing],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
@ -121,11 +139,20 @@ export const AllVerified = (): JSX.Element => {
const theme = useTheme();
return (
<SafetyNumberChangeDialog
contacts={[contactWithJustProfileVerified, contactWithJustNumberVerified]}
contacts={[
{
story: undefined,
contacts: [
contactWithJustProfileVerified,
contactWithJustNumberVerified,
],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
@ -143,15 +170,21 @@ export const MultipleContactsAllWithBadges = (): JSX.Element => {
return (
<SafetyNumberChangeDialog
contacts={[
contactWithAllData,
contactWithJustProfileVerified,
contactWithJustNumberVerified,
contactWithNothing,
{
story: undefined,
contacts: [
contactWithAllData,
contactWithJustProfileVerified,
contactWithJustNumberVerified,
contactWithNothing,
],
},
]}
getPreferredBadge={() => getFakeBadge()}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
@ -170,21 +203,27 @@ export const TenContacts = (): JSX.Element => {
return (
<SafetyNumberChangeDialog
contacts={[
contactWithAllData,
contactWithJustProfileVerified,
contactWithJustNumberVerified,
contactWithNothing,
contactWithAllData,
contactWithAllData,
contactWithAllData,
contactWithAllData,
contactWithAllData,
contactWithAllData,
{
story: undefined,
contacts: [
contactWithAllData,
contactWithJustProfileVerified,
contactWithJustNumberVerified,
contactWithNothing,
contactWithAllData,
contactWithAllData,
contactWithAllData,
contactWithAllData,
contactWithAllData,
contactWithAllData,
],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
@ -197,3 +236,90 @@ export const TenContacts = (): JSX.Element => {
TenContacts.story = {
name: 'Ten contacts; first isReviewing = false, then scrolling dialog',
};
export const NoContacts = (): JSX.Element => {
const theme = useTheme();
return (
<SafetyNumberChangeDialog
contacts={[
{
story: {
name: 'My Story',
conversationId: 'our-conversation-id',
distributionId: MY_STORY_ID,
},
contacts: [],
},
{
story: {
name: 'Custom List A',
conversationId: 'our-conversation-id',
distributionId: 'some-other-distribution-id',
},
contacts: [],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
}}
theme={theme}
/>
);
};
export const InMultipleStories = (): JSX.Element => {
const theme = useTheme();
return (
<SafetyNumberChangeDialog
contacts={[
{
story: {
name: 'Not to be trusted',
conversationId: 'our-conversation-id',
distributionId: MY_STORY_ID,
},
contacts: [contactWithAllData, contactWithJustProfileVerified],
},
{
story: {
name: 'Custom List A',
conversationId: 'our-conversation-id',
distributionId: 'some-other-distribution-id',
},
contacts: [
contactWithAllData,
contactWithAllData,
contactWithAllData,
],
},
{
story: {
name: 'Hiking Buds',
conversationId: 'hiking-group-id',
},
contacts: [
contactWithJustNumberVerified,
contactWithAllData,
contactWithAllData,
],
},
]}
getPreferredBadge={() => undefined}
i18n={i18n}
onCancel={action('cancel')}
onConfirm={action('confirm')}
removeFromStory={action('removeFromStory')}
renderSafetyNumber={() => {
action('renderSafetyNumber');
return <div>This is a mock Safety Number View</div>;
}}
theme={theme}
/>
);
};

View file

@ -3,6 +3,7 @@
import * as React from 'react';
import { noop } from 'lodash';
import classNames from 'classnames';
import { Avatar } from './Avatar';
import type { ActionSpec } from './ConfirmationDialog';
@ -12,8 +13,15 @@ import { Modal } from './Modal';
import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
import { ThemeType } from '../types/Util';
import { isInSystemContacts } from '../util/isInSystemContacts';
import { missingCaseError } from '../util/missingCaseError';
import { ContextMenu } from './ContextMenu';
import { Theme } from '../util/theme';
import { isNotNil } from '../util/isNotNil';
import { MY_STORY_ID } from '../types/Stories';
import type { UUIDStringType } from '../types/UUID';
export enum SafetyNumberChangeSource {
Calling = 'Calling',
@ -21,21 +29,60 @@ export enum SafetyNumberChangeSource {
Story = 'Story',
}
enum DialogState {
StartingInReview = 'StartingInReview',
ExplicitReviewNeeded = 'ExplicitReviewNeeded',
ExplicitReviewStep = 'ExplicitReviewStep',
ExplicitReviewComplete = 'ExplicitReviewComplete',
}
export type SafetyNumberProps = {
contactID: string;
onClose: () => void;
};
export type Props = {
readonly confirmText?: string;
readonly contacts: Array<ConversationType>;
readonly getPreferredBadge: PreferredBadgeSelectorType;
readonly i18n: LocalizerType;
readonly onCancel: () => void;
readonly onConfirm: () => void;
readonly renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
readonly theme: ThemeType;
type StoryContacts = {
story?: {
name: string;
// For My Story or custom distribution lists, conversationId will be our own
conversationId: string;
// For Group stories, distributionId will not be provided
distributionId?: string;
};
contacts: Array<ConversationType>;
};
export type ContactsByStory = Array<StoryContacts>;
export type Props = Readonly<{
confirmText?: string;
contacts: ContactsByStory;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
onCancel: () => void;
onConfirm: () => void;
removeFromStory?: (
distributionId: string,
uuids: Array<UUIDStringType>
) => unknown;
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
theme: ThemeType;
}>;
function doesRequireExplicitReviewMode(count: number) {
return count > 5;
}
function getStartingDialogState(count: number): DialogState {
if (count === 0) {
return DialogState.ExplicitReviewComplete;
}
if (doesRequireExplicitReviewMode(count)) {
return DialogState.ExplicitReviewNeeded;
}
return DialogState.StartingInReview;
}
export const SafetyNumberChangeDialog = ({
confirmText,
@ -44,11 +91,19 @@ export const SafetyNumberChangeDialog = ({
i18n,
onCancel,
onConfirm,
removeFromStory,
renderSafetyNumber,
theme,
}: Props): JSX.Element => {
const [isReviewing, setIsReviewing] = React.useState<boolean>(
contacts.length <= 5
const totalCount = contacts.reduce(
(count, item) => count + item.contacts.length,
0
);
const allVerified = contacts.every(item =>
item.contacts.every(contact => contact.isVerified)
);
const [dialogState, setDialogState] = React.useState<DialogState>(
getStartingDialogState(totalCount)
);
const [selectedContact, setSelectedContact] = React.useState<
ConversationType | undefined
@ -61,6 +116,15 @@ export const SafetyNumberChangeDialog = ({
}
}, [cancelButtonRef, contacts]);
React.useEffect(() => {
if (
dialogState === DialogState.ExplicitReviewStep &&
(totalCount === 0 || allVerified)
) {
setDialogState(DialogState.ExplicitReviewComplete);
}
}, [allVerified, dialogState, setDialogState, totalCount]);
const onClose = selectedContact
? () => {
setSelectedContact(undefined);
@ -80,30 +144,40 @@ export const SafetyNumberChangeDialog = ({
);
}
const allVerified = contacts.every(contact => contact.isVerified);
const actions: Array<ActionSpec> = [
{
action: onConfirm,
text:
confirmText ||
(allVerified
? i18n('safetyNumberChangeDialog_send')
: i18n('sendAnyway')),
style: 'affirmative',
},
];
if (
dialogState === DialogState.StartingInReview ||
dialogState === DialogState.ExplicitReviewStep
) {
let text: string;
if (dialogState === DialogState.ExplicitReviewStep) {
text = i18n('safetyNumberChangeDialog_done');
} else if (allVerified || totalCount === 0) {
text = confirmText || i18n('safetyNumberChangeDialog_send');
} else {
text = confirmText || i18n('sendAnyway');
}
if (isReviewing) {
return (
<ConfirmationDialog
key="SafetyNumberChangeDialog.reviewing"
dialogName="SafetyNumberChangeDialog.reviewing"
actions={actions}
actions={[
{
action: () => {
if (dialogState === DialogState.ExplicitReviewStep) {
setDialogState(DialogState.ExplicitReviewComplete);
} else {
onConfirm();
}
},
text,
style: 'affirmative',
},
]}
hasXButton
i18n={i18n}
moduleClassName="module-SafetyNumberChangeDialog__confirm-dialog"
noMouseClose
noDefaultCancelButton={!isReviewing}
onCancel={onClose}
onClose={noop}
>
@ -114,32 +188,44 @@ export const SafetyNumberChangeDialog = ({
<div className="module-SafetyNumberChangeDialog__message">
{i18n('safetyNumberChangeDialog__message')}
</div>
<ul className="module-SafetyNumberChangeDialog__contacts">
{contacts.map((contact: ConversationType) => {
const shouldShowNumber = Boolean(
contact.name || contact.profileName
);
return (
<ContactRow
contact={contact}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
setSelectedContact={setSelectedContact}
shouldShowNumber={shouldShowNumber}
theme={theme}
/>
);
})}
</ul>
{contacts.map((section: StoryContacts) => (
<ContactSection
key={section.story?.name || 'default'}
section={section}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
removeFromStory={removeFromStory}
setSelectedContact={setSelectedContact}
theme={theme}
/>
))}
</ConfirmationDialog>
);
}
actions.unshift({
action: () => setIsReviewing(true),
text: i18n('safetyNumberChangeDialog__review'),
});
let text: string;
if (dialogState === DialogState.ExplicitReviewNeeded) {
text = confirmText || i18n('sendAnyway');
} else if (dialogState === DialogState.ExplicitReviewComplete) {
text = confirmText || i18n('safetyNumberChangeDialog_send');
} else {
throw missingCaseError(dialogState);
}
const actions: Array<ActionSpec> = [
{
action: onConfirm,
text,
style: 'affirmative',
},
];
if (dialogState === DialogState.ExplicitReviewNeeded) {
actions.unshift({
action: () => setDialogState(DialogState.ExplicitReviewStep),
text: i18n('safetyNumberChangeDialog__review'),
});
}
return (
<ConfirmationDialog
@ -150,7 +236,7 @@ export const SafetyNumberChangeDialog = ({
i18n={i18n}
moduleClassName="module-SafetyNumberChangeDialog__confirm-dialog"
noMouseClose
noDefaultCancelButton={!isReviewing}
noDefaultCancelButton={dialogState === DialogState.ExplicitReviewNeeded}
onCancel={onClose}
onClose={noop}
>
@ -158,34 +244,205 @@ export const SafetyNumberChangeDialog = ({
<div className="module-SafetyNumberChangeDialog__title">
{i18n('safetyNumberChanges')}
</div>
<div className="module-SafetyNumberChangeDialog__message">
{i18n('icu:safetyNumberChangeDialog__many-contacts', {
count: contacts.length,
})}
<div
className={classNames(
'module-SafetyNumberChangeDialog__message',
dialogState === DialogState.ExplicitReviewComplete
? 'module-SafetyNumberChangeDialog__message--narrow'
: undefined
)}
>
{dialogState === DialogState.ExplicitReviewNeeded
? i18n('icu:safetyNumberChangeDialog__many-contacts', {
count: totalCount,
})
: i18n('safetyNumberChangeDialog__post-review')}
</div>
</ConfirmationDialog>
);
};
type ContactRowProps = Readonly<{
contact: ConversationType;
function ContactSection({
section,
getPreferredBadge,
i18n,
removeFromStory,
setSelectedContact,
theme,
}: Readonly<{
section: StoryContacts;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
removeFromStory?: (
distributionId: string,
uuids: Array<UUIDStringType>
) => unknown;
setSelectedContact: (contact: ConversationType) => void;
shouldShowNumber: boolean;
theme: ThemeType;
}>;
}>) {
if (section.contacts.length === 0) {
return null;
}
if (!section.story) {
return (
<ul className="module-SafetyNumberChangeDialog__contacts">
{section.contacts.map((contact: ConversationType) => {
const shouldShowNumber = Boolean(contact.name || contact.profileName);
return (
<ContactRow
key={contact.id}
contact={contact}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
removeFromStory={removeFromStory}
setSelectedContact={setSelectedContact}
shouldShowNumber={shouldShowNumber}
theme={theme}
/>
);
})}
</ul>
);
}
const { distributionId } = section.story;
const uuids = section.contacts.map(contact => contact.uuid).filter(isNotNil);
const sectionName =
distributionId === MY_STORY_ID ? i18n('Stories__mine') : section.story.name;
return (
<div className="module-SafetyNumberChangeDialog__section">
<div className="module-SafetyNumberChangeDialog__row">
<div className="module-SafetyNumberChangeDialog__row__story-name">
{sectionName}
</div>
{distributionId && removeFromStory && uuids.length > 1 && (
<SectionButtonWithMenu
ariaLabel={i18n('safetyNumberChangeDialog__actions-story', {
story: sectionName,
})}
i18n={i18n}
memberCount={uuids.length}
storyName={sectionName}
theme={theme}
removeFromStory={() => {
removeFromStory(distributionId, uuids);
}}
/>
)}
</div>
<ul className="module-SafetyNumberChangeDialog__contacts">
{section.contacts.map((contact: ConversationType) => {
const shouldShowNumber = Boolean(contact.name || contact.profileName);
return (
<ContactRow
key={contact.id}
contact={contact}
distributionId={distributionId}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
removeFromStory={removeFromStory}
setSelectedContact={setSelectedContact}
shouldShowNumber={shouldShowNumber}
theme={theme}
/>
);
})}
</ul>
</div>
);
}
function SectionButtonWithMenu({
ariaLabel,
i18n,
removeFromStory,
storyName,
memberCount,
theme,
}: Readonly<{
ariaLabel: string;
i18n: LocalizerType;
removeFromStory: () => unknown;
storyName: string;
memberCount: number;
theme: ThemeType;
}>) {
const [isConfirming, setIsConfirming] = React.useState<boolean>(false);
return (
<>
<ContextMenu
ariaLabel={ariaLabel}
i18n={i18n}
menuOptions={[
{
icon: 'module-SafetyNumberChangeDialog__menu-icon--delete',
label: i18n('safetyNumberChangeDialog__remove-all'),
onClick: () => setIsConfirming(true),
},
]}
moduleClassName="module-SafetyNumberChangeDialog__row__chevron"
theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light}
/>
{isConfirming && (
<ConfirmationDialog
key="SafetyNumberChangeDialog.confirm-remove-all"
dialogName="SafetyNumberChangeDialog.confirm-remove-all"
actions={[
{
action: () => {
removeFromStory();
setIsConfirming(false);
},
text: i18n('safetyNumberChangeDialog__remove-all'),
style: 'affirmative',
},
]}
i18n={i18n}
noMouseClose
onCancel={() => setIsConfirming(false)}
onClose={noop}
>
{i18n('icu:safetyNumberChangeDialog__confirm-remove-all', {
story: storyName,
count: memberCount,
})}
</ConfirmationDialog>
)}
</>
);
}
function ContactRow({
contact,
distributionId,
getPreferredBadge,
i18n,
removeFromStory,
setSelectedContact,
shouldShowNumber,
theme,
}: ContactRowProps) {
}: Readonly<{
contact: ConversationType;
distributionId?: string;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
removeFromStory?: (
distributionId: string,
uuids: Array<UUIDStringType>
) => unknown;
setSelectedContact: (contact: ConversationType) => void;
shouldShowNumber: boolean;
theme: ThemeType;
}>) {
const { uuid } = contact;
return (
<li className="module-SafetyNumberChangeDialog__contact" key={contact.id}>
<li className="module-SafetyNumberChangeDialog__row" key={contact.id}>
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
@ -202,52 +459,93 @@ function ContactRow({
size={36}
unblurredAvatarPath={contact.unblurredAvatarPath}
/>
<div className="module-SafetyNumberChangeDialog__contact--wrapper">
<div className="module-SafetyNumberChangeDialog__contact--name">
<div className="module-SafetyNumberChangeDialog__row--wrapper">
<div className="module-SafetyNumberChangeDialog__row--name">
{contact.title}
{isInSystemContacts(contact) ? (
{isInSystemContacts(contact) && (
<span>
{' '}
<InContactsIcon i18n={i18n} />
</span>
) : null}
)}
</div>
{shouldShowNumber || contact.isVerified ? (
<div className="module-SafetyNumberChangeDialog__contact--subtitle">
{shouldShowNumber ? (
<div className="module-SafetyNumberChangeDialog__row--subtitle">
{shouldShowNumber && (
<span className="module-SafetyNumberChangeDialog__rtl-span">
{contact.phoneNumber}
</span>
) : (
''
)}
{shouldShowNumber && contact.isVerified ? (
{shouldShowNumber && contact.isVerified && (
<span className="module-SafetyNumberChangeDialog__rtl-span">
&nbsp;&middot;&nbsp;
</span>
) : (
''
)}
{contact.isVerified ? (
{contact.isVerified && (
<span className="module-SafetyNumberChangeDialog__rtl-span">
{i18n('verified')}
</span>
) : (
''
)}
</div>
) : null}
</div>
<button
className="module-SafetyNumberChangeDialog__contact--view"
onClick={() => {
setSelectedContact(contact);
}}
tabIndex={0}
type="button"
>
{i18n('view')}
</button>
{distributionId && removeFromStory && uuid ? (
<RowButtonWithMenu
ariaLabel={i18n('safetyNumberChangeDialog__actions-contact', {
contact: contact.title,
})}
i18n={i18n}
theme={theme}
removeFromStory={() => removeFromStory(distributionId, [uuid])}
verifyContact={() => setSelectedContact(contact)}
/>
) : (
<button
className="module-SafetyNumberChangeDialog__row__view"
onClick={() => {
setSelectedContact(contact);
}}
tabIndex={0}
type="button"
>
{i18n('view')}
</button>
)}
</li>
);
}
function RowButtonWithMenu({
ariaLabel,
i18n,
removeFromStory,
verifyContact,
theme,
}: Readonly<{
ariaLabel: string;
i18n: LocalizerType;
removeFromStory: () => unknown;
verifyContact: () => unknown;
theme: ThemeType;
}>) {
return (
<ContextMenu
ariaLabel={ariaLabel}
i18n={i18n}
menuOptions={[
{
icon: 'module-SafetyNumberChangeDialog__menu-icon--verify',
label: i18n('safetyNumberChangeDialog__verify-number'),
onClick: verifyContact,
},
{
icon: 'module-SafetyNumberChangeDialog__menu-icon--delete',
label: i18n('safetyNumberChangeDialog__remove'),
onClick: removeFromStory,
},
]}
moduleClassName="module-SafetyNumberChangeDialog__row__chevron"
theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light}
/>
);
}

View file

@ -48,6 +48,7 @@ export type PropsType = {
groupConversations: Array<ConversationType>;
groupStories: Array<ConversationType>;
hasFirstStoryPostExperience: boolean;
ourConversationId: string | undefined;
i18n: LocalizerType;
me: ConversationType;
onClose: () => unknown;
@ -56,7 +57,11 @@ export type PropsType = {
name: string,
viewerUuids: Array<UUIDStringType>
) => unknown;
onSelectedStoryList: (memberUuids: Array<string>) => unknown;
onSelectedStoryList: (options: {
conversationId: string;
distributionId: string | undefined;
uuids: Array<UUIDStringType>;
}) => unknown;
onSend: (
listIds: Array<UUIDStringType>,
conversationIds: Array<string>
@ -70,7 +75,7 @@ export type PropsType = {
} & Pick<
StoriesSettingsModalPropsType,
| 'onHideMyStoriesFrom'
| 'onRemoveMember'
| 'onRemoveMembers'
| 'onRepliesNReactionsChanged'
| 'onViewersUpdated'
| 'setMyStoriesToAllSignalConnections'
@ -94,7 +99,7 @@ type PageType = SendStoryPage | StoriesSettingsPage;
function getListMemberUuids(
list: StoryDistributionListWithMembersDataType,
signalConnections: Array<ConversationType>
): Array<string> {
): Array<UUIDStringType> {
const memberUuids = list.members.map(({ uuid }) => uuid).filter(isNotNil);
if (list.id === MY_STORY_ID && list.isBlockList) {
@ -118,11 +123,12 @@ export const SendStoryModal = ({
hasFirstStoryPostExperience,
i18n,
me,
ourConversationId,
onClose,
onDeleteList,
onDistributionListCreated,
onHideMyStoriesFrom,
onRemoveMember,
onRemoveMembers,
onRepliesNReactionsChanged,
onSelectedStoryList,
onSend,
@ -387,7 +393,7 @@ export const SendStoryModal = ({
i18n={i18n}
listToEdit={listToEdit}
signalConnectionsCount={signalConnections.length}
onRemoveMember={onRemoveMember}
onRemoveMembers={onRemoveMembers}
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
setConfirmDeleteList={setConfirmDeleteList}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
@ -636,8 +642,12 @@ export const SendStoryModal = ({
}
return new Set([...listIds]);
});
if (value) {
onSelectedStoryList(getListMemberUuids(list, signalConnections));
if (value && ourConversationId) {
onSelectedStoryList({
conversationId: ourConversationId,
distributionId: list.id,
uuids: getListMemberUuids(list, signalConnections),
});
}
}}
>
@ -763,7 +773,11 @@ export const SendStoryModal = ({
return new Set([...groupIds]);
});
if (value) {
onSelectedStoryList(group.memberships.map(({ uuid }) => uuid));
onSelectedStoryList({
conversationId: group.id,
distributionId: undefined,
uuids: group.memberships.map(({ uuid }) => uuid),
});
}
}}
>

View file

@ -48,7 +48,7 @@ export default {
toggleGroupsForStorySend: { action: true },
onDistributionListCreated: { action: true },
onHideMyStoriesFrom: { action: true },
onRemoveMember: { action: true },
onRemoveMembers: { action: true },
onRepliesNReactionsChanged: { action: true },
onViewersUpdated: { action: true },
setMyStoriesToAllSignalConnections: { action: true },

View file

@ -38,6 +38,7 @@ import {
} from '../util/shouldNeverBeCalled';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { getGroupMemberships } from '../util/getGroupMemberships';
import { strictAssert } from '../util/assert';
export type PropsType = {
candidateConversations: Array<ConversationType>;
@ -55,7 +56,7 @@ export type PropsType = {
viewerUuids: Array<UUIDStringType>
) => unknown;
onHideMyStoriesFrom: (viewerUuids: Array<UUIDStringType>) => unknown;
onRemoveMember: (listId: string, uuid: UUIDStringType | undefined) => unknown;
onRemoveMembers: (listId: string, uuids: Array<UUIDStringType>) => unknown;
onRepliesNReactionsChanged: (
listId: string,
allowsReplies: boolean
@ -248,7 +249,7 @@ export const StoriesSettingsModal = ({
toggleGroupsForStorySend,
onDistributionListCreated,
onHideMyStoriesFrom,
onRemoveMember,
onRemoveMembers,
onRepliesNReactionsChanged,
onViewersUpdated,
setMyStoriesToAllSignalConnections,
@ -355,7 +356,7 @@ export const StoriesSettingsModal = ({
i18n={i18n}
listToEdit={listToEdit}
signalConnectionsCount={signalConnections.length}
onRemoveMember={onRemoveMember}
onRemoveMembers={onRemoveMembers}
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
setConfirmDeleteList={setConfirmDeleteList}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
@ -552,7 +553,7 @@ type DistributionListSettingsModalPropsType = {
} & Pick<
PropsType,
| 'getPreferredBadge'
| 'onRemoveMember'
| 'onRemoveMembers'
| 'onRepliesNReactionsChanged'
| 'setMyStoriesToAllSignalConnections'
| 'toggleSignalConnectionsModal'
@ -562,7 +563,7 @@ export const DistributionListSettingsModal = ({
getPreferredBadge,
i18n,
listToEdit,
onRemoveMember,
onRemoveMembers,
onRepliesNReactionsChanged,
onBackButtonClick,
onClose,
@ -578,7 +579,7 @@ export const DistributionListSettingsModal = ({
| {
listId: string;
title: string;
uuid: UUIDStringType | undefined;
uuid: UUIDStringType;
}
>();
@ -689,13 +690,14 @@ export const DistributionListSettingsModal = ({
member.title,
])}
className="StoriesSettingsModal__list__delete"
onClick={() =>
onClick={() => {
strictAssert(member.uuid, 'Story member was missing uuid');
setConfirmRemoveMember({
listId: listToEdit.id,
title: member.title,
uuid: member.uuid,
})
}
});
}}
type="button"
/>
</div>
@ -738,10 +740,9 @@ export const DistributionListSettingsModal = ({
actions={[
{
action: () =>
onRemoveMember(
confirmRemoveMember.listId,
confirmRemoveMember.uuid
),
onRemoveMembers(confirmRemoveMember.listId, [
confirmRemoveMember.uuid,
]),
style: 'negative',
text: i18n('StoriesSettings__remove--action'),
},

View file

@ -55,10 +55,11 @@ export type PropsType = {
| 'groupStories'
| 'hasFirstStoryPostExperience'
| 'me'
| 'ourConversationId'
| 'onDeleteList'
| 'onDistributionListCreated'
| 'onHideMyStoriesFrom'
| 'onRemoveMember'
| 'onRemoveMembers'
| 'onRepliesNReactionsChanged'
| 'onSelectedStoryList'
| 'onViewersUpdated'
@ -83,11 +84,12 @@ export const StoryCreator = ({
isSending,
linkPreview,
me,
ourConversationId,
onClose,
onDeleteList,
onDistributionListCreated,
onHideMyStoriesFrom,
onRemoveMember,
onRemoveMembers,
onRepliesNReactionsChanged,
onSelectedStoryList,
onSend,
@ -154,13 +156,14 @@ export const StoryCreator = ({
groupConversations={groupConversations}
groupStories={groupStories}
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
ourConversationId={ourConversationId}
i18n={i18n}
me={me}
onClose={() => setDraftAttachment(undefined)}
onDeleteList={onDeleteList}
onDistributionListCreated={onDistributionListCreated}
onHideMyStoriesFrom={onHideMyStoriesFrom}
onRemoveMember={onRemoveMember}
onRemoveMembers={onRemoveMembers}
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
onSelectedStoryList={onSelectedStoryList}
onSend={(listIds, groupIds) => {

View file

@ -35,6 +35,7 @@ import { explodePromise } from '../util/explodePromise';
import type { Job } from './Job';
import type { ParsedJob } from './types';
import type SendMessage from '../textsecure/SendMessage';
import type { UUIDStringType } from '../types/UUID';
// Note: generally, we only want to add to this list. If you do need to change one of
// these values, you'll likely need to write a database migration.
@ -361,7 +362,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
}
}
} catch (error: unknown) {
const untrustedUuids: Array<string> = [];
const untrustedUuids: Array<UUIDStringType> = [];
const processError = (toProcess: unknown) => {
if (toProcess instanceof OutgoingIdentityKeyError) {

View file

@ -3,10 +3,11 @@
import { isNotNil } from '../../util/isNotNil';
import * as log from '../../logging/log';
import type { UUIDStringType } from '../../types/UUID';
export function getUntrustedConversationUuids(
recipients: ReadonlyArray<string>
): Array<string> {
): Array<UUIDStringType> {
return recipients
.map(recipient => {
const recipientConversation = window.ConversationController.getOrCreate(

View file

@ -36,6 +36,7 @@ import { ourProfileKeyService } from '../../services/ourProfileKey';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { isConversationAccepted } from '../../util/isConversationAccepted';
import { sendToGroup } from '../../util/sendToGroup';
import type { UUIDStringType } from '../../types/UUID';
export async function sendNormalMessage(
conversation: ConversationModel,
@ -387,11 +388,11 @@ function getMessageRecipients({
allRecipientIdentifiers: Array<string>;
recipientIdentifiersWithoutMe: Array<string>;
sentRecipientIdentifiers: Array<string>;
untrustedUuids: Array<string>;
untrustedUuids: Array<UUIDStringType>;
} {
const allRecipientIdentifiers: Array<string> = [];
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedUuids: Array<string> = [];
const untrustedUuids: Array<UUIDStringType> = [];
const sentRecipientIdentifiers: Array<string> = [];
const currentConversationRecipients = conversation.getMemberConversationIds();

View file

@ -26,6 +26,7 @@ import { ourProfileKeyService } from '../../services/ourProfileKey';
import { canReact, isStory } from '../../state/selectors/message';
import { findAndFormatContact } from '../../util/findAndFormatContact';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { incrementMessageCounter } from '../../util/incrementMessageCounter';
@ -377,11 +378,11 @@ function getRecipients(
): {
allRecipientIdentifiers: Array<string>;
recipientIdentifiersWithoutMe: Array<string>;
untrustedUuids: Array<string>;
untrustedUuids: Array<UUIDStringType>;
} {
const allRecipientIdentifiers: Array<string> = [];
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedUuids: Array<string> = [];
const untrustedUuids: Array<UUIDStringType> = [];
const currentConversationRecipients = conversation.getMemberConversationIds();
@ -413,7 +414,6 @@ function getRecipients(
continue;
}
if (recipient.isUnregistered()) {
untrustedUuids.push(recipientIdentifier);
continue;
}

View file

@ -18,6 +18,11 @@ import type {
SendState,
SendStateByConversationId,
} from '../../messages/MessageSendState';
import {
isSent,
SendActionType,
sendStateReducer,
} from '../../messages/MessageSendState';
import type { UUIDStringType } from '../../types/UUID';
import * as Errors from '../../types/errors';
import dataInterface from '../../sql/Client';
@ -31,7 +36,6 @@ import { handleMessageSend } from '../../util/handleMessageSend';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
import { isNotNil } from '../../util/isNotNil';
import { isSent } from '../../messages/MessageSendState';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { sendContentMessageToGroup } from '../../util/sendToGroup';
import { SendMessageChallengeError } from '../../textsecure/Errors';
@ -176,9 +180,12 @@ export async function sendStory(
return;
}
const distributionId = message.get('storyDistributionListId');
const logId = `stories.sendStory(${timestamp}/${distributionId})`;
if (message.get('timestamp') !== timestamp) {
log.error(
`stories.sendStory(${timestamp}): Message timestamp ${message.get(
`${logId}: Message timestamp ${message.get(
'timestamp'
)} does not match job timestamp`
);
@ -188,15 +195,13 @@ export async function sendStory(
const messageConversation = message.getConversation();
if (messageConversation !== conversation) {
log.error(
`stories.sendStory(${timestamp}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
`${logId}: Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
);
return;
}
if (message.isErased() || message.get('deletedForEveryone')) {
log.info(
`stories.sendStory(${timestamp}): message was erased. Giving up on sending it`
);
log.info(`${logId}: message was erased. Giving up on sending it`);
return;
}
@ -207,7 +212,7 @@ export async function sendStory(
if (!receiverId) {
log.info(
`stories.sendStory(${timestamp}): did not get a valid recipient ID for message. Giving up on sending it`
`${logId}: did not get a valid recipient ID for message. Giving up on sending it`
);
return;
}
@ -233,9 +238,7 @@ export async function sendStory(
};
if (!shouldContinue) {
log.info(
`stories.sendStory(${timestamp}): ran out of time. Giving up on sending it`
);
log.info(`${logId}: ran out of time. Giving up on sending it`);
await markMessageFailed(message, [
new Error('Message send ran out of time'),
]);
@ -260,11 +263,12 @@ export async function sendStory(
window.reduxActions.conversations.conversationStoppedByMissingVerification(
{
conversationId: conversation.id,
distributionId,
untrustedUuids,
}
);
throw new Error(
`stories.sendStory(${timestamp}): sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.`
`${logId}: sending blocked because ${untrustedUuids.length} conversation(s) were untrusted. Failing this attempt.`
);
}
@ -363,7 +367,7 @@ export async function sendStory(
originalError = error;
} else {
log.error(
`promiseForError threw something other than an error: ${Errors.toLogFormat(
`${logId}: promiseForError threw something other than an error: ${Errors.toLogFormat(
error
)}`
);
@ -406,7 +410,7 @@ export async function sendStory(
const didFullySend =
!messageSendErrors.length || didSendToEveryone(message);
if (!didFullySend) {
throw new Error('message did not fully send');
throw new Error(`${logId}: message did not fully send`);
}
} catch (thrownError: unknown) {
const errors = [thrownError, ...messageSendErrors];
@ -423,7 +427,7 @@ export async function sendStory(
token: error.data?.token,
reason:
'conversationJobQueue.run(' +
`${conversation.idForLogging()}, story, ${timestamp})`,
`${conversation.idForLogging()}, story, ${timestamp}/${distributionId})`,
},
error.data
);
@ -472,7 +476,39 @@ export async function sendStory(
};
}
return acc;
const oldSendState = {
...oldSendStateByConversationId[conversationId],
};
if (!oldSendState) {
return acc;
}
const recipient = window.ConversationController.get(conversationId);
if (!recipient) {
return acc;
}
if (recipient.isUnregistered()) {
if (!isSent(oldSendState.status)) {
// We should have filtered this out on initial send, but we'll drop them from
// send list here if needed.
return acc;
}
// If a previous send to them did succeed, we'll keep that status around
return {
...acc,
[conversationId]: oldSendState,
};
}
return {
...acc,
[conversationId]: sendStateReducer(oldSendState, {
type: SendActionType.Failed,
updatedAt: Date.now(),
}),
};
}, {} as SendStateByConversationId);
if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) {
@ -555,13 +591,13 @@ function getMessageRecipients({
allowedReplyByUuid: Map<string, boolean>;
pendingSendRecipientIds: Array<string>;
sentRecipientIds: Array<string>;
untrustedUuids: Array<string>;
untrustedUuids: Array<UUIDStringType>;
} {
const allRecipientIds: Array<string> = [];
const allowedReplyByUuid = new Map<string, boolean>();
const pendingSendRecipientIds: Array<string> = [];
const sentRecipientIds: Array<string> = [];
const untrustedUuids: Array<string> = [];
const untrustedUuids: Array<UUIDStringType> = [];
Object.entries(message.get('sendStateByConversationId') || {}).forEach(
([recipientConversationId, sendState]) => {

View file

@ -22,13 +22,20 @@ import { getOwn } from '../../util/getOwn';
import { assertDev, strictAssert } from '../../util/assert';
import * as universalExpireTimer from '../../util/universalExpireTimer';
import type {
ShowSendAnywayDialogActiontype,
ShowSendAnywayDialogActionType,
ToggleProfileEditorErrorActionType,
} from './globalModals';
import {
SHOW_SEND_ANYWAY_DIALOG,
TOGGLE_PROFILE_EDITOR_ERROR,
} from './globalModals';
import {
MODIFY_LIST,
DELETE_LIST,
HIDE_MY_STORIES_FROM,
VIEWERS_CHANGED,
} from './storyDistributionLists';
import type { StoryDistributionListsActionType } from './storyDistributionLists';
import type {
UUIDFetchStateKeyType,
UUIDFetchStateType,
@ -48,7 +55,7 @@ import type { DraftBodyRangesType } from '../../types/Util';
import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID';
import { StorySendMode } from '../../types/Stories';
import { MY_STORY_ID, StorySendMode } from '../../types/Stories';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
@ -293,16 +300,27 @@ type ComposerGroupCreationState = {
userAvatarData: Array<AvatarDataType>;
};
type DistributionVerificationData = {
uuidsNeedingVerification: ReadonlyArray<UUIDStringType>;
};
export type ConversationVerificationData =
| {
type: ConversationVerificationState.PendingVerification;
uuidsNeedingVerification: ReadonlyArray<string>;
uuidsNeedingVerification: ReadonlyArray<UUIDStringType>;
byDistributionId?: Record<string, DistributionVerificationData>;
}
| {
type: ConversationVerificationState.VerificationCancelled;
canceledAt: number;
};
type VerificationDataByConversation = Record<
string,
ConversationVerificationData
>;
type ComposerStateType =
| {
step: ComposerStep.StartDirectConversation;
@ -356,7 +374,7 @@ export type ConversationsStateType = {
* verification: either a set of pending conversationIds to be approved, or a tombstone
* telling jobs to cancel themselves up to that timestamp.
*/
verificationDataByConversation: Record<string, ConversationVerificationData>;
verificationDataByConversation: VerificationDataByConversation;
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
@ -555,7 +573,8 @@ type ConversationStoppedByMissingVerificationActionType = {
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
payload: {
conversationId: string;
untrustedUuids: ReadonlyArray<string>;
distributionId?: string;
untrustedUuids: ReadonlyArray<UUIDStringType>;
};
};
export type MessageChangedActionType = {
@ -796,7 +815,7 @@ export type ConversationActionType =
| ShowArchivedConversationsActionType
| ShowChooseGroupMembersActionType
| ShowInboxActionType
| ShowSendAnywayDialogActiontype
| ShowSendAnywayDialogActionType
| StartComposingActionType
| StartSettingGroupMetadataActionType
| ToggleConversationInChooseMembersActionType
@ -1608,7 +1627,8 @@ function selectMessage(
function conversationStoppedByMissingVerification(payload: {
conversationId: string;
untrustedUuids: ReadonlyArray<string>;
distributionId?: string;
untrustedUuids: ReadonlyArray<UUIDStringType>;
}): ConversationStoppedByMissingVerificationActionType {
// Fetching profiles to ensure that we have their latest identity key in storage
payload.untrustedUuids.forEach(uuid => {
@ -2227,48 +2247,138 @@ function closeComposerModal(
};
}
function getVerificationDataForConversation(
state: Readonly<ConversationsStateType>,
conversationId: string,
untrustedUuids: ReadonlyArray<string>
): Record<string, ConversationVerificationData> {
const { verificationDataByConversation } = state;
const existingPendingState = getOwn(
verificationDataByConversation,
conversationId
);
function getVerificationDataForConversation({
conversationId,
distributionId,
state,
untrustedUuids,
}: {
conversationId: string;
distributionId?: string;
state: Readonly<VerificationDataByConversation>;
untrustedUuids: ReadonlyArray<UUIDStringType>;
}): VerificationDataByConversation {
const existing = getOwn(state, conversationId);
if (
!existingPendingState ||
existingPendingState.type ===
ConversationVerificationState.VerificationCancelled
!existing ||
existing.type === ConversationVerificationState.VerificationCancelled
) {
return {
[conversationId]: {
type: ConversationVerificationState.PendingVerification as const,
uuidsNeedingVerification: untrustedUuids,
uuidsNeedingVerification: distributionId ? [] : untrustedUuids,
...(distributionId
? {
byDistributionId: {
[distributionId]: {
uuidsNeedingVerification: untrustedUuids,
},
},
}
: undefined),
},
};
}
const uuidsNeedingVerification: ReadonlyArray<string> = Array.from(
new Set([
...existingPendingState.uuidsNeedingVerification,
...untrustedUuids,
])
const existingUuids = distributionId
? existing.byDistributionId?.[distributionId]?.uuidsNeedingVerification
: existing.uuidsNeedingVerification;
const uuidsNeedingVerification: ReadonlyArray<UUIDStringType> = Array.from(
new Set([...(existingUuids || []), ...untrustedUuids])
);
return {
[conversationId]: {
...existing,
type: ConversationVerificationState.PendingVerification as const,
uuidsNeedingVerification,
...(distributionId ? undefined : { uuidsNeedingVerification }),
...(distributionId
? {
byDistributionId: {
...existing.byDistributionId,
[distributionId]: {
uuidsNeedingVerification,
},
},
}
: undefined),
},
};
}
// Return same data, and we do nothing. Return undefined, and we'll delete the list.
type DistributionVisitor = (
id: string,
data: DistributionVerificationData
) => DistributionVerificationData | undefined;
function visitListsInVerificationData(
existing: VerificationDataByConversation,
visitor: DistributionVisitor
): VerificationDataByConversation {
let result = existing;
Object.entries(result).forEach(([conversationId, conversationData]) => {
if (
conversationData.type !==
ConversationVerificationState.PendingVerification
) {
return;
}
const { byDistributionId } = conversationData;
if (!byDistributionId) {
return;
}
let updatedByDistributionId = byDistributionId;
Object.entries(byDistributionId).forEach(
([distributionId, distributionData]) => {
const visitorResult = visitor(distributionId, distributionData);
if (!visitorResult) {
updatedByDistributionId = omit(updatedByDistributionId, [
distributionId,
]);
} else if (visitorResult !== distributionData) {
updatedByDistributionId = {
...updatedByDistributionId,
[distributionId]: visitorResult,
};
}
}
);
const listCount = Object.keys(updatedByDistributionId).length;
if (
conversationData.uuidsNeedingVerification.length === 0 &&
listCount === 0
) {
result = omit(result, [conversationId]);
} else if (listCount === 0) {
result = {
...result,
[conversationId]: omit(conversationData, ['byDistributionId']),
};
} else if (updatedByDistributionId !== byDistributionId) {
result = {
...result,
[conversationId]: {
...conversationData,
byDistributionId: updatedByDistributionId,
},
};
}
});
return result;
}
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType>
action: Readonly<ConversationActionType | StoryDistributionListsActionType>
): ConversationsStateType {
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
return {
@ -2281,16 +2391,12 @@ export function reducer(
const { conversationId } = action.payload;
const { verificationDataByConversation } = state;
const existingPendingState = getOwn(
verificationDataByConversation,
conversationId
);
const existing = getOwn(verificationDataByConversation, conversationId);
// If there are active verifications required, this will do nothing.
if (
existingPendingState &&
existingPendingState.type ===
ConversationVerificationState.PendingVerification
existing &&
existing.type === ConversationVerificationState.PendingVerification
) {
return state;
}
@ -2612,15 +2718,149 @@ export function reducer(
selectedMessageSource: SelectedMessageSource.Focus,
};
}
if (action.type === CONVERSATION_STOPPED_BY_MISSING_VERIFICATION) {
const { conversationId, untrustedUuids } = action.payload;
const nextVerificationData = getVerificationDataForConversation(
state,
conversationId,
untrustedUuids
if (action.type === MODIFY_LIST) {
const {
id: listId,
isBlockList,
membersToRemove,
membersToAdd,
} = action.payload;
const removedUuids = new Set(isBlockList ? membersToAdd : membersToRemove);
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (listId === id) {
const uuidsNeedingVerification = data.uuidsNeedingVerification.filter(
uuid => !removedUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
return undefined;
}
return {
...data,
uuidsNeedingVerification,
};
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === DELETE_LIST) {
const { listId } = action.payload;
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (listId === id) {
return undefined;
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === HIDE_MY_STORIES_FROM) {
const removedUuids = new Set(action.payload);
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (MY_STORY_ID === id) {
const uuidsNeedingVerification = data.uuidsNeedingVerification.filter(
uuid => !removedUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
return undefined;
}
return {
...data,
uuidsNeedingVerification,
};
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === VIEWERS_CHANGED) {
const { listId, memberUuids } = action.payload;
const newUuids = new Set(memberUuids);
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (listId === id) {
const uuidsNeedingVerification = data.uuidsNeedingVerification.filter(
uuid => newUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
return undefined;
}
return {
...data,
uuidsNeedingVerification,
};
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === CONVERSATION_STOPPED_BY_MISSING_VERIFICATION) {
const { conversationId, distributionId, untrustedUuids } = action.payload;
const nextVerificationData = getVerificationDataForConversation({
conversationId,
distributionId,
state: state.verificationDataByConversation,
untrustedUuids,
});
return {
...state,
verificationDataByConversation: {
@ -2634,14 +2874,30 @@ export function reducer(
...state.verificationDataByConversation,
};
action.payload.conversationsToPause.forEach(
(untrustedUuids, conversationId) => {
const nextVerificationData = getVerificationDataForConversation(
state,
Object.entries(action.payload.untrustedByConversation).forEach(
([conversationId, conversationData]) => {
const nextConversation = getVerificationDataForConversation({
state: verificationDataByConversation,
conversationId,
Array.from(untrustedUuids)
untrustedUuids: conversationData.uuids,
});
Object.assign(verificationDataByConversation, nextConversation);
if (!conversationData.byDistributionId) {
return;
}
Object.entries(conversationData.byDistributionId).forEach(
([distributionId, distributionData]) => {
const nextDistribution = getVerificationDataForConversation({
state: verificationDataByConversation,
distributionId,
conversationId,
untrustedUuids: distributionData.uuids,
});
Object.assign(verificationDataByConversation, nextDistribution);
}
);
Object.assign(verificationDataByConversation, nextVerificationData);
}
);

View file

@ -11,6 +11,7 @@ import * as SingleServePromise from '../../services/singleServePromise';
import { getMessageById } from '../../messages/getMessageById';
import { getMessagePropsSelector } from '../selectors/message';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { RecipientsByConversation } from './stories';
// State
@ -136,10 +137,10 @@ type HideStoriesSettingsActionType = {
type: typeof HIDE_STORIES_SETTINGS;
};
export type ShowSendAnywayDialogActiontype = {
export type ShowSendAnywayDialogActionType = {
type: typeof SHOW_SEND_ANYWAY_DIALOG;
payload: SafetyNumberChangedBlockingDataType & {
conversationsToPause: Map<string, Set<string>>;
untrustedByConversation: RecipientsByConversation;
};
};
@ -157,7 +158,7 @@ export type GlobalModalsActionType =
| HideStoriesSettingsActionType
| ShowStoriesSettingsActionType
| HideSendAnywayDialogActiontype
| ShowSendAnywayDialogActiontype
| ShowSendAnywayDialogActionType
| ToggleForwardMessageModalActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
@ -311,17 +312,17 @@ function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType
}
function showBlockingSafetyNumberChangeDialog(
conversationsToPause: Map<string, Set<string>>,
untrustedByConversation: RecipientsByConversation,
explodedPromise: ExplodePromiseResultType<boolean>,
source?: SafetyNumberChangeSource
): ThunkAction<void, RootStateType, unknown, ShowSendAnywayDialogActiontype> {
): ThunkAction<void, RootStateType, unknown, ShowSendAnywayDialogActionType> {
const promiseUuid = SingleServePromise.set<boolean>(explodedPromise);
return dispatch => {
dispatch({
type: SHOW_SEND_ANYWAY_DIALOG,
payload: {
conversationsToPause,
untrustedByConversation,
promiseUuid,
source,
},

View file

@ -7,7 +7,6 @@ import { isEqual, pick } from 'lodash';
import * as Errors from '../../types/errors';
import type { AttachmentType } from '../../types/Attachment';
import type { DraftBodyRangesType } from '../../types/Util';
import type { ConversationModel } from '../../models/conversations';
import type { MessageAttributesType } from '../../model-types.d';
import type {
MessageChangedActionType,
@ -55,6 +54,7 @@ import { useBoundActions } from '../../hooks/useBoundActions';
import { verifyStoryListMembers as doVerifyStoryListMembers } from '../../util/verifyStoryListMembers';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { getOwn } from '../../util/getOwn';
export type StoryDataType = {
attachment?: AttachmentType;
@ -104,9 +104,25 @@ export type AddStoryData =
}
| undefined;
export type RecipientsByConversation = Record<
string, // conversationId
{
uuids: Array<UUIDStringType>;
byDistributionId?: Record<
string, // distributionId
{
uuids: Array<UUIDStringType>;
}
>;
}
>;
// State
export type StoriesStateType = Readonly<{
addStoryData: AddStoryData;
hasAllStoriesUnmuted: boolean;
lastOpenedAtTimestamp: number | undefined;
openedAtTimestamp: number | undefined;
replyState?: Readonly<{
@ -114,13 +130,8 @@ export type StoriesStateType = Readonly<{
replies: Array<MessageAttributesType>;
}>;
selectedStoryData?: SelectedStoryDataType;
addStoryData: AddStoryData;
sendStoryModalData?: Readonly<{
untrustedUuids: ReadonlyArray<string>;
verifiedUuids: ReadonlyArray<string>;
}>;
sendStoryModalData?: RecipientsByConversation;
stories: ReadonlyArray<StoryDataType>;
hasAllStoriesUnmuted: boolean;
}>;
// Actions
@ -149,8 +160,9 @@ type DOEStoryActionType = {
type ListMembersVerified = {
type: typeof LIST_MEMBERS_VERIFIED;
payload: {
untrustedUuids: Array<string>;
verifiedUuids: Array<string>;
conversationId: string;
distributionId: string | undefined;
uuids: Array<UUIDStringType>;
};
};
@ -560,41 +572,21 @@ function sendStoryMessage(
'sendStoryMessage: sendStoryModalData is not defined, cannot send'
);
if (sendStoryModalData.untrustedUuids.length) {
log.info('sendStoryMessage: SN changed for some conversations');
log.info('sendStoryMessage: Verifing trust for all recipients');
const conversationsNeedingVerification: Array<ConversationModel> =
sendStoryModalData.untrustedUuids
.map(uuid => window.ConversationController.get(uuid))
.filter(isNotNil);
const result = await blockSendUntilConversationsAreVerified(
sendStoryModalData,
SafetyNumberChangeSource.Story,
Date.now() - openedAtTimestamp
);
if (!conversationsNeedingVerification.length) {
log.warn(
'sendStoryMessage: Could not retrieve conversations for untrusted uuids'
);
return;
}
const result = await blockSendUntilConversationsAreVerified(
conversationsNeedingVerification,
SafetyNumberChangeSource.Story,
Date.now() - openedAtTimestamp
);
if (!result) {
log.info('sendStoryMessage: failed to verify untrusted; stopping send');
dispatch({
type: SET_STORY_SENDING,
payload: false,
});
return;
}
// Clear all untrusted and verified uuids; we're clear to send!
if (!result) {
log.info('sendStoryMessage: failed to verify untrusted; stopping send');
dispatch({
type: SEND_STORY_MODAL_OPEN_STATE_CHANGED,
payload: undefined,
type: SET_STORY_SENDING,
payload: false,
});
return;
}
try {
@ -602,6 +594,10 @@ function sendStoryMessage(
// Note: Only when we've successfully queued the message do we dismiss the story
// composer view.
dispatch({
type: SEND_STORY_MODAL_OPEN_STATE_CHANGED,
payload: undefined,
});
dispatch({
type: SET_ADD_STORY_DATA,
payload: undefined,
@ -653,9 +649,15 @@ function toggleStoriesView(): ToggleViewActionType {
};
}
function verifyStoryListMembers(
memberUuids: Array<string>
): ThunkAction<void, RootStateType, unknown, ListMembersVerified> {
function verifyStoryListMembers({
conversationId,
distributionId,
uuids,
}: {
conversationId: string;
distributionId: string | undefined;
uuids: Array<UUIDStringType>;
}): ThunkAction<void, RootStateType, unknown, ListMembersVerified> {
return async (dispatch, getState) => {
const { stories } = getState();
const { sendStoryModalData } = stories;
@ -664,25 +666,20 @@ function verifyStoryListMembers(
return;
}
const alreadyVerifiedUuids = new Set([...sendStoryModalData.verifiedUuids]);
const uuidsNeedingVerification = memberUuids.filter(
uuid => !alreadyVerifiedUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
if (!uuids.length) {
return;
}
const { untrustedUuids, verifiedUuids } = await doVerifyStoryListMembers(
uuidsNeedingVerification
);
// This will fetch the latest identity key for these contacts, which will ensure that
// the later verified/trusted checks will flag that change.
await doVerifyStoryListMembers(uuids);
dispatch({
type: LIST_MEMBERS_VERIFIED,
payload: {
untrustedUuids: Array.from(untrustedUuids),
verifiedUuids: Array.from(verifiedUuids),
conversationId,
distributionId,
uuids,
},
});
};
@ -1594,10 +1591,7 @@ export function reducer(
if (action.payload) {
return {
...state,
sendStoryModalData: {
untrustedUuids: [],
verifiedUuids: [],
},
sendStoryModalData: {},
};
}
@ -1608,31 +1602,49 @@ export function reducer(
}
if (action.type === LIST_MEMBERS_VERIFIED) {
const sendStoryModalData = {
untrustedUuids: [],
verifiedUuids: [],
...(state.sendStoryModalData || {}),
};
const { sendStoryModalData } = state;
const { conversationId, distributionId, uuids } = action.payload;
const untrustedUuids = Array.from(
new Set([
...sendStoryModalData.untrustedUuids,
...action.payload.untrustedUuids,
])
);
const verifiedUuids = Array.from(
new Set([
...sendStoryModalData.verifiedUuids,
...action.payload.verifiedUuids,
])
const existing =
sendStoryModalData && getOwn(sendStoryModalData, conversationId);
if (distributionId) {
const existingUuids = existing?.byDistributionId?.[distributionId]?.uuids;
const finalUuids = Array.from(
new Set([...(existingUuids || []), ...uuids])
);
return {
...state,
sendStoryModalData: {
...sendStoryModalData,
[conversationId]: {
...existing,
uuids: existing?.uuids || [],
byDistributionId: {
...existing?.byDistributionId,
[distributionId]: {
uuids: finalUuids,
},
},
},
},
};
}
const finalUuids = Array.from(
new Set([...(existing?.uuids || []), ...uuids])
);
return {
...state,
sendStoryModalData: {
...sendStoryModalData,
untrustedUuids,
verifiedUuids,
[conversationId]: {
...existing,
uuids: finalUuids,
},
},
};
}

View file

@ -1,6 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import type { ThunkAction } from 'redux-thunk';
import type { StateType as RootStateType } from '../reducer';
@ -23,7 +24,7 @@ export type StoryDistributionListDataType = {
name: string;
allowsReplies: boolean;
isBlockList: boolean;
memberUuids: Array<string>;
memberUuids: Array<UUIDStringType>;
};
export type StoryDistributionListStateType = {
@ -34,12 +35,12 @@ export type StoryDistributionListStateType = {
const ALLOW_REPLIES_CHANGED = 'storyDistributionLists/ALLOW_REPLIES_CHANGED';
const CREATE_LIST = 'storyDistributionLists/CREATE_LIST';
const DELETE_LIST = 'storyDistributionLists/DELETE_LIST';
const HIDE_MY_STORIES_FROM = 'storyDistributionLists/HIDE_MY_STORIES_FROM';
const MODIFY_LIST = 'storyDistributionLists/MODIFY_LIST';
const REMOVE_MEMBER = 'storyDistributionLists/REMOVE_MEMBER';
export const DELETE_LIST = 'storyDistributionLists/DELETE_LIST';
export const HIDE_MY_STORIES_FROM =
'storyDistributionLists/HIDE_MY_STORIES_FROM';
export const MODIFY_LIST = 'storyDistributionLists/MODIFY_LIST';
const RESET_MY_STORIES = 'storyDistributionLists/RESET_MY_STORIES';
const VIEWERS_CHANGED = 'storyDistributionLists/VIEWERS_CHANGED';
export const VIEWERS_CHANGED = 'storyDistributionLists/VIEWERS_CHANGED';
type AllowRepliesChangedActionType = {
type: typeof ALLOW_REPLIES_CHANGED;
@ -64,15 +65,15 @@ type DeleteListActionType = {
type HideMyStoriesFromActionType = {
type: typeof HIDE_MY_STORIES_FROM;
payload: Array<string>;
payload: Array<UUIDStringType>;
};
type ModifyDistributionListType = Omit<
StoryDistributionListDataType,
'memberUuids'
> & {
membersToAdd: Array<string>;
membersToRemove: Array<string>;
membersToAdd: Array<UUIDStringType>;
membersToRemove: Array<UUIDStringType>;
};
export type ModifyListActionType = {
@ -80,14 +81,6 @@ export type ModifyListActionType = {
payload: ModifyDistributionListType;
};
type RemoveMemberActionType = {
type: typeof REMOVE_MEMBER;
payload: {
listId: string;
memberUuid: string;
};
};
type ResetMyStoriesActionType = {
type: typeof RESET_MY_STORIES;
};
@ -96,17 +89,16 @@ type ViewersChangedActionType = {
type: typeof VIEWERS_CHANGED;
payload: {
listId: string;
memberUuids: Array<string>;
memberUuids: Array<UUIDStringType>;
};
};
type StoryDistributionListsActionType =
export type StoryDistributionListsActionType =
| AllowRepliesChangedActionType
| CreateListActionType
| DeleteListActionType
| HideMyStoriesFromActionType
| ModifyListActionType
| RemoveMemberActionType
| ResetMyStoriesActionType
| ViewersChangedActionType;
@ -300,14 +292,14 @@ function hideMyStoriesFrom(
};
}
function removeMemberFromDistributionList(
function removeMembersFromDistributionList(
listId: string,
memberUuid: UUIDStringType | undefined
): ThunkAction<void, RootStateType, null, RemoveMemberActionType> {
memberUuids: Array<UUIDStringType>
): ThunkAction<void, RootStateType, null, ModifyListActionType> {
return async dispatch => {
if (!memberUuid) {
if (!memberUuids.length) {
log.warn(
'storyDistributionLists.removeMemberFromDistributionList cannot remove a member without uuid',
'storyDistributionLists.removeMembersFromDistributionList cannot remove a member without uuid',
listId
);
return;
@ -318,38 +310,59 @@ function removeMemberFromDistributionList(
if (!storyDistribution) {
log.warn(
'storyDistributionLists.removeMemberFromDistributionList: No story found for id',
'storyDistributionLists.removeMembersFromDistributionList: No story found for id',
listId
);
return;
}
let toAdd: Array<UUIDStringType> = [];
let toRemove: Array<UUIDStringType> = memberUuids;
let { isBlockList } = storyDistribution;
// My Story is set to 'All Signal Connections' or is already an exclude list
if (
listId === MY_STORY_ID &&
(storyDistribution.members.length === 0 || isBlockList)
) {
isBlockList = true;
toAdd = memberUuids;
toRemove = [];
// The user has now configured My Stories
window.storage.put('hasSetMyStoriesPrivacy', true);
}
await dataInterface.modifyStoryDistributionWithMembers(
{
...storyDistribution,
isBlockList,
storageNeedsSync: true,
},
{
toAdd: [],
toRemove: [memberUuid],
toAdd,
toRemove,
}
);
log.info(
'storyDistributionLists.removeMemberFromDistributionList: removed',
'storyDistributionLists.removeMembersFromDistributionList: removed',
{
listId,
memberUuid,
memberUuids,
}
);
storageServiceUploadJob();
dispatch({
type: REMOVE_MEMBER,
type: MODIFY_LIST,
payload: {
listId,
memberUuid,
...omit(storyDistribution, ['members']),
isBlockList,
storageNeedsSync: true,
membersToAdd: toAdd,
membersToRemove: toRemove,
},
});
};
@ -465,7 +478,7 @@ export const actions = {
deleteDistributionList,
hideMyStoriesFrom,
modifyDistributionList,
removeMemberFromDistributionList,
removeMembersFromDistributionList,
setMyStoriesToAllSignalConnections,
updateStoryViewers,
};
@ -515,7 +528,9 @@ export function reducer(
);
if (listIndex >= 0) {
const existingDistributionList = state.distributionLists[listIndex];
const memberUuids = new Set<string>(existingDistributionList.memberUuids);
const memberUuids = new Set<UUIDStringType>(
existingDistributionList.memberUuids
);
membersToAdd.forEach(uuid => memberUuids.add(uuid));
membersToRemove.forEach(uuid => memberUuids.delete(uuid));
@ -572,20 +587,6 @@ export function reducer(
return distributionLists ? { distributionLists } : state;
}
if (action.type === REMOVE_MEMBER) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
action.payload.listId,
list => ({
memberUuids: list.memberUuids.filter(
uuid => uuid !== action.payload.memberUuid
),
})
);
return distributionLists ? { distributionLists } : state;
}
if (action.type === ALLOW_REPLIES_CHANGED) {
const distributionLists = replaceDistributionListData(
state.distributionLists,

View file

@ -0,0 +1,97 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// This file is to prevent circular references with other selector files, since
// selectors/conversations is used by so many things
import { createSelector } from 'reselect';
import type { ContactsByStory } from '../../components/SafetyNumberChangeDialog';
import type { ConversationVerificationData } from '../ducks/conversations';
import type { StoryDistributionListDataType } from '../ducks/storyDistributionLists';
import type { GetConversationByIdType } from './conversations';
import { isGroup } from '../../util/whatTypeOfConversation';
import {
getConversationSelector,
getConversationVerificationData,
} from './conversations';
import { ConversationVerificationState } from '../ducks/conversationsEnums';
import { getDistributionListSelector } from './storyDistributionLists';
export const getByDistributionListConversationsStoppingSend = createSelector(
getConversationSelector,
getDistributionListSelector,
getConversationVerificationData,
(
conversationSelector: GetConversationByIdType,
distributionListSelector: (
id: string
) => StoryDistributionListDataType | undefined,
verificationDataByConversation: Record<string, ConversationVerificationData>
): ContactsByStory => {
const conversations: ContactsByStory = [];
Object.entries(verificationDataByConversation).forEach(
([conversationId, conversationData]) => {
if (
conversationData.type !==
ConversationVerificationState.PendingVerification
) {
return;
}
const conversationUuids = new Set(
conversationData.uuidsNeedingVerification
);
if (conversationData.byDistributionId) {
Object.entries(conversationData.byDistributionId).forEach(
([distributionId, distributionData]) => {
if (distributionData.uuidsNeedingVerification.length === 0) {
return;
}
const currentDistribution =
distributionListSelector(distributionId);
if (!currentDistribution) {
distributionData.uuidsNeedingVerification.forEach(uuid => {
conversationUuids.add(uuid);
});
return;
}
conversations.push({
story: {
conversationId,
distributionId,
name: currentDistribution.name,
},
contacts: distributionData.uuidsNeedingVerification.map(uuid =>
conversationSelector(uuid)
),
});
}
);
}
if (conversationUuids.size) {
const currentConversation = conversationSelector(conversationId);
conversations.push({
story: isGroup(currentConversation)
? {
conversationId,
name: currentConversation.title,
}
: undefined,
contacts: Array.from(conversationUuids).map(uuid =>
conversationSelector(uuid)
),
});
}
}
);
return conversations;
}
);

View file

@ -1074,7 +1074,7 @@ export const getContactSelector = createSelector(
}
);
const getConversationVerificationData = createSelector(
export const getConversationVerificationData = createSelector(
getConversations,
(
conversations: Readonly<ConversationsStateType>
@ -1097,6 +1097,14 @@ export const getConversationUuidsStoppingSend = createSelector(
item.uuidsNeedingVerification.forEach(conversationId => {
result.add(conversationId);
});
if (item.byDistributionId) {
Object.values(item.byDistributionId).forEach(distribution => {
distribution.uuidsNeedingVerification.forEach(conversationId => {
result.add(conversationId);
});
});
}
}
});
return Array.from(result);

View file

@ -12,21 +12,24 @@ import {
SafetyNumberChangeSource,
} from '../../components/SafetyNumberChangeDialog';
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
import { getConversationsStoppingSend } from '../selectors/conversations';
import { getByDistributionListConversationsStoppingSend } from '../selectors/conversations-extra';
import { getIntl, getTheme } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists';
export function SmartSendAnywayDialog(): JSX.Element {
const { hideBlockingSafetyNumberChangeDialog } = useGlobalModalActions();
const { removeMembersFromDistributionList } =
useStoryDistributionListsActions();
const { cancelConversationVerification, verifyConversationsStoppingSend } =
useConversationsActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const theme = useSelector(getTheme);
const contacts = useSelector(getConversationsStoppingSend);
const contacts = useSelector(getByDistributionListConversationsStoppingSend);
const safetyNumberChangedBlockingData = useSelector<
StateType,
@ -66,6 +69,7 @@ export function SmartSendAnywayDialog(): JSX.Element {
explodedPromise?.resolve(true);
hideBlockingSafetyNumberChangeDialog();
}}
removeFromStory={removeMembersFromDistributionList}
renderSafetyNumber={({ contactID, onClose }) => (
<SmartSafetyNumberViewer contactID={contactID} onClose={onClose} />
)}

View file

@ -32,7 +32,7 @@ export function SmartStoriesSettingsModal(): JSX.Element | null {
createDistributionList,
deleteDistributionList,
hideMyStoriesFrom,
removeMemberFromDistributionList,
removeMembersFromDistributionList,
setMyStoriesToAllSignalConnections,
updateStoryViewers,
} = useStoryDistributionListsActions();
@ -65,7 +65,7 @@ export function SmartStoriesSettingsModal(): JSX.Element | null {
toggleGroupsForStorySend={toggleGroupsForStorySend}
onDistributionListCreated={createDistributionList}
onHideMyStoriesFrom={hideMyStoriesFrom}
onRemoveMember={removeMemberFromDistributionList}
onRemoveMembers={removeMembersFromDistributionList}
onRepliesNReactionsChanged={allowsRepliesChanged}
onViewersUpdated={updateStoryViewers}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}

View file

@ -17,7 +17,7 @@ import {
selectMostRecentActiveStoryTimestampByGroupOrDistributionList,
} from '../selectors/conversations';
import { getDistributionListsWithMembers } from '../selectors/storyDistributionLists';
import { getIntl } from '../selectors/user';
import { getIntl, getUserConversationId } from '../selectors/user';
import {
getInstalledStickerPacks,
getRecentStickers,
@ -53,12 +53,13 @@ export function SmartStoryCreator(): JSX.Element | null {
createDistributionList,
deleteDistributionList,
hideMyStoriesFrom,
removeMemberFromDistributionList,
removeMembersFromDistributionList,
setMyStoriesToAllSignalConnections,
updateStoryViewers,
} = useStoryDistributionListsActions();
const { toggleSignalConnectionsModal } = useGlobalModalActions();
const ourConversationId = useSelector(getUserConversationId);
const candidateConversations = useSelector(getCandidateContactsForNewGroup);
const distributionLists = useSelector(getDistributionListsWithMembers);
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
@ -94,11 +95,12 @@ export function SmartStoryCreator(): JSX.Element | null {
isSending={isSending}
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
me={me}
ourConversationId={ourConversationId}
onClose={() => setAddStoryData(undefined)}
onDeleteList={deleteDistributionList}
onDistributionListCreated={createDistributionList}
onHideMyStoriesFrom={hideMyStoriesFrom}
onRemoveMember={removeMemberFromDistributionList}
onRemoveMembers={removeMembersFromDistributionList}
onRepliesNReactionsChanged={allowsRepliesChanged}
onSelectedStoryList={verifyStoryListMembers}
onSend={sendStoryMessage}

View file

@ -59,7 +59,10 @@ import {
defaultSetGroupMetadataComposerState,
} from '../../helpers/defaultComposerStates';
describe('both/state/selectors/conversations', () => {
describe('both/state/selectors/conversations-extra', () => {
const UUID_1 = UUID.generate().toString();
const UUID_2 = UUID.generate().toString();
const getEmptyRootState = (): StateType => {
return rootReducer(undefined, noopAction());
};
@ -301,32 +304,32 @@ describe('both/state/selectors/conversations', () => {
});
it('returns all conversations stopping send', () => {
const convo1 = makeConversation('abc');
const convo2 = makeConversation('def');
const convo1 = makeConversation(UUID_1);
const convo2 = makeConversation(UUID_2);
const state: StateType = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
def: convo2,
abc: convo1,
[UUID_1]: convo1,
[UUID_2]: convo2,
},
verificationDataByConversation: {
'convo a': {
type: ConversationVerificationState.PendingVerification as const,
uuidsNeedingVerification: ['abc'],
uuidsNeedingVerification: [UUID_1],
},
'convo b': {
type: ConversationVerificationState.PendingVerification as const,
uuidsNeedingVerification: ['def', 'abc'],
uuidsNeedingVerification: [UUID_2, UUID_1],
},
},
},
};
assert.sameDeepMembers(getConversationUuidsStoppingSend(state), [
'abc',
'def',
UUID_1,
UUID_2,
]);
assert.sameDeepMembers(getConversationsStoppingSend(state), [

View file

@ -0,0 +1,147 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { RecipientsByConversation } from '../../state/ducks/stories';
import type { UUIDStringType } from '../../types/UUID';
import { UUID } from '../../types/UUID';
import {
getAllUuids,
filterUuids,
} from '../../util/blockSendUntilConversationsAreVerified';
describe('both/util/blockSendUntilConversationsAreVerified', () => {
const UUID_1 = UUID.generate().toString();
const UUID_2 = UUID.generate().toString();
const UUID_3 = UUID.generate().toString();
const UUID_4 = UUID.generate().toString();
describe('#getAllUuids', () => {
it('should return empty set for empty object', () => {
const starting: RecipientsByConversation = {};
const expected: Array<UUIDStringType> = [];
const actual = getAllUuids(starting);
assert.sameMembers(Array.from(actual), expected);
});
it('should return uuids multiple conversations', () => {
const starting: RecipientsByConversation = {
abc: {
uuids: [UUID_1, UUID_2],
},
def: {
uuids: [],
},
ghi: {
uuids: [UUID_2, UUID_3],
},
};
const expected: Array<UUIDStringType> = [UUID_1, UUID_2, UUID_3];
const actual = getAllUuids(starting);
assert.sameMembers(Array.from(actual), expected);
});
it('should return uuids from byDistributionId and its parent', () => {
const starting: RecipientsByConversation = {
abc: {
uuids: [UUID_1, UUID_2],
byDistributionId: {
abc: {
uuids: [UUID_3],
},
def: {
uuids: [],
},
ghi: {
uuids: [UUID_4],
},
},
},
};
const expected: Array<UUIDStringType> = [UUID_1, UUID_2, UUID_3, UUID_4];
const actual = getAllUuids(starting);
assert.sameMembers(Array.from(actual), expected);
});
it('should return uuids from byDistributionId with empty parent', () => {
const starting: RecipientsByConversation = {
abc: {
uuids: [],
byDistributionId: {
abc: {
uuids: [UUID_3],
},
},
},
};
const expected: Array<UUIDStringType> = [UUID_3];
const actual = getAllUuids(starting);
assert.sameMembers(Array.from(actual), expected);
});
});
describe('#filterUuids', () => {
const starting: RecipientsByConversation = {
abc: {
uuids: [UUID_1],
byDistributionId: {
abc: {
uuids: [UUID_2, UUID_3],
},
def: {
uuids: [UUID_1],
},
},
},
def: {
uuids: [UUID_1, UUID_4],
},
ghi: {
uuids: [UUID_3],
byDistributionId: {
abc: {
uuids: [UUID_4],
},
},
},
};
it('should return empty object if predicate always returns false', () => {
const expected: RecipientsByConversation = {};
const actual = filterUuids(starting, () => false);
assert.deepEqual(actual, expected);
});
it('should return exact copy of object if predicate always returns true', () => {
const expected = starting;
const actual = filterUuids(starting, () => true);
assert.notStrictEqual(actual, expected);
assert.deepEqual(actual, expected);
});
it('should return just a few uuids for selective predicate', () => {
const expected: RecipientsByConversation = {
abc: {
uuids: [],
byDistributionId: {
abc: {
uuids: [UUID_2, UUID_3],
},
},
},
ghi: {
uuids: [UUID_3],
},
};
const actual = filterUuids(
starting,
(uuid: UUIDStringType) => uuid === UUID_2 || uuid === UUID_3
);
assert.deepEqual(actual, expected);
});
});
});

View file

@ -0,0 +1,20 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { waitForAll } from '../../util/waitForAll';
describe('util/waitForAll', () => {
it('returns result of provided tasks', async () => {
const task1 = () => Promise.resolve(1);
const task2 = () => Promise.resolve(2);
const task3 = () => Promise.resolve(3);
const result = await waitForAll({
tasks: [task1, task2, task3],
maxConcurrency: 1,
});
assert.deepEqual(result, [1, 2, 3]);
});
});

View file

@ -46,6 +46,16 @@ import {
defaultSetGroupMetadataComposerState,
} from '../../../test-both/helpers/defaultComposerStates';
import { updateRemoteConfig } from '../../../test-both/helpers/RemoteConfigStub';
import type { ShowSendAnywayDialogActionType } from '../../../state/ducks/globalModals';
import { SHOW_SEND_ANYWAY_DIALOG } from '../../../state/ducks/globalModals';
import type { StoryDistributionListsActionType } from '../../../state/ducks/storyDistributionLists';
import {
DELETE_LIST,
HIDE_MY_STORIES_FROM,
MODIFY_LIST,
VIEWERS_CHANGED,
} from '../../../state/ducks/storyDistributionLists';
import { MY_STORY_ID } from '../../../types/Stories';
const {
clearGroupCreationError,
@ -76,6 +86,11 @@ const {
} = actions;
describe('both/state/ducks/conversations', () => {
const UUID_1 = UUID.generate().toString();
const UUID_2 = UUID.generate().toString();
const UUID_3 = UUID.generate().toString();
const UUID_4 = UUID.generate().toString();
const getEmptyRootState = () => rootReducer(undefined, noopAction());
let sinonSandbox: sinon.SinonSandbox;
@ -747,28 +762,28 @@ describe('both/state/ducks/conversations', () => {
getEmptyState(),
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedUuids: ['convo 1'],
untrustedUuids: [UUID_1],
})
);
const second = reducer(
first,
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedUuids: ['convo 2'],
untrustedUuids: [UUID_2],
})
);
const third = reducer(
second,
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedUuids: ['convo 1', 'convo 3'],
untrustedUuids: [UUID_1, UUID_3],
})
);
assert.deepStrictEqual(third.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: ['convo 1', 'convo 2', 'convo 3'],
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
});
});
@ -787,14 +802,123 @@ describe('both/state/ducks/conversations', () => {
state,
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedUuids: ['convo 1', 'convo 2'],
untrustedUuids: [UUID_1, UUID_2],
})
);
assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: ['convo 1', 'convo 2'],
uuidsNeedingVerification: [UUID_1, UUID_2],
},
});
});
});
describe('SHOW_SEND_ANYWAY_DIALOG', () => {
it('adds nothing to existing empty state', () => {
const state = getEmptyState();
const action: ShowSendAnywayDialogActionType = {
type: SHOW_SEND_ANYWAY_DIALOG,
payload: {
untrustedByConversation: {},
promiseUuid: UUID.generate().toString(),
source: undefined,
},
};
const actual = reducer(state, action);
assert.deepStrictEqual(actual.verificationDataByConversation, {});
});
it('adds multiple conversations and distribution lists to empty list', () => {
const state = getEmptyState();
const action: ShowSendAnywayDialogActionType = {
type: SHOW_SEND_ANYWAY_DIALOG,
payload: {
untrustedByConversation: {
abc: {
uuids: [UUID_1, UUID_2],
byDistributionId: {
abc: {
uuids: [UUID_1, UUID_3],
},
def: {
uuids: [UUID_2, UUID_4],
},
},
},
},
promiseUuid: UUID.generate().toString(),
source: undefined,
},
};
const actual = reducer(state, action);
assert.deepStrictEqual(actual.verificationDataByConversation, {
abc: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1, UUID_2],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_3],
},
def: {
uuidsNeedingVerification: [UUID_2, UUID_4],
},
},
},
});
});
it('adds and de-dupes in multiple conversations and distribution lists', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
abc: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1],
},
},
},
},
};
const action: ShowSendAnywayDialogActionType = {
type: SHOW_SEND_ANYWAY_DIALOG,
payload: {
untrustedByConversation: {
abc: {
uuids: [UUID_1, UUID_2],
byDistributionId: {
abc: {
uuids: [UUID_1, UUID_3],
},
def: {
uuids: [UUID_2, UUID_4],
},
},
},
},
promiseUuid: UUID.generate().toString(),
source: undefined,
},
};
const actual = reducer(state, action);
assert.deepStrictEqual(actual.verificationDataByConversation, {
abc: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1, UUID_2],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_3],
},
def: {
uuidsNeedingVerification: [UUID_2, UUID_4],
},
},
},
});
});
@ -826,7 +950,7 @@ describe('both/state/ducks/conversations', () => {
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: ['convo 1', 'convo 2'],
uuidsNeedingVerification: [UUID_1, UUID_2],
},
},
};
@ -920,7 +1044,7 @@ describe('both/state/ducks/conversations', () => {
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: ['convo 1', 'convo 2'],
uuidsNeedingVerification: [UUID_1, UUID_2],
},
},
};
@ -1986,73 +2110,386 @@ describe('both/state/ducks/conversations', () => {
assert.strictEqual(action.payload.maxGroupSize, 1235);
});
});
});
describe('COLORS_CHANGED', () => {
const abc = getDefaultConversationWithUuid({
id: 'abc',
conversationColor: 'wintergreen',
describe('COLORS_CHANGED', () => {
const abc = getDefaultConversationWithUuid({
id: 'abc',
conversationColor: 'wintergreen',
});
const def = getDefaultConversationWithUuid({
id: 'def',
conversationColor: 'infrared',
});
const ghi = getDefaultConversation({
id: 'ghi',
e164: 'ghi',
conversationColor: 'ember',
});
const jkl = getDefaultConversation({
id: 'jkl',
groupId: 'jkl',
conversationColor: 'plum',
});
const getState = () => ({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
abc,
def,
ghi,
jkl,
},
conversationsByUuid: {
abc,
def,
},
conversationsByE164: {
ghi,
},
conversationsByGroupId: {
jkl,
},
},
});
it('resetAllChatColors', async () => {
const dispatch = sinon.spy();
await resetAllChatColors()(dispatch, getState, null);
const [action] = dispatch.getCall(0).args;
const nextState = reducer(getState().conversations, action);
sinon.assert.calledOnce(dispatch);
assert.isUndefined(nextState.conversationLookup.abc.conversationColor);
assert.isUndefined(nextState.conversationLookup.def.conversationColor);
assert.isUndefined(nextState.conversationLookup.ghi.conversationColor);
assert.isUndefined(nextState.conversationLookup.jkl.conversationColor);
assert.isUndefined(
nextState.conversationsByUuid[abc.uuid].conversationColor
);
assert.isUndefined(
nextState.conversationsByUuid[def.uuid].conversationColor
);
assert.isUndefined(nextState.conversationsByE164.ghi.conversationColor);
assert.isUndefined(
nextState.conversationsByGroupId.jkl.conversationColor
);
window.storage.remove('defaultConversationColor');
});
});
const def = getDefaultConversationWithUuid({
id: 'def',
conversationColor: 'infrared',
});
const ghi = getDefaultConversation({
id: 'ghi',
e164: 'ghi',
conversationColor: 'ember',
});
const jkl = getDefaultConversation({
id: 'jkl',
groupId: 'jkl',
conversationColor: 'plum',
});
const getState = () => ({
...getEmptyRootState(),
conversations: {
// When distribution lists change
describe('VIEWERS_CHANGED', () => {
const state: ConversationsStateType = {
...getEmptyState(),
conversationLookup: {
abc,
def,
ghi,
jkl,
verificationDataByConversation: {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
},
conversationsByUuid: {
abc,
def,
},
conversationsByE164: {
ghi,
},
conversationsByGroupId: {
jkl,
},
},
};
it('removes uuids now missing from the list', async () => {
const action: StoryDistributionListsActionType = {
type: VIEWERS_CHANGED,
payload: {
listId: 'abc',
memberUuids: [UUID_1, UUID_2],
},
};
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_2],
},
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
it('removes now-empty list', async () => {
const action: StoryDistributionListsActionType = {
type: VIEWERS_CHANGED,
payload: {
listId: 'abc',
memberUuids: [],
},
};
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
});
describe('HIDE_MY_STORIES_FROM', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
[MY_STORY_ID]: {
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
},
};
it('resetAllChatColors', async () => {
const dispatch = sinon.spy();
await resetAllChatColors()(dispatch, getState, null);
it('removes now hidden uuids', async () => {
const action: StoryDistributionListsActionType = {
type: HIDE_MY_STORIES_FROM,
payload: [UUID_1, UUID_2],
};
const [action] = dispatch.getCall(0).args;
const nextState = reducer(getState().conversations, action);
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
[MY_STORY_ID]: {
uuidsNeedingVerification: [UUID_3],
},
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
it('eliminates list if all items removed', async () => {
const action: StoryDistributionListsActionType = {
type: HIDE_MY_STORIES_FROM,
payload: [UUID_1, UUID_2, UUID_3],
};
sinon.assert.calledOnce(dispatch);
assert.isUndefined(nextState.conversationLookup.abc.conversationColor);
assert.isUndefined(nextState.conversationLookup.def.conversationColor);
assert.isUndefined(nextState.conversationLookup.ghi.conversationColor);
assert.isUndefined(nextState.conversationLookup.jkl.conversationColor);
assert.isUndefined(
nextState.conversationsByUuid[abc.uuid].conversationColor
);
assert.isUndefined(
nextState.conversationsByUuid[def.uuid].conversationColor
);
assert.isUndefined(nextState.conversationsByE164.ghi.conversationColor);
assert.isUndefined(
nextState.conversationsByGroupId.jkl.conversationColor
);
window.storage.remove('defaultConversationColor');
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
});
describe('DELETE_LIST', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
},
};
it('eliminates deleted list entirely', async () => {
const action: StoryDistributionListsActionType = {
type: DELETE_LIST,
payload: {
deletedAtTimestamp: Date.now(),
listId: 'abc',
},
};
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
def: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
it('deletes parent conversation if no other lists, no top-level uuids', async () => {
const starting: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
},
},
},
};
const action: StoryDistributionListsActionType = {
type: DELETE_LIST,
payload: {
deletedAtTimestamp: Date.now(),
listId: 'abc',
},
};
const actual = reducer(starting, action);
assert.deepEqual(actual.verificationDataByConversation, {});
});
it('deletes byDistributionId if top-level list does have uuids', async () => {
const starting: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1],
byDistributionId: {
abc: {
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
},
},
},
};
const action: StoryDistributionListsActionType = {
type: DELETE_LIST,
payload: {
deletedAtTimestamp: Date.now(),
listId: 'abc',
},
};
const actual = reducer(starting, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1],
},
});
});
});
describe('MODIFY_LIST', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
[UUID_1]: {
uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3],
},
[UUID_2]: {
uuidsNeedingVerification: [UUID_3],
},
},
},
},
};
it('removes toRemove uuids for isBlockList = false', async () => {
const action: StoryDistributionListsActionType = {
type: MODIFY_LIST,
payload: {
id: UUID_1,
name: 'list1',
allowsReplies: true,
isBlockList: false,
membersToAdd: [UUID_2, UUID_4],
membersToRemove: [UUID_3],
},
};
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
[UUID_1]: {
uuidsNeedingVerification: [UUID_1, UUID_2],
},
[UUID_2]: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
it('removes toAdd uuids for isBlocklist = true', async () => {
const action: StoryDistributionListsActionType = {
type: MODIFY_LIST,
payload: {
id: UUID_1,
name: 'list1',
allowsReplies: true,
isBlockList: true,
membersToAdd: [UUID_2, UUID_1],
membersToRemove: [UUID_3],
},
};
const actual = reducer(state, action);
assert.deepEqual(actual.verificationDataByConversation, {
convo1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
[UUID_1]: {
uuidsNeedingVerification: [UUID_3],
},
[UUID_2]: {
uuidsNeedingVerification: [UUID_3],
},
},
},
});
});
});
});
});

View file

@ -0,0 +1,281 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { StateType } from '../../../state/reducer';
import type { ConversationType } from '../../../state/ducks/conversations';
import type { StoryDistributionListDataType } from '../../../state/ducks/storyDistributionLists';
import type { UUIDStringType } from '../../../types/UUID';
import type { ContactsByStory } from '../../../components/SafetyNumberChangeDialog';
import * as Bytes from '../../../Bytes';
import { reducer as rootReducer } from '../../../state/reducer';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { getEmptyState } from '../../../state/ducks/conversations';
import { getByDistributionListConversationsStoppingSend } from '../../../state/selectors/conversations-extra';
import { UUID } from '../../../types/UUID';
import { noopAction } from '../../../state/ducks/noop';
import { ID_LENGTH } from '../../../groups';
import { ConversationVerificationState } from '../../../state/ducks/conversationsEnums';
describe('both/state/selectors/conversations-extra', () => {
const UUID_1 = UUID.generate().toString();
const UUID_2 = UUID.generate().toString();
const LIST_1 = UUID.generate().toString();
const LIST_2 = UUID.generate().toString();
const GROUP_ID = Bytes.toBase64(new Uint8Array(ID_LENGTH));
const getEmptyRootState = (): StateType => {
return rootReducer(undefined, noopAction());
};
function makeConversation(
id: string,
uuid?: UUIDStringType
): ConversationType {
const title = `${id} title`;
return getDefaultConversation({
id,
uuid,
searchableTitle: title,
title,
titleNoDefault: title,
});
}
function makeDistributionList(
name: string,
id: UUIDStringType
): StoryDistributionListDataType {
return {
id,
name: `distribution ${name}`,
allowsReplies: true,
isBlockList: false,
memberUuids: [],
};
}
describe('#getByDistributionListConversationsStoppingSend', () => {
const direct1 = makeConversation('direct1', UUID_1);
const direct2 = makeConversation('direct2', UUID_2);
const group1 = {
...makeConversation('group1'),
groupVersion: 2 as const,
groupId: GROUP_ID,
};
const state: StateType = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
direct1,
direct2,
group1,
},
conversationsByUuid: {
[UUID_1]: direct1,
[UUID_2]: direct2,
},
verificationDataByConversation: {},
},
storyDistributionLists: {
distributionLists: [
makeDistributionList('list1', LIST_1),
makeDistributionList('list2', LIST_2),
],
},
};
it('returns empty array for no untrusted recipients', () => {
const actual = getByDistributionListConversationsStoppingSend(state);
assert.isEmpty(actual);
});
it('returns empty story field for 1:1 conversations', () => {
const starting: StateType = {
...state,
conversations: {
...state.conversations,
verificationDataByConversation: {
direct1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1, UUID_2],
},
},
},
};
const expected: ContactsByStory = [
{
story: undefined,
contacts: [direct1, direct2],
},
];
const actual = getByDistributionListConversationsStoppingSend(starting);
assert.sameDeepMembers(actual, expected);
});
it('returns groups with name set', () => {
const starting: StateType = {
...state,
conversations: {
...state.conversations,
verificationDataByConversation: {
group1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1, UUID_2],
},
},
},
};
const expected: ContactsByStory = [
{
story: {
name: 'group1 title',
conversationId: 'group1',
},
contacts: [direct1, direct2],
},
];
const actual = getByDistributionListConversationsStoppingSend(starting);
assert.sameDeepMembers(actual, expected);
});
it('returns distribution lists with distributionId set', () => {
const starting: StateType = {
...state,
conversations: {
...state.conversations,
verificationDataByConversation: {
direct1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1],
byDistributionId: {
[LIST_1]: {
uuidsNeedingVerification: [UUID_2],
},
},
},
},
},
};
const expected: ContactsByStory = [
{
story: undefined,
contacts: [direct1],
},
{
story: {
name: 'distribution list1',
conversationId: 'direct1',
distributionId: LIST_1,
},
contacts: [direct2],
},
];
const actual = getByDistributionListConversationsStoppingSend(starting);
assert.sameDeepMembers(actual, expected);
});
it('returns distribution lists even if parent is empty', () => {
const starting: StateType = {
...state,
conversations: {
...state.conversations,
verificationDataByConversation: {
direct1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [],
byDistributionId: {
[LIST_1]: {
uuidsNeedingVerification: [UUID_1],
},
[LIST_2]: {
uuidsNeedingVerification: [UUID_2],
},
},
},
},
},
};
const expected: ContactsByStory = [
{
story: {
name: 'distribution list1',
conversationId: 'direct1',
distributionId: LIST_1,
},
contacts: [direct1],
},
{
story: {
name: 'distribution list2',
conversationId: 'direct1',
distributionId: LIST_2,
},
contacts: [direct2],
},
];
const actual = getByDistributionListConversationsStoppingSend(starting);
assert.sameDeepMembers(actual, expected);
});
it('drops items that are not pending verification', () => {
const starting: StateType = {
...state,
conversations: {
...state.conversations,
verificationDataByConversation: {
direct1: {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: Date.now(),
},
direct2: {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: Date.now(),
},
},
},
};
const expected: ContactsByStory = [];
const actual = getByDistributionListConversationsStoppingSend(starting);
assert.sameDeepMembers(actual, expected);
});
it('puts UUIDs from unknown distribution lists into their parent', () => {
const starting: StateType = {
...state,
conversations: {
...state.conversations,
verificationDataByConversation: {
direct1: {
type: ConversationVerificationState.PendingVerification,
uuidsNeedingVerification: [UUID_1],
byDistributionId: {
// Not a list id!
[UUID_1]: {
uuidsNeedingVerification: [UUID_2],
},
},
},
},
},
};
const expected: ContactsByStory = [
{
story: undefined,
contacts: [direct1, direct2],
},
];
const actual = getByDistributionListConversationsStoppingSend(starting);
assert.sameDeepMembers(actual, expected);
});
});
});

View file

@ -6,7 +6,6 @@ import * as sinon from 'sinon';
import {
_analyzeSenderKeyDevices,
_waitForAll,
_shouldFailSend,
} from '../../util/sendToGroup';
import { UUID } from '../../types/UUID';
@ -166,21 +165,6 @@ describe('sendToGroup', () => {
});
});
describe('#_waitForAll', () => {
it('returns result of provided tasks', async () => {
const task1 = () => Promise.resolve(1);
const task2 = () => Promise.resolve(2);
const task3 = () => Promise.resolve(3);
const result = await _waitForAll({
tasks: [task1, task2, task3],
maxConcurrency: 1,
});
assert.deepEqual(result, [1, 2, 3]);
});
});
describe('#_shouldFailSend', () => {
it('returns false for a generic error', async () => {
const error = new Error('generic');

View file

@ -1,63 +1,38 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationModel } from '../models/conversations';
import type { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import * as log from '../logging/log';
import { explodePromise } from './explodePromise';
import { getConversationIdForLogging } from './idForLogging';
import type { RecipientsByConversation } from '../state/ducks/stories';
import { isNotNil } from './isNotNil';
import type { UUIDStringType } from '../types/UUID';
import { waitForAll } from './waitForAll';
export async function blockSendUntilConversationsAreVerified(
conversations: Array<ConversationModel>,
source?: SafetyNumberChangeSource,
byConversationId: RecipientsByConversation,
source: SafetyNumberChangeSource,
timestampThreshold?: number
): Promise<boolean> {
const conversationsToPause = new Map<string, Set<string>>();
const allUuids = getAllUuids(byConversationId);
await waitForAll({
tasks: Array.from(allUuids).map(uuid => async () => updateUuidTrust(uuid)),
});
await Promise.all(
conversations.map(async conversation => {
if (!conversation) {
return;
}
const uuidsStoppingSend = new Set<string>();
await conversation.updateVerified();
const unverifieds = conversation.getUnverified();
if (unverifieds.length) {
unverifieds.forEach(unverifiedConversation => {
const uuid = unverifiedConversation.get('uuid');
if (uuid) {
uuidsStoppingSend.add(uuid);
}
});
}
const untrusted = conversation.getUntrusted(timestampThreshold);
if (untrusted.length) {
untrusted.forEach(untrustedConversation => {
const uuid = untrustedConversation.get('uuid');
if (uuid) {
uuidsStoppingSend.add(uuid);
}
});
}
if (uuidsStoppingSend.size) {
log.info('blockSendUntilConversationsAreVerified: blocking send', {
id: getConversationIdForLogging(conversation.attributes),
untrustedCount: uuidsStoppingSend.size,
});
conversationsToPause.set(conversation.id, uuidsStoppingSend);
}
})
const untrustedByConversation = filterUuids(
byConversationId,
(uuid: UUIDStringType) => !isUuidTrusted(uuid, timestampThreshold)
);
if (conversationsToPause.size) {
const untrustedUuids = getAllUuids(untrustedByConversation);
if (untrustedUuids.size) {
log.info(
`blockSendUntilConversationsAreVerified: Blocking send; ${untrustedUuids.size} untrusted uuids`
);
const explodedPromise = explodePromise<boolean>();
window.reduxActions.globalModals.showBlockingSafetyNumberChangeDialog(
conversationsToPause,
untrustedByConversation,
explodedPromise,
source
);
@ -66,3 +41,109 @@ export async function blockSendUntilConversationsAreVerified(
return true;
}
async function updateUuidTrust(uuid: string) {
const conversation = window.ConversationController.get(uuid);
if (!conversation) {
return;
}
await conversation.updateVerified();
}
function isUuidTrusted(uuid: string, timestampThreshold?: number) {
const conversation = window.ConversationController.get(uuid);
if (!conversation) {
log.warn(
`blockSendUntilConversationsAreVerified/isUuidTrusted: No conversation for send target ${uuid}`
);
return true;
}
const unverifieds = conversation.getUnverified();
if (unverifieds.length) {
return false;
}
const untrusted = conversation.getUntrusted(timestampThreshold);
if (untrusted.length) {
return false;
}
return true;
}
export function getAllUuids(
byConversation: RecipientsByConversation
): Set<UUIDStringType> {
const allUuids = new Set<UUIDStringType>();
Object.values(byConversation).forEach(conversationData => {
conversationData.uuids.forEach(uuid => allUuids.add(uuid));
if (conversationData.byDistributionId) {
Object.values(conversationData.byDistributionId).forEach(
distributionData => {
distributionData.uuids.forEach(uuid => allUuids.add(uuid));
}
);
}
});
return allUuids;
}
export function filterUuids(
byConversation: RecipientsByConversation,
predicate: (uuid: UUIDStringType) => boolean
): RecipientsByConversation {
const filteredByConversation: RecipientsByConversation = {};
Object.entries(byConversation).forEach(
([conversationId, conversationData]) => {
const conversationFiltered = conversationData.uuids
.map(uuid => {
if (predicate(uuid)) {
return uuid;
}
return undefined;
})
.filter(isNotNil);
let byDistributionId:
| Record<string, { uuids: Array<UUIDStringType> }>
| undefined;
if (conversationData.byDistributionId) {
Object.entries(conversationData.byDistributionId).forEach(
([distributionId, distributionData]) => {
const distributionFiltered = distributionData.uuids
.map(uuid => {
if (predicate(uuid)) {
return uuid;
}
return undefined;
})
.filter(isNotNil);
if (distributionFiltered.length) {
byDistributionId = {
...byDistributionId,
[distributionId]: {
uuids: distributionFiltered,
},
};
}
}
);
}
if (conversationFiltered.length || byDistributionId) {
filteredByConversation[conversationId] = {
uuids: conversationFiltered,
...(byDistributionId ? { byDistributionId } : undefined),
};
}
}
);
return filteredByConversation;
}

View file

@ -10,6 +10,7 @@ import { blockSendUntilConversationsAreVerified } from './blockSendUntilConversa
import { getMessageIdForLogging } from './idForLogging';
import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview';
import type { RecipientsByConversation } from '../state/ducks/stories';
export async function maybeForwardMessage(
messageAttributes: MessageAttributesType,
@ -40,13 +41,20 @@ export async function maybeForwardMessage(
throw new Error('Cannot send to group');
}
const recipientsByConversation: RecipientsByConversation = {};
conversations.forEach(conversation => {
recipientsByConversation[conversation.id] = {
uuids: conversation.getMemberUuids().map(uuid => uuid.toString()),
};
});
// Verify that all contacts that we're forwarding
// to are verified and trusted.
// 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 canSend = await blockSendUntilConversationsAreVerified(
conversations,
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!canSend) {

View file

@ -96,6 +96,10 @@ export async function sendStoryMessage(
return acc;
}
if (convo.isUnregistered()) {
return acc;
}
acc.push(uuid);
return acc;
},

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { differenceWith, omit, partition } from 'lodash';
import PQueue from 'p-queue';
import {
ErrorCode,
@ -60,7 +59,7 @@ import { SignalService as Proto } from '../protobuf';
import { strictAssert } from './assert';
import * as log from '../logging/log';
import { GLOBAL_ZONE } from '../SignalProtocolStore';
import { MINUTE } from './durations';
import { waitForAll } from './waitForAll';
const ERROR_EXPIRED_OR_MISSING_DEVICES = 409;
const ERROR_STALE_DEVICES = 410;
@ -68,8 +67,6 @@ const ERROR_STALE_DEVICES = 410;
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const MAX_CONCURRENCY = 5;
// sendWithSenderKey is recursive, but we don't want to loop back too many times.
const MAX_RECURSION = 10;
@ -530,7 +527,7 @@ export async function sendToGroupViaSenderKey(options: {
if (parsed.success) {
const { uuids404 } = parsed.data;
if (uuids404 && uuids404.length > 0) {
await _waitForAll({
await waitForAll({
tasks: uuids404.map(
uuid => async () => markIdentifierUnregistered(uuid)
),
@ -831,21 +828,6 @@ export function _shouldFailSend(error: unknown, logId: string): boolean {
return false;
}
export async function _waitForAll<T>({
tasks,
maxConcurrency = MAX_CONCURRENCY,
}: {
tasks: Array<() => Promise<T>>;
maxConcurrency?: number;
}): Promise<Array<T>> {
const queue = new PQueue({
concurrency: maxConcurrency,
timeout: MINUTE * 30,
throwOnTimeout: true,
});
return queue.addAll(tasks);
}
function getRecipients(options: GroupSendOptionsType): Array<string> {
if (options.groupV2) {
return options.groupV2.members;
@ -888,7 +870,7 @@ function isIdentifierRegistered(identifier: string) {
async function handle409Response(logId: string, error: HTTPError) {
const parsed = multiRecipient409ResponseSchema.safeParse(error.response);
if (parsed.success) {
await _waitForAll({
await waitForAll({
tasks: parsed.data.map(item => async () => {
const { uuid, devices } = item;
// Start new sessions with devices we didn't know about before
@ -900,7 +882,7 @@ async function handle409Response(logId: string, error: HTTPError) {
if (devices.extraDevices && devices.extraDevices.length > 0) {
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
await _waitForAll({
await waitForAll({
tasks: devices.extraDevices.map(deviceId => async () => {
await window.textsecure.storage.protocol.archiveSession(
new QualifiedAddress(ourUuid, Address.create(uuid, deviceId))
@ -929,14 +911,14 @@ async function handle410Response(
const parsed = multiRecipient410ResponseSchema.safeParse(error.response);
if (parsed.success) {
await _waitForAll({
await waitForAll({
tasks: parsed.data.map(item => async () => {
const { uuid, devices } = item;
if (devices.staleDevices && devices.staleDevices.length > 0) {
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
// First, archive our existing sessions with these devices
await _waitForAll({
await waitForAll({
tasks: devices.staleDevices.map(deviceId => async () => {
await window.textsecure.storage.protocol.archiveSession(
new QualifiedAddress(ourUuid, Address.create(uuid, deviceId))
@ -1281,7 +1263,7 @@ async function fetchKeysForIdentifiers(
);
try {
await _waitForAll({
await waitForAll({
tasks: identifiers.map(
identifier => async () => fetchKeysForIdentifier(identifier)
),

23
ts/util/waitForAll.ts Normal file
View file

@ -0,0 +1,23 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import PQueue from 'p-queue';
import { MINUTE } from './durations';
const MAX_CONCURRENCY = 5;
export async function waitForAll<T>({
tasks,
maxConcurrency = MAX_CONCURRENCY,
}: {
tasks: Array<() => Promise<T>>;
maxConcurrency?: number;
}): Promise<Array<T>> {
const queue = new PQueue({
concurrency: maxConcurrency,
timeout: MINUTE * 30,
throwOnTimeout: true,
});
return queue.addAll(tasks);
}

View file

@ -2326,8 +2326,14 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
async isCallSafe(): Promise<boolean> {
const recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
const callAnyway = await blockSendUntilConversationsAreVerified(
[this.model],
recipientsByConversation,
SafetyNumberChangeSource.Calling
);
@ -2345,11 +2351,15 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
packId: string;
stickerId: number;
}): Promise<void> {
const { model }: { model: ConversationModel } = this;
const recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
try {
const sendAnyway = await blockSendUntilConversationsAreVerified(
[this.model],
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!sendAnyway) {
@ -2361,7 +2371,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
const { packId, stickerId } = options;
model.sendStickerMessage(packId, stickerId);
this.model.sendStickerMessage(packId, stickerId);
} catch (error) {
log.error('clickSend error:', error && error.stack ? error.stack : error);
}
@ -2497,16 +2507,20 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
voiceNoteAttachment?: AttachmentType;
} = {}
): Promise<void> {
const { model }: { model: ConversationModel } = this;
const timestamp = options.timestamp || Date.now();
this.sendStart = Date.now();
const recipientsByConversation = {
[this.model.id]: {
uuids: this.model.getMemberUuids().map(uuid => uuid.toString()),
},
};
try {
this.disableMessageField();
const sendAnyway = await blockSendUntilConversationsAreVerified(
[this.model],
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!sendAnyway) {
@ -2522,7 +2536,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
return;
}
model.clearTypingTimers();
this.model.clearTypingTimers();
if (this.showInvalidMessageToast(message)) {
this.enableMessageField();
@ -2556,7 +2570,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
log.info('Send pre-checks took', sendDelta, 'milliseconds');
await model.enqueueMessageForSend(
await this.model.enqueueMessageForSend(
{
body: message,
attachments,
@ -2569,7 +2583,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
timestamp,
extraReduxActions: () => {
this.compositionApi.current?.reset();
model.setMarkedUnread(false);
this.model.setMarkedUnread(false);
this.setQuoteMessage(null);
resetLinkPreview();
this.clearAttachments();