diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 73be719cf57..21fb652da78 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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." diff --git a/images/icons/v2/x-circle-16.svg b/images/icons/v2/x-circle-16.svg new file mode 100644 index 00000000000..9ac61f3e009 --- /dev/null +++ b/images/icons/v2/x-circle-16.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/stylesheets/components/SafetyNumberChangeDialog.scss b/stylesheets/components/SafetyNumberChangeDialog.scss index 6c43effaa3a..554b99447c6 100644 --- a/stylesheets/components/SafetyNumberChangeDialog.scss +++ b/stylesheets/components/SafetyNumberChangeDialog.scss @@ -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 + ); + } + } } } diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 1c8cdf87607..64ee7588c12 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -356,7 +356,12 @@ const ActiveCallManager: React.FC = ({ activeCall.conversationsWithSafetyNumberChanges.length ? ( { const theme = useTheme(); return ( undefined} i18n={i18n} onCancel={action('cancel')} onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} renderSafetyNumber={() => { action('renderSafetyNumber'); return
This is a mock Safety Number View
; @@ -80,11 +87,17 @@ export const DifferentConfirmationText = (): JSX.Element => { return ( undefined} i18n={i18n} onCancel={action('cancel')} onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} renderSafetyNumber={() => { action('renderSafetyNumber'); return
This is a mock Safety Number View
; @@ -99,15 +112,20 @@ export const MultiContactDialog = (): JSX.Element => { return ( undefined} i18n={i18n} onCancel={action('cancel')} onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} renderSafetyNumber={() => { action('renderSafetyNumber'); return
This is a mock Safety Number View
; @@ -121,11 +139,20 @@ export const AllVerified = (): JSX.Element => { const theme = useTheme(); return ( undefined} i18n={i18n} onCancel={action('cancel')} onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} renderSafetyNumber={() => { action('renderSafetyNumber'); return
This is a mock Safety Number View
; @@ -143,15 +170,21 @@ export const MultipleContactsAllWithBadges = (): JSX.Element => { return ( getFakeBadge()} i18n={i18n} onCancel={action('cancel')} onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} renderSafetyNumber={() => { action('renderSafetyNumber'); return
This is a mock Safety Number View
; @@ -170,21 +203,27 @@ export const TenContacts = (): JSX.Element => { return ( undefined} i18n={i18n} onCancel={action('cancel')} onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} renderSafetyNumber={() => { action('renderSafetyNumber'); return
This is a mock Safety Number View
; @@ -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 ( + undefined} + i18n={i18n} + onCancel={action('cancel')} + onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} + renderSafetyNumber={() => { + action('renderSafetyNumber'); + return
This is a mock Safety Number View
; + }} + theme={theme} + /> + ); +}; + +export const InMultipleStories = (): JSX.Element => { + const theme = useTheme(); + return ( + undefined} + i18n={i18n} + onCancel={action('cancel')} + onConfirm={action('confirm')} + removeFromStory={action('removeFromStory')} + renderSafetyNumber={() => { + action('renderSafetyNumber'); + return
This is a mock Safety Number View
; + }} + theme={theme} + /> + ); +}; diff --git a/ts/components/SafetyNumberChangeDialog.tsx b/ts/components/SafetyNumberChangeDialog.tsx index b71e7e32e53..0d0259379f9 100644 --- a/ts/components/SafetyNumberChangeDialog.tsx +++ b/ts/components/SafetyNumberChangeDialog.tsx @@ -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; - 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; }; +export type ContactsByStory = Array; + +export type Props = Readonly<{ + confirmText?: string; + contacts: ContactsByStory; + getPreferredBadge: PreferredBadgeSelectorType; + i18n: LocalizerType; + onCancel: () => void; + onConfirm: () => void; + removeFromStory?: ( + distributionId: string, + uuids: Array + ) => 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( - 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( + 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 = [ - { - 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 ( { + 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 = ({
{i18n('safetyNumberChangeDialog__message')}
-
    - {contacts.map((contact: ConversationType) => { - const shouldShowNumber = Boolean( - contact.name || contact.profileName - ); - - return ( - - ); - })} -
+ {contacts.map((section: StoryContacts) => ( + + ))}
); } - 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 = [ + { + action: onConfirm, + text, + style: 'affirmative', + }, + ]; + + if (dialogState === DialogState.ExplicitReviewNeeded) { + actions.unshift({ + action: () => setDialogState(DialogState.ExplicitReviewStep), + text: i18n('safetyNumberChangeDialog__review'), + }); + } return ( @@ -158,34 +244,205 @@ export const SafetyNumberChangeDialog = ({
{i18n('safetyNumberChanges')}
-
- {i18n('icu:safetyNumberChangeDialog__many-contacts', { - count: contacts.length, - })} +
+ {dialogState === DialogState.ExplicitReviewNeeded + ? i18n('icu:safetyNumberChangeDialog__many-contacts', { + count: totalCount, + }) + : i18n('safetyNumberChangeDialog__post-review')}
); }; -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 + ) => unknown; setSelectedContact: (contact: ConversationType) => void; - shouldShowNumber: boolean; theme: ThemeType; -}>; +}>) { + if (section.contacts.length === 0) { + return null; + } + + if (!section.story) { + return ( +
    + {section.contacts.map((contact: ConversationType) => { + const shouldShowNumber = Boolean(contact.name || contact.profileName); + + return ( + + ); + })} +
+ ); + } + + 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 ( +
+
+
+ {sectionName} +
+ {distributionId && removeFromStory && uuids.length > 1 && ( + { + removeFromStory(distributionId, uuids); + }} + /> + )} +
+
    + {section.contacts.map((contact: ConversationType) => { + const shouldShowNumber = Boolean(contact.name || contact.profileName); + + return ( + + ); + })} +
+
+ ); +} + +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(false); + + return ( + <> + setIsConfirming(true), + }, + ]} + moduleClassName="module-SafetyNumberChangeDialog__row__chevron" + theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light} + /> + {isConfirming && ( + { + 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, + })} + + )} + + ); +} 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 + ) => unknown; + setSelectedContact: (contact: ConversationType) => void; + shouldShowNumber: boolean; + theme: ThemeType; +}>) { + const { uuid } = contact; + return ( -
  • +
  • -
    -
    +
    +
    {contact.title} - {isInSystemContacts(contact) ? ( + {isInSystemContacts(contact) && ( {' '} - ) : null} + )}
    {shouldShowNumber || contact.isVerified ? ( -
    - {shouldShowNumber ? ( +
    + {shouldShowNumber && ( {contact.phoneNumber} - ) : ( - '' )} - {shouldShowNumber && contact.isVerified ? ( + {shouldShowNumber && contact.isVerified && (  ·  - ) : ( - '' )} - {contact.isVerified ? ( + {contact.isVerified && ( {i18n('verified')} - ) : ( - '' )}
    ) : null}
    - + {distributionId && removeFromStory && uuid ? ( + removeFromStory(distributionId, [uuid])} + verifyContact={() => setSelectedContact(contact)} + /> + ) : ( + + )}
  • ); } + +function RowButtonWithMenu({ + ariaLabel, + i18n, + removeFromStory, + verifyContact, + theme, +}: Readonly<{ + ariaLabel: string; + i18n: LocalizerType; + removeFromStory: () => unknown; + verifyContact: () => unknown; + theme: ThemeType; +}>) { + return ( + + ); +} diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index e99141cb03b..e30256b5389 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -48,6 +48,7 @@ export type PropsType = { groupConversations: Array; groupStories: Array; hasFirstStoryPostExperience: boolean; + ourConversationId: string | undefined; i18n: LocalizerType; me: ConversationType; onClose: () => unknown; @@ -56,7 +57,11 @@ export type PropsType = { name: string, viewerUuids: Array ) => unknown; - onSelectedStoryList: (memberUuids: Array) => unknown; + onSelectedStoryList: (options: { + conversationId: string; + distributionId: string | undefined; + uuids: Array; + }) => unknown; onSend: ( listIds: Array, conversationIds: Array @@ -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 -): Array { +): Array { 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), + }); } }} > diff --git a/ts/components/StoriesSettingsModal.stories.tsx b/ts/components/StoriesSettingsModal.stories.tsx index 5fd89b021a4..c6e4a34f0db 100644 --- a/ts/components/StoriesSettingsModal.stories.tsx +++ b/ts/components/StoriesSettingsModal.stories.tsx @@ -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 }, diff --git a/ts/components/StoriesSettingsModal.tsx b/ts/components/StoriesSettingsModal.tsx index 750f5170bc6..0f747fa40bf 100644 --- a/ts/components/StoriesSettingsModal.tsx +++ b/ts/components/StoriesSettingsModal.tsx @@ -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; @@ -55,7 +56,7 @@ export type PropsType = { viewerUuids: Array ) => unknown; onHideMyStoriesFrom: (viewerUuids: Array) => unknown; - onRemoveMember: (listId: string, uuid: UUIDStringType | undefined) => unknown; + onRemoveMembers: (listId: string, uuids: Array) => 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" />
    @@ -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'), }, diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index 22d704e9831..c25fab59a3e 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -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) => { diff --git a/ts/jobs/conversationJobQueue.ts b/ts/jobs/conversationJobQueue.ts index 3ede938850f..35b7bba511e 100644 --- a/ts/jobs/conversationJobQueue.ts +++ b/ts/jobs/conversationJobQueue.ts @@ -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 { } } } catch (error: unknown) { - const untrustedUuids: Array = []; + const untrustedUuids: Array = []; const processError = (toProcess: unknown) => { if (toProcess instanceof OutgoingIdentityKeyError) { diff --git a/ts/jobs/helpers/getUntrustedConversationUuids.ts b/ts/jobs/helpers/getUntrustedConversationUuids.ts index 48a2ad2c7fe..9dba77eeda8 100644 --- a/ts/jobs/helpers/getUntrustedConversationUuids.ts +++ b/ts/jobs/helpers/getUntrustedConversationUuids.ts @@ -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 -): Array { +): Array { return recipients .map(recipient => { const recipientConversation = window.ConversationController.getOrCreate( diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 743bdd36569..8c75c2419fd 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -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; recipientIdentifiersWithoutMe: Array; sentRecipientIdentifiers: Array; - untrustedUuids: Array; + untrustedUuids: Array; } { const allRecipientIdentifiers: Array = []; const recipientIdentifiersWithoutMe: Array = []; - const untrustedUuids: Array = []; + const untrustedUuids: Array = []; const sentRecipientIdentifiers: Array = []; const currentConversationRecipients = conversation.getMemberConversationIds(); diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index fe616126e07..67c14781ede 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -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; recipientIdentifiersWithoutMe: Array; - untrustedUuids: Array; + untrustedUuids: Array; } { const allRecipientIdentifiers: Array = []; const recipientIdentifiersWithoutMe: Array = []; - const untrustedUuids: Array = []; + const untrustedUuids: Array = []; const currentConversationRecipients = conversation.getMemberConversationIds(); @@ -413,7 +414,6 @@ function getRecipients( continue; } if (recipient.isUnregistered()) { - untrustedUuids.push(recipientIdentifier); continue; } diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 8fe3c9eb027..ac690049621 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -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; pendingSendRecipientIds: Array; sentRecipientIds: Array; - untrustedUuids: Array; + untrustedUuids: Array; } { const allRecipientIds: Array = []; const allowedReplyByUuid = new Map(); const pendingSendRecipientIds: Array = []; const sentRecipientIds: Array = []; - const untrustedUuids: Array = []; + const untrustedUuids: Array = []; Object.entries(message.get('sendStateByConversationId') || {}).forEach( ([recipientConversationId, sendState]) => { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index b30557beb1c..c3e15ea677b 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; }; +type DistributionVerificationData = { + uuidsNeedingVerification: ReadonlyArray; +}; + export type ConversationVerificationData = | { type: ConversationVerificationState.PendingVerification; - uuidsNeedingVerification: ReadonlyArray; + uuidsNeedingVerification: ReadonlyArray; + + byDistributionId?: Record; } | { 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; + 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; + distributionId?: string; + untrustedUuids: ReadonlyArray; }; }; 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; + distributionId?: string; + untrustedUuids: ReadonlyArray; }): 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, - conversationId: string, - untrustedUuids: ReadonlyArray -): Record { - const { verificationDataByConversation } = state; - const existingPendingState = getOwn( - verificationDataByConversation, - conversationId - ); +function getVerificationDataForConversation({ + conversationId, + distributionId, + state, + untrustedUuids, +}: { + conversationId: string; + distributionId?: string; + state: Readonly; + untrustedUuids: ReadonlyArray; +}): 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 = Array.from( - new Set([ - ...existingPendingState.uuidsNeedingVerification, - ...untrustedUuids, - ]) + const existingUuids = distributionId + ? existing.byDistributionId?.[distributionId]?.uuidsNeedingVerification + : existing.uuidsNeedingVerification; + + const uuidsNeedingVerification: ReadonlyArray = 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 = getEmptyState(), - action: Readonly + action: Readonly ): 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,14 +2718,148 @@ export function reducer( selectedMessageSource: SelectedMessageSource.Focus, }; } + + 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, untrustedUuids } = action.payload; + const { conversationId, distributionId, untrustedUuids } = action.payload; - const nextVerificationData = getVerificationDataForConversation( - state, + const nextVerificationData = getVerificationDataForConversation({ conversationId, - untrustedUuids - ); + distributionId, + state: state.verificationDataByConversation, + untrustedUuids, + }); return { ...state, @@ -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); } ); diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 18f6ae1c1dc..e8e5a62365a 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -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>; + 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>, + untrustedByConversation: RecipientsByConversation, explodedPromise: ExplodePromiseResultType, source?: SafetyNumberChangeSource -): ThunkAction { +): ThunkAction { const promiseUuid = SingleServePromise.set(explodedPromise); return dispatch => { dispatch({ type: SHOW_SEND_ANYWAY_DIALOG, payload: { - conversationsToPause, + untrustedByConversation, promiseUuid, source, }, diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 9af32768c29..84840f044c6 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -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; + + byDistributionId?: Record< + string, // distributionId + { + uuids: Array; + } + >; + } +>; + // 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; }>; selectedStoryData?: SelectedStoryDataType; - addStoryData: AddStoryData; - sendStoryModalData?: Readonly<{ - untrustedUuids: ReadonlyArray; - verifiedUuids: ReadonlyArray; - }>; + sendStoryModalData?: RecipientsByConversation; stories: ReadonlyArray; - hasAllStoriesUnmuted: boolean; }>; // Actions @@ -149,8 +160,9 @@ type DOEStoryActionType = { type ListMembersVerified = { type: typeof LIST_MEMBERS_VERIFIED; payload: { - untrustedUuids: Array; - verifiedUuids: Array; + conversationId: string; + distributionId: string | undefined; + uuids: Array; }; }; @@ -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'); - - const conversationsNeedingVerification: Array = - sendStoryModalData.untrustedUuids - .map(uuid => window.ConversationController.get(uuid)) - .filter(isNotNil); - - if (!conversationsNeedingVerification.length) { - log.warn( - 'sendStoryMessage: Could not retrieve conversations for untrusted uuids' - ); - return; - } - - const result = await blockSendUntilConversationsAreVerified( - conversationsNeedingVerification, - SafetyNumberChangeSource.Story, - Date.now() - openedAtTimestamp - ); + log.info('sendStoryMessage: Verifing trust for all recipients'); - if (!result) { - log.info('sendStoryMessage: failed to verify untrusted; stopping send'); - dispatch({ - type: SET_STORY_SENDING, - payload: false, - }); - return; - } + const result = await blockSendUntilConversationsAreVerified( + sendStoryModalData, + SafetyNumberChangeSource.Story, + Date.now() - openedAtTimestamp + ); - // 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 -): ThunkAction { +function verifyStoryListMembers({ + conversationId, + distributionId, + uuids, +}: { + conversationId: string; + distributionId: string | undefined; + uuids: Array; +}): ThunkAction { 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, + }, }, }; } diff --git a/ts/state/ducks/storyDistributionLists.ts b/ts/state/ducks/storyDistributionLists.ts index f7571befd7b..860d73195f1 100644 --- a/ts/state/ducks/storyDistributionLists.ts +++ b/ts/state/ducks/storyDistributionLists.ts @@ -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; + memberUuids: Array; }; 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; + payload: Array; }; type ModifyDistributionListType = Omit< StoryDistributionListDataType, 'memberUuids' > & { - membersToAdd: Array; - membersToRemove: Array; + membersToAdd: Array; + membersToRemove: Array; }; 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; + memberUuids: Array; }; }; -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 { + memberUuids: Array +): ThunkAction { 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 = []; + let toRemove: Array = 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(existingDistributionList.memberUuids); + const memberUuids = new Set( + 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, diff --git a/ts/state/selectors/conversations-extra.ts b/ts/state/selectors/conversations-extra.ts new file mode 100644 index 00000000000..2c968255d4f --- /dev/null +++ b/ts/state/selectors/conversations-extra.ts @@ -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 + ): 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; + } +); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 821164079db..0a7d224d8eb 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -1074,7 +1074,7 @@ export const getContactSelector = createSelector( } ); -const getConversationVerificationData = createSelector( +export const getConversationVerificationData = createSelector( getConversations, ( conversations: Readonly @@ -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); diff --git a/ts/state/smart/SendAnywayDialog.tsx b/ts/state/smart/SendAnywayDialog.tsx index da2956ac48e..69c09f0489c 100644 --- a/ts/state/smart/SendAnywayDialog.tsx +++ b/ts/state/smart/SendAnywayDialog.tsx @@ -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(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 }) => ( )} diff --git a/ts/state/smart/StoriesSettingsModal.tsx b/ts/state/smart/StoriesSettingsModal.tsx index a07ae31a825..1f538a1c550 100644 --- a/ts/state/smart/StoriesSettingsModal.tsx +++ b/ts/state/smart/StoriesSettingsModal.tsx @@ -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} diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index fc20a5cec20..4dfd4d5bf62 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -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} diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index d6d68e6c71d..82d95df9e90 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -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), [ diff --git a/ts/test-both/util/blockSendUntilConversationsAreVerified_test.ts b/ts/test-both/util/blockSendUntilConversationsAreVerified_test.ts new file mode 100644 index 00000000000..0264281d769 --- /dev/null +++ b/ts/test-both/util/blockSendUntilConversationsAreVerified_test.ts @@ -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 = []; + 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 = [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 = [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 = [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); + }); + }); +}); diff --git a/ts/test-both/util/waitForAll.ts b/ts/test-both/util/waitForAll.ts new file mode 100644 index 00000000000..52d64179146 --- /dev/null +++ b/ts/test-both/util/waitForAll.ts @@ -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]); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index a2552285c4b..5cd43bb3979 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -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', - }); - 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', + 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 getState = () => ({ - ...getEmptyRootState(), - conversations: { + + // When distribution lists change + + describe('VIEWERS_CHANGED', () => { + const state: ConversationsStateType = { ...getEmptyState(), - conversationLookup: { - abc, - def, - ghi, - jkl, - }, - conversationsByUuid: { - abc, - def, + verificationDataByConversation: { + convo1: { + type: ConversationVerificationState.PendingVerification, + uuidsNeedingVerification: [], + byDistributionId: { + abc: { + uuidsNeedingVerification: [UUID_1, UUID_2, UUID_3], + }, + def: { + uuidsNeedingVerification: [UUID_3], + }, + }, + }, }, - conversationsByE164: { - ghi, + }; + + 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], + }, + }, + }, }, - conversationsByGroupId: { - jkl, + }; + + it('removes now hidden uuids', async () => { + const action: StoryDistributionListsActionType = { + type: HIDE_MY_STORIES_FROM, + payload: [UUID_1, UUID_2], + }; + + 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], + }; + + 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('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'); + 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], + }, + }, + }, + }); + }); }); }); }); diff --git a/ts/test-electron/state/selectors/conversations-extra_test.ts b/ts/test-electron/state/selectors/conversations-extra_test.ts new file mode 100644 index 00000000000..7c1289bf419 --- /dev/null +++ b/ts/test-electron/state/selectors/conversations-extra_test.ts @@ -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); + }); + }); +}); diff --git a/ts/test-electron/util/sendToGroup_test.ts b/ts/test-electron/util/sendToGroup_test.ts index 0fe3cb6dc30..01dcfb6efd1 100644 --- a/ts/test-electron/util/sendToGroup_test.ts +++ b/ts/test-electron/util/sendToGroup_test.ts @@ -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'); diff --git a/ts/util/blockSendUntilConversationsAreVerified.ts b/ts/util/blockSendUntilConversationsAreVerified.ts index fc976110465..b7f994214d7 100644 --- a/ts/util/blockSendUntilConversationsAreVerified.ts +++ b/ts/util/blockSendUntilConversationsAreVerified.ts @@ -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, - source?: SafetyNumberChangeSource, + byConversationId: RecipientsByConversation, + source: SafetyNumberChangeSource, timestampThreshold?: number ): Promise { - const conversationsToPause = new Map>(); + 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(); - - 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(); 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 { + const allUuids = new Set(); + 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 }> + | 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; +} diff --git a/ts/util/maybeForwardMessage.ts b/ts/util/maybeForwardMessage.ts index 015a0c73833..1f6b57e668f 100644 --- a/ts/util/maybeForwardMessage.ts +++ b/ts/util/maybeForwardMessage.ts @@ -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) { diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 334e0ff27fd..378c1df0541 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -96,6 +96,10 @@ export async function sendStoryMessage( return acc; } + if (convo.isUnregistered()) { + return acc; + } + acc.push(uuid); return acc; }, diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 628886e6ce2..da2685f3d0e 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -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({ - tasks, - maxConcurrency = MAX_CONCURRENCY, -}: { - tasks: Array<() => Promise>; - maxConcurrency?: number; -}): Promise> { - const queue = new PQueue({ - concurrency: maxConcurrency, - timeout: MINUTE * 30, - throwOnTimeout: true, - }); - return queue.addAll(tasks); -} - function getRecipients(options: GroupSendOptionsType): Array { 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) ), diff --git a/ts/util/waitForAll.ts b/ts/util/waitForAll.ts new file mode 100644 index 00000000000..c775021616b --- /dev/null +++ b/ts/util/waitForAll.ts @@ -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({ + tasks, + maxConcurrency = MAX_CONCURRENCY, +}: { + tasks: Array<() => Promise>; + maxConcurrency?: number; +}): Promise> { + const queue = new PQueue({ + concurrency: maxConcurrency, + timeout: MINUTE * 30, + throwOnTimeout: true, + }); + return queue.addAll(tasks); +} diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 121489f0232..425361eb231 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -2326,8 +2326,14 @@ export class ConversationView extends window.Backbone.View { } async isCallSafe(): Promise { + 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 { packId: string; stickerId: number; }): Promise { - 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 { } 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 { voiceNoteAttachment?: AttachmentType; } = {} ): Promise { - 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 { return; } - model.clearTypingTimers(); + this.model.clearTypingTimers(); if (this.showInvalidMessageToast(message)) { this.enableMessageField(); @@ -2556,7 +2570,7 @@ export class ConversationView extends window.Backbone.View { 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 { timestamp, extraReduxActions: () => { this.compositionApi.current?.reset(); - model.setMarkedUnread(false); + this.model.setMarkedUnread(false); this.setQuoteMessage(null); resetLinkPreview(); this.clearAttachments();