+
+
{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();