signal-desktop/ts/state/ducks/conversations.ts

6081 lines
160 KiB
TypeScript

// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import {
difference,
fromPairs,
omit,
orderBy,
pick,
values,
without,
} from 'lodash';
import { clipboard } from 'electron';
import type { ReadonlyDeep } from 'type-fest';
import type { AttachmentType } from '../../types/Attachment';
import type { StateType as RootStateType } from '../reducer';
import * as groups from '../../groups';
import * as log from '../../logging/log';
import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
import { assertDev, strictAssert } from '../../util/assert';
import { drop } from '../../util/drop';
import type { DurationInSeconds } from '../../util/durations';
import * as universalExpireTimer from '../../util/universalExpireTimer';
import * as Attachment from '../../types/Attachment';
import { isFileDangerous } from '../../util/isFileDangerous';
import type {
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,
} from '../../util/uuidFetchState';
import type {
AvatarColorType,
ConversationColorType,
CustomColorType,
} from '../../types/Colors';
import type {
ConversationAttributesType,
DraftEditMessageType,
LastMessageStatus,
MessageAttributesType,
} from '../../model-types.d';
import type {
DraftBodyRanges,
HydratedBodyRangesType,
} from '../../types/BodyRange';
import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID';
import { MY_STORY_ID, StorySendMode } from '../../types/Stories';
import * as Errors from '../../types/errors';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
import { isMessageUnread } from '../../util/isMessageUnread';
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile';
import {
getConversationUuidsStoppingSend,
getConversationIdsStoppedForVerification,
getConversationSelector,
getMe,
getMessagesByConversation,
} from '../selectors/conversations';
import type { AvatarDataType, AvatarUpdateType } from '../../types/Avatar';
import { getDefaultAvatars } from '../../types/Avatar';
import { getAvatarData } from '../../util/getAvatarData';
import { isSameAvatarData } from '../../util/isSameAvatarData';
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import {
ComposerStep,
ConversationVerificationState,
OneTimeModalState,
TargetedMessageSource,
} from './conversationsEnums';
import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { NoopActionType } from './noop';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue';
import type { TimelineMessageLoadingState } from '../../util/timelineUtil';
import {
isDirectConversation,
isGroup,
isGroupV2,
} from '../../util/whatTypeOfConversation';
import { missingCaseError } from '../../util/missingCaseError';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { isIncoming, processBodyRanges } from '../selectors/message';
import { getActiveCallState } from '../selectors/calling';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import type { ShowToastActionType } from './toast';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import { isMemberRequestingToJoin } from '../../util/groupMembershipUtils';
import { removePendingMember } from '../../util/removePendingMember';
import { denyPendingApprovalRequest } from '../../util/denyPendingApprovalRequest';
import { SignalService as Proto } from '../../protobuf';
import { addReportSpamJob } from '../../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../../jobs/reportSpamJobQueue';
import {
modifyGroupV2,
buildAddMembersChange,
buildPromotePendingAdminApprovalMemberChange,
buildUpdateAttributesChange,
initiateMigrationToGroupV2 as doInitiateMigrationToGroupV2,
} from '../../groups';
import { getMessageById } from '../../messages/getMessageById';
import type { PanelRenderType, PanelRequestType } from '../../types/Panels';
import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue';
import { isOlderThan } from '../../util/timestamp';
import { DAY } from '../../util/durations';
import { isNotNil } from '../../util/isNotNil';
import { PanelType } from '../../types/Panels';
import { startConversation } from '../../util/startConversation';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
import { UUIDKind } from '../../types/UUID';
import { removeLinkPreview } from '../../services/LinkPreview';
import type {
ReplaceAttachmentsActionType,
ResetComposerActionType,
SetFocusActionType,
SetQuotedMessageActionType,
} from './composer';
import {
SET_FOCUS,
replaceAttachments,
setComposerFocus,
setQuoteByMessageId,
resetComposer,
handleLeaveConversation,
} from './composer';
import { ReceiptType } from '../../types/Receipt';
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
import { Sound, SoundType } from '../../util/Sound';
import { canEditMessage } from '../../util/canEditMessage';
// State
export type DBConversationType = ReadonlyDeep<{
id: string;
activeAt?: number;
lastMessage?: string | null;
type: string;
}>;
export const InteractionModes = ['mouse', 'keyboard'] as const;
export type InteractionModeType = ReadonlyDeep<typeof InteractionModes[number]>;
export type MessageTimestamps = ReadonlyDeep<
Pick<MessageAttributesType, 'sent_at' | 'received_at'>
>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageType = MessageAttributesType & {
interactionType?: InteractionModeType;
};
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageWithUIFieldsType = MessageAttributesType & {
displayLimit?: number;
isSpoilerExpanded?: Record<number, boolean>;
};
export const ConversationTypes = ['direct', 'group'] as const;
export type ConversationTypeType = ReadonlyDeep<
typeof ConversationTypes[number]
>;
export type LastMessageType = ReadonlyDeep<
| {
deletedForEveryone: false;
author?: string;
bodyRanges?: HydratedBodyRangesType;
prefix?: string;
status?: LastMessageStatus;
text: string;
}
| { deletedForEveryone: true }
>;
export type DraftPreviewType = ReadonlyDeep<{
text: string;
prefix?: string;
bodyRanges?: HydratedBodyRangesType;
}>;
export type ConversationType = ReadonlyDeep<
{
id: string;
uuid?: UUIDStringType;
pni?: UUIDStringType;
e164?: string;
name?: string;
systemGivenName?: string;
systemFamilyName?: string;
systemNickname?: string;
familyName?: string;
firstName?: string;
profileName?: string;
username?: string;
about?: string;
aboutText?: string;
aboutEmoji?: string;
avatars?: ReadonlyArray<AvatarDataType>;
avatarPath?: string;
avatarHash?: string;
profileAvatarPath?: string;
unblurredAvatarPath?: string;
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
canChangeTimer?: boolean;
canEditGroupInfo?: boolean;
canAddNewMembers?: boolean;
color?: AvatarColorType;
conversationColor?: ConversationColorType;
customColor?: CustomColorType;
customColorId?: string;
discoveredUnregisteredAt?: number;
hideStory?: boolean;
hiddenFromConversationSearch?: boolean;
isArchived?: boolean;
isBlocked?: boolean;
removalStage?: 'justNotification' | 'messageRequest';
isGroupV1AndDisabled?: boolean;
isPinned?: boolean;
isUntrusted?: boolean;
isVerified?: boolean;
activeAt?: number;
timestamp?: number;
inboxPosition?: number;
left?: boolean;
lastMessage?: LastMessageType;
markedUnread?: boolean;
phoneNumber?: string;
membersCount?: number;
hasMessages?: boolean;
accessControlAddFromInviteLink?: number;
accessControlAttributes?: number;
accessControlMembers?: number;
announcementsOnly?: boolean;
announcementsOnlyReady?: boolean;
expireTimer?: DurationInSeconds;
memberships?: ReadonlyArray<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
pendingMemberships?: ReadonlyArray<{
uuid: UUIDStringType;
addedByUserId?: UUIDStringType;
}>;
pendingApprovalMemberships?: ReadonlyArray<{
uuid: UUIDStringType;
}>;
bannedMemberships?: ReadonlyArray<UUIDStringType>;
muteExpiresAt?: number;
dontNotifyForMentionsIfMuted?: boolean;
isMe: boolean;
lastUpdated?: number;
// This is used by the CompositionInput for @mentions
sortedGroupMembers?: ReadonlyArray<ConversationType>;
title: string;
titleNoDefault?: string;
searchableTitle?: string;
unreadCount?: number;
unreadMentionsCount?: number;
isSelected?: boolean;
isFetchingUUID?: boolean;
typingContactId?: string;
recentMediaItems?: ReadonlyArray<MediaItemType>;
profileSharing?: boolean;
shouldShowDraft?: boolean;
// Full information for re-hydrating composition area
draftText?: string;
draftEditMessage?: DraftEditMessageType;
draftBodyRanges?: DraftBodyRanges;
// Summary for the left pane
draftPreview?: DraftPreviewType;
sharedGroupNames: ReadonlyArray<string>;
groupDescription?: string;
groupVersion?: 1 | 2;
groupId?: string;
groupLink?: string;
messageRequestsEnabled?: boolean;
acceptedMessageRequest: boolean;
secretParams?: string;
publicParams?: string;
profileKey?: string;
voiceNotePlaybackRate?: number;
badges: ReadonlyArray<
| {
id: string;
}
| {
id: string;
expiresAt: number;
isVisible: boolean;
}
>;
} & (
| {
type: 'direct';
storySendMode?: undefined;
acknowledgedGroupNameCollisions?: undefined;
}
| {
type: 'group';
storySendMode: StorySendMode;
acknowledgedGroupNameCollisions: GroupNameCollisionsWithIdsByTitle;
}
)
>;
export type ProfileDataType = ReadonlyDeep<
{
firstName: string;
} & Pick<ConversationType, 'aboutEmoji' | 'aboutText' | 'familyName'>
>;
export type ConversationLookupType = ReadonlyDeep<{
[key: string]: ConversationType;
}>;
export type CustomError = ReadonlyDeep<
Error & {
identifier?: string;
number?: string;
}
>;
type MessagePointerType = ReadonlyDeep<{
id: string;
received_at: number;
sent_at?: number;
}>;
type MessageMetricsType = ReadonlyDeep<{
newest?: MessagePointerType;
oldest?: MessagePointerType;
oldestUnseen?: MessagePointerType;
totalUnseen: number;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageLookupType = {
[key: string]: MessageWithUIFieldsType;
};
export type ConversationMessageType = ReadonlyDeep<{
isNearBottom?: boolean;
messageChangeCounter: number;
messageIds: ReadonlyArray<string>;
messageLoadingState?: undefined | TimelineMessageLoadingState;
metrics: MessageMetricsType;
scrollToMessageId?: string;
scrollToMessageCounter: number;
}>;
export type MessagesByConversationType = ReadonlyDeep<{
[key: string]: ConversationMessageType | undefined;
}>;
export type PreJoinConversationType = ReadonlyDeep<{
avatar?: {
loading?: boolean;
url?: string;
};
groupDescription?: string;
memberCount: number;
title: string;
approvalRequired: boolean;
}>;
type ComposerGroupCreationState = ReadonlyDeep<{
groupAvatar: undefined | Uint8Array;
groupName: string;
groupExpireTimer: DurationInSeconds;
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
selectedConversationIds: ReadonlyArray<string>;
userAvatarData: ReadonlyArray<AvatarDataType>;
}>;
type DistributionVerificationData = ReadonlyDeep<{
uuidsNeedingVerification: Array<UUIDStringType>;
}>;
export type ConversationVerificationData = ReadonlyDeep<
| {
type: ConversationVerificationState.PendingVerification;
uuidsNeedingVerification: ReadonlyArray<UUIDStringType>;
byDistributionId?: Record<string, DistributionVerificationData>;
}
| {
type: ConversationVerificationState.VerificationCancelled;
canceledAt: number;
}
>;
type VerificationDataByConversation = ReadonlyDeep<
Record<string, ConversationVerificationData>
>;
type ComposerStateType = ReadonlyDeep<
| {
step: ComposerStep.StartDirectConversation;
searchTerm: string;
uuidFetchState: UUIDFetchStateType;
}
| ({
step: ComposerStep.ChooseGroupMembers;
searchTerm: string;
uuidFetchState: UUIDFetchStateType;
} & ComposerGroupCreationState)
| ({
step: ComposerStep.SetGroupMetadata;
isEditingAvatar: boolean;
} & ComposerGroupCreationState &
(
| { isCreating: false; hasError: boolean }
| { isCreating: true; hasError: false }
))
>;
type ContactSpoofingReviewStateType = ReadonlyDeep<
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
safeConversationId: string;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
groupConversationId: string;
}
>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
export type ConversationsStateType = Readonly<{
preJoinConversation?: PreJoinConversationType;
invitedUuidsForNewlyCreatedGroup?: ReadonlyArray<string>;
conversationLookup: ConversationLookupType;
conversationsByE164: ConversationLookupType;
conversationsByUuid: ConversationLookupType;
conversationsByGroupId: ConversationLookupType;
conversationsByUsername: ConversationLookupType;
selectedConversationId?: string;
targetedMessage: string | undefined;
targetedMessageCounter: number;
targetedMessageSource: TargetedMessageSource | undefined;
targetedConversationPanels: ReadonlyArray<PanelRenderType>;
targetedMessageForDetails?: MessageAttributesType;
lastSelectedMessage: MessageTimestamps | undefined;
selectedMessageIds: ReadonlyArray<string> | undefined;
showArchived: boolean;
composer?: ComposerStateType;
contactSpoofingReview?: ContactSpoofingReviewStateType;
/**
* Each key is a conversation ID. Each value is a value representing the state of
* verification: either a set of pending conversationIds to be approved, or a tombstone
* telling jobs to cancel themselves up to that timestamp.
*/
verificationDataByConversation: VerificationDataByConversation;
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType;
}>;
// Helpers
export const getConversationCallMode = (
conversation: ConversationType
): CallMode => {
if (
conversation.left ||
conversation.isBlocked ||
conversation.isMe ||
!conversation.acceptedMessageRequest
) {
return CallMode.None;
}
if (conversation.type === 'direct') {
return CallMode.Direct;
}
if (conversation.type === 'group' && conversation.groupVersion === 2) {
return CallMode.Group;
}
return CallMode.None;
};
// Actions
const CANCEL_CONVERSATION_PENDING_VERIFICATION =
'conversations/CANCEL_CONVERSATION_PENDING_VERIFICATION';
const CLEAR_CANCELLED_VERIFICATION =
'conversations/CLEAR_CANCELLED_VERIFICATION';
const CLEAR_CONVERSATIONS_PENDING_VERIFICATION =
'conversations/CLEAR_CONVERSATIONS_PENDING_VERIFICATION';
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
const COMPOSE_TOGGLE_EDITING_AVATAR =
'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR';
const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION =
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES';
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
export const TARGETED_CONVERSATION_CHANGED =
'conversations/TARGETED_CONVERSATION_CHANGED';
const PUSH_PANEL = 'conversations/PUSH_PANEL';
const POP_PANEL = 'conversations/POP_PANEL';
export const MESSAGE_CHANGED = 'MESSAGE_CHANGED';
export const MESSAGE_DELETED = 'MESSAGE_DELETED';
export const MESSAGE_EXPIRED = 'conversations/MESSAGE_EXPIRED';
export const SET_VOICE_NOTE_PLAYBACK_RATE =
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED';
export const SHOW_SPOILER = 'conversations/SHOW_SPOILER';
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
payload: {
canceledAt: number;
};
}>;
type ClearGroupCreationErrorActionType = ReadonlyDeep<{
type: 'CLEAR_GROUP_CREATION_ERROR';
}>;
type ClearInvitedUuidsForNewlyCreatedGroupActionType = ReadonlyDeep<{
type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP';
}>;
type ClearVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CLEAR_CONVERSATIONS_PENDING_VERIFICATION;
}>;
type ClearCancelledVerificationActionType = ReadonlyDeep<{
type: typeof CLEAR_CANCELLED_VERIFICATION;
payload: {
conversationId: string;
};
}>;
type CloseContactSpoofingReviewActionType = ReadonlyDeep<{
type: 'CLOSE_CONTACT_SPOOFING_REVIEW';
}>;
type CloseMaximumGroupSizeModalActionType = ReadonlyDeep<{
type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL';
}>;
type CloseRecommendedGroupSizeModalActionType = ReadonlyDeep<{
type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL';
}>;
type ColorsChangedActionType = ReadonlyDeep<{
type: typeof COLORS_CHANGED;
payload: {
conversationColor?: ConversationColorType;
customColorData?: {
id: string;
value: CustomColorType;
};
};
}>;
type ColorSelectedPayloadType = ReadonlyDeep<{
conversationId: string;
conversationColor?: ConversationColorType;
customColorData?: {
id: string;
value: CustomColorType;
};
}>;
export type ColorSelectedActionType = ReadonlyDeep<{
type: typeof COLOR_SELECTED;
payload: ColorSelectedPayloadType;
}>;
type ComposeDeleteAvatarActionType = ReadonlyDeep<{
type: typeof COMPOSE_REMOVE_AVATAR;
payload: AvatarDataType;
}>;
type ComposeReplaceAvatarsActionType = ReadonlyDeep<{
type: typeof COMPOSE_REPLACE_AVATAR;
payload: {
curr: AvatarDataType;
prev?: AvatarDataType;
};
}>;
type ComposeSaveAvatarActionType = ReadonlyDeep<{
type: typeof COMPOSE_ADD_AVATAR;
payload: AvatarDataType;
}>;
type CustomColorRemovedActionType = ReadonlyDeep<{
type: typeof CUSTOM_COLOR_REMOVED;
payload: {
colorId: string;
};
}>;
type DiscardMessagesActionType = ReadonlyDeep<{
type: typeof DISCARD_MESSAGES;
payload: Readonly<
| {
conversationId: string;
numberToKeepAtBottom: number;
}
| { conversationId: string; numberToKeepAtTop: number }
>;
}>;
type SetPreJoinConversationActionType = ReadonlyDeep<{
type: 'SET_PRE_JOIN_CONVERSATION';
payload: {
data: PreJoinConversationType | undefined;
};
}>;
type ConversationAddedActionType = ReadonlyDeep<{
type: 'CONVERSATION_ADDED';
payload: {
id: string;
data: ConversationType;
};
}>;
export type ConversationChangedActionType = ReadonlyDeep<{
type: 'CONVERSATION_CHANGED';
payload: {
id: string;
data: ConversationType;
};
}>;
export type ConversationRemovedActionType = ReadonlyDeep<{
type: 'CONVERSATION_REMOVED';
payload: {
id: string;
};
}>;
export type ConversationUnloadedActionType = ReadonlyDeep<{
type: typeof CONVERSATION_UNLOADED;
payload: {
conversationId: string;
};
}>;
type CreateGroupPendingActionType = ReadonlyDeep<{
type: 'CREATE_GROUP_PENDING';
}>;
type CreateGroupFulfilledActionType = ReadonlyDeep<{
type: 'CREATE_GROUP_FULFILLED';
payload: {
invitedUuids: ReadonlyArray<UUIDStringType>;
};
}>;
type CreateGroupRejectedActionType = ReadonlyDeep<{
type: 'CREATE_GROUP_REJECTED';
}>;
export type RemoveAllConversationsActionType = ReadonlyDeep<{
type: 'CONVERSATIONS_REMOVE_ALL';
payload: null;
}>;
export type MessageTargetedActionType = ReadonlyDeep<{
type: 'MESSAGE_TARGETED';
payload: {
messageId: string;
conversationId: string;
};
}>;
export type ToggleSelectMessagesActionType = ReadonlyDeep<{
type: 'TOGGLE_SELECT_MESSAGES';
payload: {
toggledMessageId: string;
messageIds: Array<string>;
selected: boolean;
};
}>;
export type ToggleSelectModeActionType = ReadonlyDeep<{
type: 'TOGGLE_SELECT_MODE';
payload: {
on: boolean;
};
}>;
type ConversationStoppedByMissingVerificationActionType = ReadonlyDeep<{
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
payload: {
conversationId: string;
distributionId?: string;
untrustedUuids: ReadonlyArray<UUIDStringType>;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
export type MessageChangedActionType = {
type: typeof MESSAGE_CHANGED;
payload: {
id: string;
conversationId: string;
data: MessageAttributesType;
};
};
export type MessageDeletedActionType = ReadonlyDeep<{
type: typeof MESSAGE_DELETED;
payload: {
id: string;
conversationId: string;
};
}>;
export type MessageExpandedActionType = ReadonlyDeep<{
type: 'MESSAGE_EXPANDED';
payload: {
id: string;
displayLimit: number;
};
}>;
export type ShowSpoilerActionType = ReadonlyDeep<{
type: typeof SHOW_SPOILER;
payload: {
id: string;
data: Record<number, boolean>;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
export type MessagesAddedActionType = Readonly<{
type: 'MESSAGES_ADDED';
payload: {
conversationId: string;
isActive: boolean;
isJustSent: boolean;
isNewMessage: boolean;
messages: ReadonlyArray<MessageAttributesType>;
};
}>;
export type MessageExpiredActionType = ReadonlyDeep<{
type: typeof MESSAGE_EXPIRED;
payload: {
id: string;
};
}>;
export type RepairNewestMessageActionType = ReadonlyDeep<{
type: 'REPAIR_NEWEST_MESSAGE';
payload: {
conversationId: string;
};
}>;
export type RepairOldestMessageActionType = ReadonlyDeep<{
type: 'REPAIR_OLDEST_MESSAGE';
payload: {
conversationId: string;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessagesResetActionType = {
type: 'MESSAGES_RESET';
payload: {
conversationId: string;
messages: ReadonlyArray<MessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string;
// The set of provided messages should be trusted, even if it conflicts with metrics,
// because we weren't looking for a specific time window of messages with our query.
unboundedFetch: boolean;
};
};
export type SetMessageLoadingStateActionType = ReadonlyDeep<{
type: 'SET_MESSAGE_LOADING_STATE';
payload: {
conversationId: string;
messageLoadingState: undefined | TimelineMessageLoadingState;
};
}>;
export type SetIsNearBottomActionType = ReadonlyDeep<{
type: 'SET_NEAR_BOTTOM';
payload: {
conversationId: string;
isNearBottom: boolean;
};
}>;
export type ScrollToMessageActionType = ReadonlyDeep<{
type: 'SCROLL_TO_MESSAGE';
payload: {
conversationId: string;
messageId: string;
};
}>;
export type ClearTargetedMessageActionType = ReadonlyDeep<{
type: 'CLEAR_TARGETED_MESSAGE';
payload: null;
}>;
export type ClearUnreadMetricsActionType = ReadonlyDeep<{
type: 'CLEAR_UNREAD_METRICS';
payload: {
conversationId: string;
};
}>;
export type TargetedConversationChangedActionType = ReadonlyDeep<{
type: typeof TARGETED_CONVERSATION_CHANGED;
payload: {
conversationId?: string;
messageId?: string;
switchToAssociatedView?: boolean;
};
}>;
type ReviewGroupMemberNameCollisionActionType = ReadonlyDeep<{
type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION';
payload: {
groupConversationId: string;
};
}>;
type ReviewMessageRequestNameCollisionActionType = ReadonlyDeep<{
type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION';
payload: {
safeConversationId: string;
};
}>;
type ShowInboxActionType = ReadonlyDeep<{
type: 'SHOW_INBOX';
payload: null;
}>;
export type ShowArchivedConversationsActionType = ReadonlyDeep<{
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
}>;
type SetComposeGroupAvatarActionType = ReadonlyDeep<{
type: 'SET_COMPOSE_GROUP_AVATAR';
payload: { groupAvatar: undefined | Uint8Array };
}>;
type SetComposeGroupNameActionType = ReadonlyDeep<{
type: 'SET_COMPOSE_GROUP_NAME';
payload: { groupName: string };
}>;
type SetComposeGroupExpireTimerActionType = ReadonlyDeep<{
type: 'SET_COMPOSE_GROUP_EXPIRE_TIMER';
payload: { groupExpireTimer: DurationInSeconds };
}>;
type SetComposeSearchTermActionType = ReadonlyDeep<{
type: 'SET_COMPOSE_SEARCH_TERM';
payload: { searchTerm: string };
}>;
type SetIsFetchingUUIDActionType = ReadonlyDeep<{
type: 'SET_IS_FETCHING_UUID';
payload: {
identifier: UUIDFetchStateKeyType;
isFetching: boolean;
};
}>;
type SetRecentMediaItemsActionType = ReadonlyDeep<{
type: 'SET_RECENT_MEDIA_ITEMS';
payload: {
id: string;
recentMediaItems: ReadonlyArray<MediaItemType>;
};
}>;
type ToggleComposeEditingAvatarActionType = ReadonlyDeep<{
type: typeof COMPOSE_TOGGLE_EDITING_AVATAR;
}>;
type StartComposingActionType = ReadonlyDeep<{
type: 'START_COMPOSING';
}>;
type ShowChooseGroupMembersActionType = ReadonlyDeep<{
type: 'SHOW_CHOOSE_GROUP_MEMBERS';
}>;
type StartSettingGroupMetadataActionType = ReadonlyDeep<{
type: 'START_SETTING_GROUP_METADATA';
}>;
export type ToggleConversationInChooseMembersActionType = ReadonlyDeep<{
type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS';
payload: {
conversationId: string;
maxRecommendedGroupSize: number;
maxGroupSize: number;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
type PushPanelActionType = Readonly<{
type: typeof PUSH_PANEL;
payload: PanelRenderType;
}>;
type PopPanelActionType = ReadonlyDeep<{
type: typeof POP_PANEL;
payload: null;
}>;
type ReplaceAvatarsActionType = ReadonlyDeep<{
type: typeof REPLACE_AVATARS;
payload: {
conversationId: string;
avatars: ReadonlyArray<AvatarDataType>;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
export type ConversationActionType =
| CancelVerificationDataByConversationActionType
| ClearCancelledVerificationActionType
| ClearGroupCreationErrorActionType
| ClearInvitedUuidsForNewlyCreatedGroupActionType
| ClearTargetedMessageActionType
| ClearUnreadMetricsActionType
| ClearVerificationDataByConversationActionType
| CloseContactSpoofingReviewActionType
| CloseMaximumGroupSizeModalActionType
| CloseRecommendedGroupSizeModalActionType
| ColorSelectedActionType
| ColorsChangedActionType
| ComposeDeleteAvatarActionType
| ComposeReplaceAvatarsActionType
| ComposeSaveAvatarActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationStoppedByMissingVerificationActionType
| ConversationUnloadedActionType
| CreateGroupFulfilledActionType
| CreateGroupPendingActionType
| CreateGroupRejectedActionType
| CustomColorRemovedActionType
| DiscardMessagesActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessageExpandedActionType
| MessageExpiredActionType
| MessageTargetedActionType
| MessagesAddedActionType
| MessagesResetActionType
| PopPanelActionType
| PushPanelActionType
| RemoveAllConversationsActionType
| RepairNewestMessageActionType
| RepairOldestMessageActionType
| ReplaceAvatarsActionType
| ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType
| ScrollToMessageActionType
| TargetedConversationChangedActionType
| SetComposeGroupAvatarActionType
| SetComposeGroupExpireTimerActionType
| SetComposeGroupNameActionType
| SetComposeSearchTermActionType
| SetIsFetchingUUIDActionType
| SetIsNearBottomActionType
| SetMessageLoadingStateActionType
| SetPreJoinConversationActionType
| SetRecentMediaItemsActionType
| ShowArchivedConversationsActionType
| ShowChooseGroupMembersActionType
| ShowInboxActionType
| ShowSendAnywayDialogActionType
| ShowSpoilerActionType
| StartComposingActionType
| StartSettingGroupMetadataActionType
| ToggleComposeEditingAvatarActionType
| ToggleConversationInChooseMembersActionType
| ToggleSelectMessagesActionType
| ToggleSelectModeActionType;
// Action Creators
export const actions = {
onConversationOpened,
onConversationClosed,
acceptConversation,
acknowledgeGroupMemberNameCollisions,
addMembersToGroup,
approvePendingMembershipFromGroupV2,
blockAndReportSpam,
blockConversation,
blockGroupLinkRequests,
cancelConversationVerification,
changeHasGroupLink,
clearCancelledConversationVerification,
clearGroupCreationError,
clearInvitedUuidsForNewlyCreatedGroup,
clearTargetedMessage,
clearUnreadMetrics,
closeContactSpoofingReview,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
colorSelected,
composeDeleteAvatarFromDisk,
composeReplaceAvatar,
composeSaveAvatarToDisk,
conversationAdded,
conversationChanged,
conversationRemoved,
conversationStoppedByMissingVerification,
createGroup,
deleteAvatarFromDisk,
deleteConversation,
deleteMessages,
deleteMessagesForEveryone,
destroyMessages,
discardEditMessage,
discardMessages,
doubleCheckMissingQuoteReference,
generateNewGroupLink,
getProfilesForConversation,
initiateMigrationToGroupV2,
kickOffAttachmentDownload,
leaveGroup,
loadNewerMessages,
loadNewestMessages,
loadOlderMessages,
loadRecentMediaItems,
markAttachmentAsCorrupted,
markMessageRead,
messageChanged,
messageDeleted,
messageExpanded,
messageExpired,
messagesAdded,
messagesReset,
myProfileChanged,
onArchive,
onMarkUnread,
onMoveToInbox,
onUndoArchive,
openGiftBadge,
popPanelForConversation,
pushPanelForConversation,
removeAllConversations,
removeConversation,
removeCustomColorOnConversations,
removeMember,
removeMemberFromGroup,
repairNewestMessage,
repairOldestMessage,
replaceAvatar,
resetAllChatColors,
copyMessageText,
retryDeleteForEveryone,
retryMessageSend,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
revokePendingMembershipsFromGroupV2,
saveAttachment,
saveAttachmentFromMessage,
saveAvatarToDisk,
scrollToMessage,
scrollToOldestUnreadMention,
showSpoiler,
targetMessage,
setAccessControlAddFromInviteLinkSetting,
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
setAnnouncementsOnly,
setComposeGroupAvatar,
setComposeGroupExpireTimer,
setComposeGroupName,
setComposeSearchTerm,
setDisappearingMessages,
setDontNotifyForMentionsIfMuted,
setIsFetchingUUID,
setIsNearBottom,
setMessageLoadingState,
setMessageToEdit,
setMuteExpiration,
setPinned,
setPreJoinConversation,
setVoiceNotePlaybackRate,
showArchivedConversations,
showChooseGroupMembers,
showConversation,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showInbox,
startComposing,
startConversation,
startSettingGroupMetadata,
toggleAdmin,
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
toggleGroupsForStorySend,
toggleHideStories,
toggleSelectMessage,
toggleSelectMode,
unblurAvatar,
updateConversationModelSharedGroups,
updateGroupAttributes,
updateSharedGroups,
verifyConversationsStoppingSend,
};
export const useConversationsActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function onArchive(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
ConversationUnloadedActionType | ShowToastActionType
> {
return (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onArchive: Conversation not found!');
}
conversation.setArchived(true);
onConversationClosed(conversationId, 'archive')(
dispatch,
getState,
undefined
);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.ConversationArchived,
parameters: {
conversationId,
},
},
});
};
}
function onUndoArchive(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
TargetedConversationChangedActionType
> {
return (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onUndoArchive: Conversation not found!');
}
conversation.setArchived(false);
showConversation({
conversationId,
})(dispatch, getState, null);
};
}
function onMarkUnread(conversationId: string): ShowToastActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onMarkUnread: Conversation not found!');
}
conversation.setMarkedUnread(true);
return {
type: SHOW_TOAST,
payload: {
toastType: ToastType.ConversationMarkedUnread,
},
};
}
function onMoveToInbox(conversationId: string): ShowToastActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onMoveToInbox: Conversation not found!');
}
conversation.setArchived(false);
return {
type: SHOW_TOAST,
payload: {
toastType: ToastType.ConversationUnarchived,
},
};
}
function acknowledgeGroupMemberNameCollisions(
conversationId: string,
groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'acknowledgeGroupMemberNameCollisions: Conversation not found!'
);
}
conversation.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
return {
type: 'NOOP',
payload: null,
};
}
function blockGroupLinkRequests(
conversationId: string,
uuid: UUIDStringType
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('blockGroupLinkRequests: Conversation not found!');
}
void conversation.blockGroupLinkRequests(uuid);
return {
type: 'NOOP',
payload: null,
};
}
function loadNewerMessages(
conversationId: string,
newestMessageId: string
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('loadNewerMessages: Conversation not found!');
}
void conversation.loadNewerMessages(newestMessageId);
return {
type: 'NOOP',
payload: null,
};
}
function loadNewestMessages(
conversationId: string,
newestMessageId: string | undefined,
setFocus: boolean | undefined
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('loadNewestMessages: Conversation not found!');
}
void conversation.loadNewestMessages(newestMessageId, setFocus);
return {
type: 'NOOP',
payload: null,
};
}
function loadOlderMessages(
conversationId: string,
oldestMessageId: string
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('loadOlderMessages: Conversation not found!');
}
void conversation.loadOlderMessages(oldestMessageId);
return {
type: 'NOOP',
payload: null,
};
}
function markMessageRead(
conversationId: string,
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (_dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('markMessageRead: Conversation not found!');
}
if (!window.SignalContext.activeWindowService.isActive()) {
return;
}
const activeCall = getActiveCallState(getState());
if (activeCall && !activeCall.pip) {
return;
}
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`markMessageRead: failed to load message ${messageId}`);
}
await conversation.markRead(message.get('received_at'), {
newestSentAt: message.get('sent_at'),
sendReadReceipts: true,
});
};
}
function removeMember(
conversationId: string,
memberConversationId: string
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('removeMember: Conversation not found!');
}
void longRunningTaskWrapper({
idForLogging: conversation.idForLogging(),
name: 'removeMember',
task: () => conversation.removeFromGroupV2(memberConversationId),
});
return {
type: 'NOOP',
payload: null,
};
}
function unblurAvatar(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('unblurAvatar: Conversation not found!');
}
conversation.unblurAvatar();
return {
type: 'NOOP',
payload: null,
};
}
function updateSharedGroups(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('updateSharedGroups: Conversation not found!');
}
void conversation.throttledUpdateSharedGroups?.();
return {
type: 'NOOP',
payload: null,
};
}
function filterAvatarData(
avatars: ReadonlyArray<AvatarDataType>,
data: AvatarDataType
): Array<AvatarDataType> {
return avatars.filter(avatarData => !isSameAvatarData(data, avatarData));
}
function getNextAvatarId(avatars: ReadonlyArray<AvatarDataType>): number {
return Math.max(...avatars.map(x => Number(x.id))) + 1;
}
async function getAvatarsAndUpdateConversation(
conversations: ConversationsStateType,
conversationId: string,
getNextAvatarsData: (
avatars: ReadonlyArray<AvatarDataType>,
nextId: number
) => ReadonlyArray<AvatarDataType>
): Promise<ReadonlyArray<AvatarDataType>> {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('getAvatarsAndUpdateConversation: No conversation found');
}
const { conversationLookup } = conversations;
const conversationAttrs = conversationLookup[conversationId];
const avatars =
conversationAttrs.avatars || getAvatarData(conversation.attributes);
const nextAvatarId = getNextAvatarId(avatars);
const nextAvatars = getNextAvatarsData(avatars, nextAvatarId);
// We don't save buffers to the db, but we definitely want it in-memory so
// we don't have to re-generate them.
//
// Mutating here because we don't want to trigger a model change
// because we're updating redux here manually ourselves. Au revoir Backbone!
conversation.attributes.avatars = nextAvatars.map(avatarData =>
omit(avatarData, ['buffer'])
);
window.Signal.Data.updateConversation(conversation.attributes);
return nextAvatars;
}
function deleteAvatarFromDisk(
avatarData: AvatarDataType,
conversationId?: string
): ThunkAction<void, RootStateType, unknown, ReplaceAvatarsActionType> {
return async (dispatch, getState) => {
if (avatarData.imagePath) {
await window.Signal.Migrations.deleteAvatar(avatarData.imagePath);
} else {
log.info(
'No imagePath for avatarData. Removing from userAvatarData, but not disk'
);
}
strictAssert(conversationId, 'conversationId not provided');
const avatars = await getAvatarsAndUpdateConversation(
getState().conversations,
conversationId,
prevAvatarsData => filterAvatarData(prevAvatarsData, avatarData)
);
dispatch({
type: REPLACE_AVATARS,
payload: {
conversationId,
avatars,
},
});
};
}
function changeHasGroupLink(
conversationId: string,
value: boolean
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('changeHasGroupLink: No conversation found');
}
await longRunningTaskWrapper({
name: 'toggleGroupLink',
idForLogging: conversation.idForLogging(),
task: async () => conversation.toggleGroupLink(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAnnouncementsOnly(
conversationId: string,
value: boolean
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setAnnouncementsOnly: No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAnnouncementsOnly(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlMembersSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setAccessControlMembersSetting: No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAccessControlMembers',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlMembers(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlAttributesSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'setAccessControlAttributesSetting: No conversation found'
);
}
await longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlAttributes(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setDisappearingMessages(
conversationId: string,
seconds: DurationInSeconds
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setDisappearingMessages: No conversation found');
}
const valueToSet = seconds > 0 ? seconds : undefined;
await longRunningTaskWrapper({
name: 'updateExpirationTimer',
idForLogging: conversation.idForLogging(),
task: async () =>
conversation.updateExpirationTimer(valueToSet, {
reason: 'setDisappearingMessages',
}),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setDontNotifyForMentionsIfMuted(
conversationId: string,
newValue: boolean
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setDontNotifyForMentionsIfMuted: No conversation found');
}
conversation.setDontNotifyForMentionsIfMuted(newValue);
return {
type: 'NOOP',
payload: null,
};
}
function setMuteExpiration(
conversationId: string,
muteExpiresAt = 0
): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setMuteExpiration: No conversation found');
}
conversation.setMuteExpiration(
muteExpiresAt >= Number.MAX_SAFE_INTEGER
? muteExpiresAt
: Date.now() + muteExpiresAt
);
return {
type: 'NOOP',
payload: null,
};
}
function setPinned(
conversationId: string,
value: boolean
): NoopActionType | ShowToastActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('setPinned: No conversation found');
}
if (value) {
const pinnedConversationIds = window.storage.get(
'pinnedConversationIds',
new Array<string>()
);
if (pinnedConversationIds.length >= 4) {
return {
type: SHOW_TOAST,
payload: {
toastType: ToastType.PinnedConversationsFull,
},
};
}
conversation.pin();
} else {
conversation.unpin();
}
return {
type: 'NOOP',
payload: null,
};
}
function deleteMessages({
conversationId,
messageIds,
lastSelectedMessage,
}: {
conversationId: string;
messageIds: ReadonlyArray<string>;
lastSelectedMessage?: MessageTimestamps;
}): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => {
if (!messageIds || messageIds.length === 0) {
log.warn('deleteMessages: No message ids provided');
return;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('deleteMessage: No conversation found');
}
await Promise.all(
messageIds.map(async messageId => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`);
}
const messageConversationId = message.get('conversationId');
if (conversationId !== messageConversationId) {
throw new Error(
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
);
}
})
);
let nearbyMessageId: string | null = null;
if (nearbyMessageId == null && lastSelectedMessage != null) {
const foundMessageId =
await window.Signal.Data.getNearbyMessageFromDeletedSet({
conversationId,
lastSelectedMessage,
deletedMessageIds: messageIds,
includeStoryReplies: false,
storyId: undefined,
});
if (foundMessageId != null) {
nearbyMessageId = foundMessageId;
}
}
await window.Signal.Data.removeMessages(messageIds);
popPanelForConversation()(dispatch, getState, undefined);
if (nearbyMessageId != null) {
dispatch(scrollToMessage(conversationId, nearbyMessageId));
}
};
}
function destroyMessages(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
ConversationUnloadedActionType | NoopActionType
> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('destroyMessages: No conversation found');
}
await longRunningTaskWrapper({
name: 'destroymessages',
idForLogging: conversation.idForLogging(),
task: async () => {
onConversationClosed(conversationId, 'delete messages')(
dispatch,
getState,
undefined
);
await conversation.destroyMessages();
drop(conversation.updateLastMessage());
},
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function discardEditMessage(
conversationId: string
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
window.ConversationController.get(conversationId)?.set(
{
draftEditMessage: undefined,
draftBodyRanges: undefined,
draft: undefined,
quotedMessageId: undefined,
},
{ unset: true }
);
};
}
function setMessageToEdit(
conversationId: string,
messageId: string
): ThunkAction<void, RootStateType, unknown, SetFocusActionType> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
return;
}
const message = (await getMessageById(messageId))?.attributes;
if (!message) {
return;
}
if (!canEditMessage(message) || !message.body) {
return;
}
setQuoteByMessageId(conversationId, undefined)(
dispatch,
getState,
undefined
);
let attachmentThumbnail: string | undefined;
if (message.attachments) {
const thumbnailPath = message.attachments[0]?.thumbnail?.path;
attachmentThumbnail = thumbnailPath
? window.Signal.Migrations.getAbsoluteAttachmentPath(thumbnailPath)
: undefined;
}
conversation.set({
draftEditMessage: {
body: message.body,
editHistoryLength: message.editHistory?.length ?? 0,
attachmentThumbnail,
preview: message.preview ? message.preview[0] : undefined,
targetMessageId: messageId,
quote: message.quote,
},
draftBodyRanges: processBodyRanges(message, {
conversationSelector: getConversationSelector(getState()),
}),
});
dispatch({
type: SET_FOCUS,
payload: {
conversationId,
},
});
};
}
function generateNewGroupLink(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('generateNewGroupLink: No conversation found');
}
await longRunningTaskWrapper({
name: 'refreshGroupLink',
idForLogging: conversation.idForLogging(),
task: async () => conversation.refreshGroupLink(),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
/**
* Not an actual redux action creator, so it doesn't produce an action (or dispatch
* itself) because updates are managed through the backbone model, which will trigger
* necessary updates and refresh conversation_view.
*
* In practice, it's similar to an already-connected thunk action. Later on we will
* replace it with an actual action that fits in with the redux approach.
*/
export const markViewed = (messageId: string): void => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`markViewed: Message ${messageId} missing!`);
}
if (message.get('readStatus') === ReadStatus.Viewed) {
return;
}
const senderE164 = message.get('source');
const senderUuid = message.get('sourceUuid');
const timestamp = getMessageSentTimestamp(message.attributes, { log });
message.set(messageUpdaterMarkViewed(message.attributes, Date.now()));
if (isIncoming(message.attributes)) {
const convoAttributes = message.getConversation()?.attributes;
const conversationId = message.get('conversationId');
drop(
conversationJobQueue.add({
type: conversationQueueJobEnum.enum.Receipts,
conversationId,
receiptsType: ReceiptType.Viewed,
receipts: [
{
messageId,
conversationId,
senderE164,
senderUuid,
timestamp,
isDirectConversation: convoAttributes
? isDirectConversation(convoAttributes)
: true,
},
],
})
);
}
drop(
viewSyncJobQueue.add({
viewSyncs: [
{
messageId,
senderE164,
senderUuid,
timestamp,
},
],
})
);
};
function setAccessControlAddFromInviteLinkSetting(
conversationId: string,
value: boolean
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'setAccessControlAddFromInviteLinkSetting: No conversation found'
);
}
await longRunningTaskWrapper({
idForLogging: conversation.idForLogging(),
name: 'updateAccessControlAddFromInviteLink',
task: async () =>
conversation.updateAccessControlAddFromInviteLink(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function discardMessages(
payload: Readonly<DiscardMessagesActionType['payload']>
): DiscardMessagesActionType {
return { type: DISCARD_MESSAGES, payload };
}
function replaceAvatar(
curr: AvatarDataType,
prev?: AvatarDataType,
conversationId?: string
): ThunkAction<void, RootStateType, unknown, ReplaceAvatarsActionType> {
return async (dispatch, getState) => {
strictAssert(conversationId, 'conversationId not provided');
const avatars = await getAvatarsAndUpdateConversation(
getState().conversations,
conversationId,
(prevAvatarsData, nextId) => {
const newAvatarData = {
...curr,
id: prev?.id ?? nextId,
};
const existingAvatarsData = prev
? filterAvatarData(prevAvatarsData, prev)
: prevAvatarsData;
return [newAvatarData, ...existingAvatarsData];
}
);
dispatch({
type: REPLACE_AVATARS,
payload: {
conversationId,
avatars,
},
});
};
}
function saveAvatarToDisk(
avatarData: AvatarDataType,
conversationId?: string
): ThunkAction<void, RootStateType, unknown, ReplaceAvatarsActionType> {
return async (dispatch, getState) => {
if (!avatarData.buffer) {
throw new Error('saveAvatarToDisk: No avatar Uint8Array provided');
}
strictAssert(conversationId, 'conversationId not provided');
const imagePath = await window.Signal.Migrations.writeNewAvatarData(
avatarData.buffer
);
const avatars = await getAvatarsAndUpdateConversation(
getState().conversations,
conversationId,
(prevAvatarsData, id) => {
const newAvatarData = {
...avatarData,
imagePath,
id,
};
return [newAvatarData, ...prevAvatarsData];
}
);
dispatch({
type: REPLACE_AVATARS,
payload: {
conversationId,
avatars,
},
});
};
}
function myProfileChanged(
profileData: ProfileDataType,
avatar: AvatarUpdateType
): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType | ToggleProfileEditorErrorActionType
> {
return async (dispatch, getState) => {
const conversation = getMe(getState());
try {
await writeProfile(
{
...conversation,
...profileData,
},
avatar
);
// writeProfile above updates the backbone model which in turn updates
// redux through it's on:change event listener. Once we lose Backbone
// we'll need to manually sync these new changes.
dispatch({
type: 'NOOP',
payload: null,
});
} catch (err) {
log.error('myProfileChanged', Errors.toLogFormat(err));
dispatch({ type: TOGGLE_PROFILE_EDITOR_ERROR });
}
};
}
function removeCustomColorOnConversations(
colorId: string
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {
return async dispatch => {
const conversationsToUpdate: Array<ConversationAttributesType> = [];
// We don't want to trigger a model change because we're updating redux
// here manually ourselves. Au revoir Backbone!
window.getConversations().forEach(conversation => {
if (conversation.get('customColorId') === colorId) {
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.conversationColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColorId;
conversationsToUpdate.push(conversation.attributes);
}
});
if (conversationsToUpdate.length) {
await window.Signal.Data.updateConversations(conversationsToUpdate);
}
dispatch({
type: CUSTOM_COLOR_REMOVED,
payload: {
colorId,
},
});
};
}
function resetAllChatColors(): ThunkAction<
void,
RootStateType,
unknown,
ColorsChangedActionType
> {
return async dispatch => {
// Calling this with no args unsets all the colors in the db
await window.Signal.Data.updateAllConversationColors();
// We don't want to trigger a model change because we're updating redux
// here manually ourselves. Au revoir Backbone!
window.getConversations().forEach(conversation => {
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.conversationColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColorId;
});
dispatch({
type: COLORS_CHANGED,
payload: {
conversationColor: undefined,
customColorData: undefined,
},
});
};
}
function kickOffAttachmentDownload(
options: Readonly<{ messageId: string }>
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await getMessageById(options.messageId);
if (!message) {
throw new Error(
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
);
}
const didUpdateValues = await message.queueAttachmentDownloads();
if (didUpdateValues) {
drop(
window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
})
);
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
type AttachmentOptions = ReadonlyDeep<{
messageId: string;
attachment: AttachmentType;
}>;
function markAttachmentAsCorrupted(
options: AttachmentOptions
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await getMessageById(options.messageId);
if (!message) {
throw new Error(
`markAttachmentAsCorrupted: Message ${options.messageId} missing!`
);
}
message.markAttachmentAsCorrupted(options.attachment);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function openGiftBadge(
messageId: string
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async dispatch => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`openGiftBadge: Message ${messageId} missing!`);
}
dispatch({
type: SHOW_TOAST,
payload: {
toastType: isIncoming(message.attributes)
? ToastType.CannotOpenGiftBadgeIncoming
: ToastType.CannotOpenGiftBadgeOutgoing,
},
});
};
}
function retryMessageSend(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`retryMessageSend: Message ${messageId} missing!`);
}
await message.retrySend();
dispatch({
type: 'NOOP',
payload: null,
});
};
}
export function copyMessageText(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`copy: Message ${messageId} missing!`);
}
const body = message.getNotificationText();
clipboard.writeText(body);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
export function retryDeleteForEveryone(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`);
}
if (isOlderThan(message.get('sent_at'), DAY)) {
throw new Error(
'retryDeleteForEveryone: Message too old to retry delete for everyone!'
);
}
try {
const conversation = message.getConversation();
if (!conversation) {
throw new Error(
`retryDeleteForEveryone: Conversation for ${messageId} missing!`
);
}
const jobData: ConversationQueueJobData = {
type: conversationQueueJobEnum.enum.DeleteForEveryone,
conversationId: conversation.id,
messageId,
recipients: conversation.getRecipients(),
revision: conversation.get('revision'),
targetTimestamp: message.get('sent_at'),
};
log.info(
`retryDeleteForEveryone: Adding job for message ${message.idForLogging()}!`
);
await conversationJobQueue.add(jobData);
dispatch({
type: 'NOOP',
payload: null,
});
} catch (error) {
log.error(
'retryDeleteForEveryone: Failed to queue delete for everyone',
Errors.toLogFormat(error)
);
}
};
}
// update the conversation voice note playback rate preference for the conversation
export function setVoiceNotePlaybackRate({
conversationId,
rate,
}: {
conversationId: string;
rate: number;
}): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
return async dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
if (rate === 1) {
delete conversationModel.attributes.voiceNotePlaybackRate;
} else {
conversationModel.attributes.voiceNotePlaybackRate = rate;
}
window.Signal.Data.updateConversation(conversationModel.attributes);
}
const conversation = conversationModel?.format();
if (conversation) {
dispatch({
type: 'CONVERSATION_CHANGED',
payload: {
id: conversationId,
data: {
...conversation,
voiceNotePlaybackRate: rate,
},
},
});
}
};
}
function colorSelected({
conversationId,
conversationColor,
customColorData,
}: ColorSelectedPayloadType): ThunkAction<
void,
RootStateType,
unknown,
ColorSelectedActionType
> {
return async dispatch => {
// We don't want to trigger a model change because we're updating redux
// here manually ourselves. Au revoir Backbone!
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
if (conversationColor) {
conversation.attributes.conversationColor = conversationColor;
if (customColorData) {
conversation.attributes.customColor = customColorData.value;
conversation.attributes.customColorId = customColorData.id;
} else {
delete conversation.attributes.customColor;
delete conversation.attributes.customColorId;
}
} else {
delete conversation.attributes.conversationColor;
delete conversation.attributes.customColor;
delete conversation.attributes.customColorId;
}
window.Signal.Data.updateConversation(conversation.attributes);
}
dispatch({
type: COLOR_SELECTED,
payload: {
conversationId,
conversationColor,
customColorData,
},
});
};
}
function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType {
return {
type: COMPOSE_TOGGLE_EDITING_AVATAR,
};
}
export function cancelConversationVerification(
canceledAt?: number
): ThunkAction<
void,
RootStateType,
unknown,
CancelVerificationDataByConversationActionType
> {
return (dispatch, getState) => {
const state = getState();
const conversationIdsBlocked =
getConversationIdsStoppedForVerification(state);
dispatch({
type: CANCEL_CONVERSATION_PENDING_VERIFICATION,
payload: {
canceledAt: canceledAt ?? Date.now(),
},
});
// Start the blocked conversation queues up again
conversationIdsBlocked.forEach(conversationId => {
conversationJobQueue.resolveVerificationWaiter(conversationId);
});
};
}
function verifyConversationsStoppingSend(): ThunkAction<
void,
RootStateType,
unknown,
ClearVerificationDataByConversationActionType
> {
return async (dispatch, getState) => {
const state = getState();
const uuidsStoppingSend = getConversationUuidsStoppingSend(state);
const conversationIdsBlocked =
getConversationIdsStoppedForVerification(state);
log.info(
`verifyConversationsStoppingSend: Starting with ${conversationIdsBlocked.length} blocked ` +
`conversations and ${uuidsStoppingSend.length} conversations to verify.`
);
// Mark conversations as approved/verified as appropriate
const promises: Array<Promise<unknown>> = [];
uuidsStoppingSend.forEach(async uuid => {
const conversation = window.ConversationController.get(uuid);
if (!conversation) {
log.warn(
`verifyConversationsStoppingSend: Cannot verify missing converastion for uuid ${uuid}`
);
return;
}
log.info(
`verifyConversationsStoppingSend: Verifying conversation ${conversation.idForLogging()}`
);
if (conversation.isUnverified()) {
promises.push(conversation.setVerifiedDefault());
}
promises.push(conversation.setApproved());
});
dispatch({
type: CLEAR_CONVERSATIONS_PENDING_VERIFICATION,
});
await Promise.all(promises);
// Start the blocked conversation queues up again
conversationIdsBlocked.forEach(conversationId => {
conversationJobQueue.resolveVerificationWaiter(conversationId);
});
};
}
export function clearCancelledConversationVerification(
conversationId: string
): ClearCancelledVerificationActionType {
return {
type: CLEAR_CANCELLED_VERIFICATION,
payload: {
conversationId,
},
};
}
function composeSaveAvatarToDisk(
avatarData: AvatarDataType
): ThunkAction<void, RootStateType, unknown, ComposeSaveAvatarActionType> {
return async dispatch => {
if (!avatarData.buffer) {
throw new Error('No avatar Uint8Array provided');
}
const imagePath = await window.Signal.Migrations.writeNewAvatarData(
avatarData.buffer
);
dispatch({
type: COMPOSE_ADD_AVATAR,
payload: {
...avatarData,
imagePath,
},
});
};
}
function composeDeleteAvatarFromDisk(
avatarData: AvatarDataType
): ThunkAction<void, RootStateType, unknown, ComposeDeleteAvatarActionType> {
return async dispatch => {
if (avatarData.imagePath) {
await window.Signal.Migrations.deleteAvatar(avatarData.imagePath);
} else {
log.info(
'No imagePath for avatarData. Removing from userAvatarData, but not disk'
);
}
dispatch({
type: COMPOSE_REMOVE_AVATAR,
payload: avatarData,
});
};
}
function composeReplaceAvatar(
curr: AvatarDataType,
prev?: AvatarDataType
): ComposeReplaceAvatarsActionType {
return {
type: COMPOSE_REPLACE_AVATAR,
payload: {
curr,
prev,
},
};
}
function setPreJoinConversation(
data: PreJoinConversationType | undefined
): SetPreJoinConversationActionType {
return {
type: 'SET_PRE_JOIN_CONVERSATION',
payload: {
data,
},
};
}
function conversationAdded(
id: string,
data: ConversationType
): ConversationAddedActionType {
return {
type: 'CONVERSATION_ADDED',
payload: {
id,
data,
},
};
}
function conversationChanged(
id: string,
data: ConversationType
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
return dispatch => {
calling.groupMembersChanged(id);
dispatch({
type: 'CONVERSATION_CHANGED',
payload: {
id,
data,
},
});
};
}
function conversationRemoved(id: string): ConversationRemovedActionType {
return {
type: 'CONVERSATION_REMOVED',
payload: {
id,
},
};
}
function createGroup(
createGroupV2 = groups.createGroupV2
): ThunkAction<
void,
RootStateType,
unknown,
| CreateGroupPendingActionType
| CreateGroupFulfilledActionType
| CreateGroupRejectedActionType
| TargetedConversationChangedActionType
> {
return async (dispatch, getState) => {
const { composer } = getState().conversations;
if (
composer?.step !== ComposerStep.SetGroupMetadata ||
composer.isCreating
) {
assertDev(false, 'Cannot create group in this stage; doing nothing');
return;
}
dispatch({ type: 'CREATE_GROUP_PENDING' });
try {
const conversation = await createGroupV2({
name: composer.groupName.trim(),
avatar: composer.groupAvatar,
avatars: composer.userAvatarData.map(avatarData =>
omit(avatarData, ['buffer'])
),
expireTimer: composer.groupExpireTimer,
conversationIds: composer.selectedConversationIds,
});
dispatch({
type: 'CREATE_GROUP_FULFILLED',
payload: {
invitedUuids: (conversation.get('pendingMembersV2') || []).map(
member => member.uuid
),
},
});
showConversation({
conversationId: conversation.id,
switchToAssociatedView: true,
})(dispatch, getState, null);
} catch (err) {
log.error('Failed to create group', Errors.toLogFormat(err));
dispatch({ type: 'CREATE_GROUP_REJECTED' });
}
};
}
function removeAllConversations(): RemoveAllConversationsActionType {
return {
type: 'CONVERSATIONS_REMOVE_ALL',
payload: null,
};
}
function targetMessage(
messageId: string,
conversationId: string
): MessageTargetedActionType {
return {
type: 'MESSAGE_TARGETED',
payload: {
messageId,
conversationId,
},
};
}
function toggleSelectMessage(
conversationId: string,
messageId: string,
shift: boolean,
selected: boolean
): ThunkAction<void, RootStateType, unknown, ToggleSelectMessagesActionType> {
return async (dispatch, getState) => {
const state = getState();
const { conversations } = state;
let toggledMessageIds: ReadonlyArray<string>;
if (shift && conversations.lastSelectedMessage != null) {
if (conversationId !== conversations.selectedConversationId) {
throw new Error("toggleSelectMessage: conversationId doesn't match");
}
const conversation = window.ConversationController.get(conversationId);
if (conversation == null) {
throw new Error('toggleSelectMessage: conversation not found');
}
const toggledMessage = getOwn(conversations.messagesLookup, messageId);
strictAssert(
toggledMessage != null,
'toggleSelectMessage: toggled message not found'
);
// Sort the messages by their order in the conversation
const [after, before] = sortByMessageOrder(
[toggledMessage, conversations.lastSelectedMessage],
message => message
);
const betweenIds = await window.Signal.Data.getMessagesBetween(
conversationId,
{
after: {
sent_at: after.sent_at,
received_at: after.received_at,
},
before: {
sent_at: before.sent_at,
received_at: before.received_at,
},
includeStoryReplies: !isGroup(conversation.attributes),
}
);
toggledMessageIds = [messageId, ...betweenIds];
} else {
toggledMessageIds = [messageId];
}
dispatch({
type: 'TOGGLE_SELECT_MESSAGES',
payload: {
toggledMessageId: messageId,
messageIds: toggledMessageIds,
selected,
},
});
};
}
function toggleSelectMode(on: boolean): ToggleSelectModeActionType {
return {
type: 'TOGGLE_SELECT_MODE',
payload: { on },
};
}
function getProfilesForConversation(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('getProfilesForConversation: no conversation found');
}
void conversation.getProfiles();
return {
type: 'NOOP',
payload: null,
};
}
function conversationStoppedByMissingVerification(payload: {
conversationId: string;
distributionId?: string;
untrustedUuids: ReadonlyArray<UUIDStringType>;
}): ConversationStoppedByMissingVerificationActionType {
// Fetching profiles to ensure that we have their latest identity key in storage
payload.untrustedUuids.forEach(uuid => {
const conversation = window.ConversationController.get(uuid);
if (!conversation) {
log.error(
`conversationStoppedByMissingVerification: uuid ${uuid} not found!`
);
return;
}
// Intentionally not awaiting here
void conversation.getProfiles();
});
return {
type: CONVERSATION_STOPPED_BY_MISSING_VERIFICATION,
payload,
};
}
function messageChanged(
id: string,
conversationId: string,
data: MessageAttributesType
): MessageChangedActionType {
return {
type: MESSAGE_CHANGED,
payload: {
id,
conversationId,
data,
},
};
}
function messageDeleted(
id: string,
conversationId: string
): MessageDeletedActionType {
return {
type: MESSAGE_DELETED,
payload: {
id,
conversationId,
},
};
}
function messageExpanded(
id: string,
displayLimit: number
): MessageExpandedActionType {
return {
type: 'MESSAGE_EXPANDED',
payload: {
id,
displayLimit,
},
};
}
function showSpoiler(
id: string,
data: Record<number, boolean>
): ShowSpoilerActionType {
return {
type: SHOW_SPOILER,
payload: {
id,
data,
},
};
}
function messageExpired(id: string): MessageExpiredActionType {
return {
type: MESSAGE_EXPIRED,
payload: {
id,
},
};
}
function messagesAdded({
conversationId,
isActive,
isJustSent,
isNewMessage,
messages,
}: {
conversationId: string;
isActive: boolean;
isJustSent: boolean;
isNewMessage: boolean;
messages: ReadonlyArray<MessageAttributesType>;
}): ThunkAction<void, RootStateType, unknown, MessagesAddedActionType> {
return (dispatch, getState) => {
const state = getState();
if (
isNewMessage &&
state.items.audioMessage &&
conversationId === state.conversations.selectedConversationId &&
isActive &&
!isJustSent &&
messages.some(isIncoming)
) {
drop(new Sound({ soundType: SoundType.Pop }).play());
}
dispatch({
type: 'MESSAGES_ADDED',
payload: {
conversationId,
isActive,
isJustSent,
isNewMessage,
messages,
},
});
};
}
function repairNewestMessage(
conversationId: string
): RepairNewestMessageActionType {
return {
type: 'REPAIR_NEWEST_MESSAGE',
payload: {
conversationId,
},
};
}
function repairOldestMessage(
conversationId: string
): RepairOldestMessageActionType {
return {
type: 'REPAIR_OLDEST_MESSAGE',
payload: {
conversationId,
},
};
}
function reviewGroupMemberNameCollision(
groupConversationId: string
): ReviewGroupMemberNameCollisionActionType {
return {
type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION',
payload: { groupConversationId },
};
}
function reviewMessageRequestNameCollision(
payload: Readonly<{
safeConversationId: string;
}>
): ReviewMessageRequestNameCollisionActionType {
return { type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION', payload };
}
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageResetOptionsType = {
conversationId: string;
messages: ReadonlyArray<MessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string;
unboundedFetch?: boolean;
};
function messagesReset({
conversationId,
messages,
metrics,
scrollToMessageId,
unboundedFetch,
}: MessageResetOptionsType): MessagesResetActionType {
for (const message of messages) {
strictAssert(
message.conversationId === conversationId,
`messagesReset(${conversationId}): invalid message conversationId ` +
`${message.conversationId}`
);
}
return {
type: 'MESSAGES_RESET',
payload: {
unboundedFetch: Boolean(unboundedFetch),
conversationId,
messages,
metrics,
scrollToMessageId,
},
};
}
function setMessageLoadingState(
conversationId: string,
messageLoadingState: undefined | TimelineMessageLoadingState
): SetMessageLoadingStateActionType {
return {
type: 'SET_MESSAGE_LOADING_STATE',
payload: {
conversationId,
messageLoadingState,
},
};
}
function setIsNearBottom(
conversationId: string,
isNearBottom: boolean
): SetIsNearBottomActionType {
return {
type: 'SET_NEAR_BOTTOM',
payload: {
conversationId,
isNearBottom,
},
};
}
function setIsFetchingUUID(
identifier: UUIDFetchStateKeyType,
isFetching: boolean
): SetIsFetchingUUIDActionType {
return {
type: 'SET_IS_FETCHING_UUID',
payload: {
identifier,
isFetching,
},
};
}
export type PushPanelForConversationActionType = ReadonlyDeep<
(panel: PanelRequestType) => unknown
>;
function pushPanelForConversation(
panel: PanelRequestType
): ThunkAction<void, RootStateType, unknown, PushPanelActionType> {
return async (dispatch, getState) => {
if (panel.type === PanelType.MessageDetails) {
const { messageId } = panel.args;
const state = getState();
const message =
state.conversations.messagesLookup[messageId] ||
(await getMessageById(messageId))?.attributes;
if (!message) {
throw new Error(
'pushPanelForConversation: could not find message for MessageDetails'
);
}
dispatch({
type: PUSH_PANEL,
payload: {
type: PanelType.MessageDetails,
args: {
message,
},
},
});
return;
}
dispatch({
type: PUSH_PANEL,
payload: panel,
});
};
}
export type PopPanelForConversationActionType = ReadonlyDeep<() => unknown>;
function popPanelForConversation(): ThunkAction<
void,
RootStateType,
unknown,
PopPanelActionType
> {
return (dispatch, getState) => {
const { conversations } = getState();
const { targetedConversationPanels: selectedConversationPanels } =
conversations;
if (!selectedConversationPanels.length) {
return;
}
dispatch({
type: POP_PANEL,
payload: null,
});
};
}
function deleteMessagesForEveryone(
messageIds: ReadonlyArray<string>
): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType | ShowToastActionType
> {
return async dispatch => {
let hasError = false;
await Promise.all(
messageIds.map(async messageId => {
try {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
const conversation = message.getConversation();
if (!conversation) {
throw new Error('deleteMessageForEveryone: no conversation');
}
await sendDeleteForEveryoneMessage(conversation.attributes, {
id: message.id,
timestamp: getMessageSentTimestamp(message.attributes, { log }),
});
} catch (error) {
hasError = true;
log.error(
'Error queuing delete-for-everyone job',
Errors.toLogFormat(error),
messageId
);
}
})
);
if (hasError) {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.DeleteForEveryoneFailed,
},
});
} else {
dispatch({
type: 'NOOP',
payload: null,
});
}
};
}
function approvePendingMembershipFromGroupV2(
conversationId: string,
memberId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`approvePendingMembershipFromGroupV2: No conversation found for conversation ${conversationId}`
);
}
const logId = conversation.idForLogging();
const pendingMember = window.ConversationController.get(memberId);
if (!pendingMember) {
throw new Error(
`approvePendingMembershipFromGroupV2/${logId}: No member found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
`approvePendingMembershipFromGroupV2/${logId}`
);
if (
isGroupV2(conversation.attributes) &&
isMemberRequestingToJoin(conversation.attributes, uuid)
) {
await modifyGroupV2({
conversation,
usingCredentialsFrom: [pendingMember],
createGroupChange: async () => {
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!isMemberRequestingToJoin(conversation.attributes, uuid)) {
log.warn(
`approvePendingMembershipFromGroupV2/${logId}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
return buildPromotePendingAdminApprovalMemberChange({
group: conversation.attributes,
uuid,
});
},
name: 'approvePendingMembershipFromGroupV2',
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function revokePendingMembershipsFromGroupV2(
conversationId: string,
memberIds: ReadonlyArray<string>
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`approvePendingMembershipFromGroupV2: No conversation found for conversation ${conversationId}`
);
}
if (!isGroupV2(conversation.attributes)) {
return;
}
// Only pending memberships can be revoked for multiple members at once
if (memberIds.length > 1) {
const uuids = memberIds.map(id => {
const uuid = window.ConversationController.get(id)?.getUuid();
strictAssert(uuid, `UUID does not exist for ${id}`);
return uuid;
});
await conversation.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () =>
removePendingMember(conversation.attributes, uuids),
extraConversationsForSend: memberIds,
});
return;
}
const [memberId] = memberIds;
const pendingMember = window.ConversationController.get(memberId);
if (!pendingMember) {
const logId = conversation.idForLogging();
throw new Error(
`revokePendingMembershipsFromGroupV2/${logId}: No conversation found for conversation ${memberId}`
);
}
const uuid = pendingMember.getCheckedUuid(
'revokePendingMembershipsFromGroupV2'
);
if (isMemberRequestingToJoin(conversation.attributes, uuid)) {
await conversation.modifyGroupV2({
name: 'denyPendingApprovalRequest',
usingCredentialsFrom: [],
createGroupChange: () =>
denyPendingApprovalRequest(conversation.attributes, uuid),
extraConversationsForSend: [memberId],
});
} else if (conversation.isMemberPending(uuid)) {
await conversation.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () =>
removePendingMember(conversation.attributes, [uuid]),
extraConversationsForSend: [memberId],
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function blockAndReportSpam(
conversationId: string
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error(
`blockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
);
return;
}
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const idForLogging = conversation.idForLogging();
void longRunningTaskWrapper({
name: 'blockAndReportSpam',
idForLogging,
task: async () => {
await Promise.all([
conversation.syncMessageRequestResponse(messageRequestEnum.BLOCK),
addReportSpamJob({
conversation: conversation.attributes,
getMessageServerGuidsForSpam:
window.Signal.Data.getMessageServerGuidsForSpam,
jobQueue: reportSpamJobQueue,
}),
]);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.ReportedSpamAndBlocked,
},
});
},
});
};
}
function acceptConversation(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'acceptConversation: Expected a conversation to be found. Doing nothing'
);
}
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
void longRunningTaskWrapper({
name: 'acceptConversation',
idForLogging: conversation.idForLogging(),
task: conversation.syncMessageRequestResponse.bind(
conversation,
messageRequestEnum.ACCEPT
),
});
return {
type: 'NOOP',
payload: null,
};
}
function removeConversation(conversationId: string): ShowToastActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'acceptConversation: Expected a conversation to be found. Doing nothing'
);
}
drop(conversation.removeContact());
return {
type: SHOW_TOAST,
payload: {
toastType: ToastType.ConversationRemoved,
parameters: {
title: conversation.getTitle(),
},
},
};
}
function blockConversation(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'blockConversation: Expected a conversation to be found. Doing nothing'
);
}
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
void longRunningTaskWrapper({
name: 'blockConversation',
idForLogging: conversation.idForLogging(),
task: conversation.syncMessageRequestResponse.bind(
conversation,
messageRequestEnum.BLOCK
),
});
return {
type: 'NOOP',
payload: null,
};
}
function deleteConversation(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'deleteConversation: Expected a conversation to be found. Doing nothing'
);
}
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
void longRunningTaskWrapper({
name: 'deleteConversation',
idForLogging: conversation.idForLogging(),
task: conversation.syncMessageRequestResponse.bind(
conversation,
messageRequestEnum.DELETE
),
});
return {
type: 'NOOP',
payload: null,
};
}
function initiateMigrationToGroupV2(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'deleteConversation: Expected a conversation to be found. Doing nothing'
);
}
void longRunningTaskWrapper({
idForLogging: conversation.idForLogging(),
name: 'initiateMigrationToGroupV2',
task: () => doInitiateMigrationToGroupV2(conversation),
});
return {
type: 'NOOP',
payload: null,
};
}
function loadRecentMediaItems(
conversationId: string,
limit: number
): ThunkAction<void, RootStateType, unknown, SetRecentMediaItemsActionType> {
return async dispatch => {
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
const messages: Array<MessageAttributesType> =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(
conversationId,
{
limit,
}
);
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageController.register(message.id, message);
});
const recentMediaItems = messages
.filter(message => message.attachments !== undefined)
.reduce(
(acc, message) => [
...acc,
...(message.attachments || []).map(
(attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment;
return {
objectURL: getAbsoluteAttachmentPath(attachment.path || ''),
thumbnailObjectUrl: thumbnail?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
),
],
[] as Array<MediaItemType>
);
dispatch({
type: 'SET_RECENT_MEDIA_ITEMS',
payload: { id: conversationId, recentMediaItems },
});
};
}
export type SaveAttachmentActionCreatorType = ReadonlyDeep<
(attachment: AttachmentType, timestamp?: number, index?: number) => unknown
>;
function saveAttachment(
attachment: AttachmentType,
timestamp = Date.now(),
index = 0
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async dispatch => {
const { fileName = '' } = attachment;
const isDangerous = isFileDangerous(fileName);
if (isDangerous) {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.DangerousFileType,
},
});
return;
}
const { readAttachmentData, saveAttachmentToDisk } =
window.Signal.Migrations;
const fullPath = await Attachment.save({
attachment,
index: index + 1,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
});
if (fullPath) {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.FileSaved,
parameters: {
fullPath,
},
},
});
}
};
}
export function saveAttachmentFromMessage(
messageId: string,
providedAttachment?: AttachmentType
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async (dispatch, getState) => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
`saveAttachmentFromMessage: Message ${messageId} missing!`
);
}
const { attachments, sent_at: timestamp } = message.attributes;
if (!attachments || attachments.length < 1) {
return;
}
const attachment =
providedAttachment && attachments.includes(providedAttachment)
? providedAttachment
: attachments[0];
saveAttachment(attachment, timestamp)(dispatch, getState, null);
};
}
function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType {
return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' };
}
function clearGroupCreationError(): ClearGroupCreationErrorActionType {
return { type: 'CLEAR_GROUP_CREATION_ERROR' };
}
function clearTargetedMessage(): ClearTargetedMessageActionType {
return {
type: 'CLEAR_TARGETED_MESSAGE',
payload: null,
};
}
function clearUnreadMetrics(
conversationId: string
): ClearUnreadMetricsActionType {
return {
type: 'CLEAR_UNREAD_METRICS',
payload: {
conversationId,
},
};
}
function closeContactSpoofingReview(): CloseContactSpoofingReviewActionType {
return { type: 'CLOSE_CONTACT_SPOOFING_REVIEW' };
}
function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
return { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL' };
}
function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
}
export function scrollToOldestUnreadMention(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => {
const conversation = getOwn(
getState().conversations.conversationLookup,
conversationId
);
if (!conversation) {
log.warn(`No conversation found: [${conversationId}]`);
return;
}
const oldestUnreadMention =
await window.Signal.Data.getOldestUnreadMentionOfMeForConversation(
conversationId,
{
includeStoryReplies: !isGroup(conversation),
}
);
if (!oldestUnreadMention) {
log.warn(`No unread mention found for conversation: [${conversationId}]`);
return;
}
dispatch(scrollToMessage(conversationId, oldestUnreadMention.id));
};
}
export function scrollToMessage(
conversationId: string,
messageId: string
): ThunkAction<void, RootStateType, unknown, ScrollToMessageActionType> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('scrollToMessage: No conversation found');
}
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`scrollToMessage: failed to load message ${messageId}`);
}
if (message.get('conversationId') !== conversationId) {
throw new Error(
`scrollToMessage: ${messageId} didn't have conversationId ${conversationId}`
);
}
const state = getState();
let isInMemory = true;
if (!window.MessageController.getById(messageId)) {
isInMemory = false;
}
// Message might be in memory, but not in the redux anymore because
// we call `messageReset()` in `loadAndScroll()`.
const messagesByConversation =
getMessagesByConversation(state)[conversationId];
if (!messagesByConversation?.messageIds.includes(messageId)) {
isInMemory = false;
}
if (isInMemory) {
dispatch({
type: 'SCROLL_TO_MESSAGE',
payload: {
conversationId,
messageId,
},
});
return;
}
drop(conversation.loadAndScroll(messageId));
};
}
function setComposeGroupAvatar(
groupAvatar: undefined | Uint8Array
): SetComposeGroupAvatarActionType {
return {
type: 'SET_COMPOSE_GROUP_AVATAR',
payload: { groupAvatar },
};
}
function setComposeGroupName(groupName: string): SetComposeGroupNameActionType {
return {
type: 'SET_COMPOSE_GROUP_NAME',
payload: { groupName },
};
}
function setComposeGroupExpireTimer(
groupExpireTimer: DurationInSeconds
): SetComposeGroupExpireTimerActionType {
return {
type: 'SET_COMPOSE_GROUP_EXPIRE_TIMER',
payload: { groupExpireTimer },
};
}
function setComposeSearchTerm(
searchTerm: string
): SetComposeSearchTermActionType {
return {
type: 'SET_COMPOSE_SEARCH_TERM',
payload: { searchTerm },
};
}
function startComposing(): StartComposingActionType {
return { type: 'START_COMPOSING' };
}
function showChooseGroupMembers(): ShowChooseGroupMembersActionType {
return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' };
}
function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
return { type: 'START_SETTING_GROUP_METADATA' };
}
function toggleConversationInChooseMembers(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
ToggleConversationInChooseMembersActionType
> {
return dispatch => {
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
const maxGroupSize = Math.max(
getGroupSizeHardLimit(1001),
maxRecommendedGroupSize + 1
);
assertDev(
maxGroupSize > maxRecommendedGroupSize,
'Expected the hard max group size to be larger than the recommended maximum'
);
dispatch({
type: 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS',
payload: { conversationId, maxGroupSize, maxRecommendedGroupSize },
});
};
}
function toggleHideStories(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
conversationModel.toggleHideStories();
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function removeMemberFromGroup(
conversationId: string,
contactId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
const idForLogging = conversationModel.idForLogging();
void longRunningTaskWrapper({
name: 'removeMemberFromGroup',
idForLogging,
task: () => conversationModel.removeFromGroupV2(contactId),
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function addMembersToGroup(
conversationId: string,
contactIds: ReadonlyArray<string>,
{
onSuccess,
onFailure,
}: {
onSuccess?: () => unknown;
onFailure?: () => unknown;
} = {}
): ThunkAction<void, RootStateType, unknown, never> {
return async () => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('addMembersToGroup: No conversation found');
}
const idForLogging = conversation.idForLogging();
try {
await longRunningTaskWrapper({
name: 'addMembersToGroup',
idForLogging,
task: () =>
modifyGroupV2({
name: 'addMembersToGroup',
conversation,
usingCredentialsFrom: contactIds
.map(id => window.ConversationController.get(id))
.filter(isNotNil),
createGroupChange: async () =>
buildAddMembersChange(conversation.attributes, contactIds),
}),
});
onSuccess?.();
} catch {
onFailure?.();
}
};
}
function updateGroupAttributes(
conversationId: string,
attributes: Readonly<{
avatar?: undefined | Uint8Array;
description?: string;
title?: string;
}>,
{
onSuccess,
onFailure,
}: {
onSuccess?: () => unknown;
onFailure?: () => unknown;
} = {}
): ThunkAction<void, RootStateType, unknown, never> {
return async () => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('updateGroupAttributes: No conversation found');
}
const { id, publicParams, revision, secretParams } =
conversation.attributes;
try {
await modifyGroupV2({
name: 'updateGroupAttributes',
conversation,
usingCredentialsFrom: [],
createGroupChange: async () =>
buildUpdateAttributesChange(
{ id, publicParams, revision, secretParams },
attributes
),
});
onSuccess?.();
} catch {
onFailure?.();
}
};
}
function leaveGroup(
conversationId: string
): ThunkAction<void, RootStateType, unknown, never> {
return async () => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('leaveGroup: No conversation found');
}
await longRunningTaskWrapper({
idForLogging: conversation.idForLogging(),
name: 'leaveGroup',
task: () => conversation.leaveGroupV2(),
});
};
}
function toggleGroupsForStorySend(
conversationIds: ReadonlyArray<string>
): ThunkAction<Promise<void>, RootStateType, unknown, NoopActionType> {
return async dispatch => {
await Promise.all(
conversationIds.map(async conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
return;
}
const oldStorySendMode = conversation.getStorySendMode();
const newStorySendMode =
oldStorySendMode === StorySendMode.Always
? StorySendMode.Never
: StorySendMode.Always;
conversation.set({
storySendMode: newStorySendMode,
});
window.Signal.Data.updateConversation(conversation.attributes);
conversation.captureChange('storySendMode');
})
);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function toggleAdmin(
conversationId: string,
contactId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversationModel = window.ConversationController.get(conversationId);
if (conversationModel) {
void conversationModel.toggleAdmin(contactId);
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function updateConversationModelSharedGroups(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (conversation && conversation.throttledUpdateSharedGroups) {
void conversation.throttledUpdateSharedGroups();
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function showExpiredIncomingTapToViewToast(): ShowToastActionType {
log.info(
'showExpiredIncomingTapToViewToastShowing expired tap-to-view toast for an incoming message'
);
return {
type: SHOW_TOAST,
payload: {
toastType: ToastType.TapToViewExpiredIncoming,
},
};
}
function showExpiredOutgoingTapToViewToast(): ShowToastActionType {
log.info('Showing expired tap-to-view toast for an outgoing message');
return {
type: SHOW_TOAST,
payload: {
toastType: ToastType.TapToViewExpiredOutgoing,
},
};
}
function showInbox(): ShowInboxActionType {
return {
type: 'SHOW_INBOX',
payload: null,
};
}
type ShowConversationArgsType = ReadonlyDeep<{
conversationId?: string;
messageId?: string;
switchToAssociatedView?: boolean;
}>;
export type ShowConversationType = ReadonlyDeep<
(options: ShowConversationArgsType) => unknown
>;
function showConversation({
conversationId,
messageId,
switchToAssociatedView,
}: ShowConversationArgsType): ThunkAction<
void,
RootStateType,
unknown,
TargetedConversationChangedActionType
> {
return (dispatch, getState) => {
const { conversations } = getState();
if (conversationId === conversations.selectedConversationId) {
if (conversationId && messageId) {
scrollToMessage(conversationId, messageId)(dispatch, getState, null);
}
return;
}
// notify composer in case we need to stop recording a voice note
if (conversations.selectedConversationId) {
dispatch(handleLeaveConversation(conversations.selectedConversationId));
}
dispatch({
type: TARGETED_CONVERSATION_CHANGED,
payload: {
conversationId,
messageId,
switchToAssociatedView,
},
});
};
}
function onConversationOpened(
conversationId: string,
messageId?: string
): ThunkAction<
void,
RootStateType,
unknown,
| ReplaceAttachmentsActionType
| ResetComposerActionType
| SetFocusActionType
| SetQuotedMessageActionType
> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onConversationOpened: Conversation not found');
}
conversation.onOpenStart();
if (messageId) {
const message = await getMessageById(messageId);
if (message) {
drop(conversation.loadAndScroll(messageId));
return;
}
log.warn(`onOpened: Did not find message ${messageId}`);
}
const { retryPlaceholders } = window.Signal.Services;
if (retryPlaceholders) {
await retryPlaceholders.findByConversationAndMarkOpened(conversation.id);
}
const loadAndUpdate = async () => {
drop(
Promise.all([
conversation.loadNewestMessages(undefined, undefined),
conversation.updateLastMessage(),
conversation.updateUnread(),
])
);
};
drop(loadAndUpdate());
dispatch(setComposerFocus(conversation.id));
const quotedMessageId = conversation.get('quotedMessageId');
if (quotedMessageId) {
setQuoteByMessageId(conversation.id, quotedMessageId)(
dispatch,
getState,
undefined
);
}
drop(conversation.fetchLatestGroupV2Data());
strictAssert(
conversation.throttledMaybeMigrateV1Group !== undefined,
'Conversation model should be initialized'
);
drop(conversation.throttledMaybeMigrateV1Group());
strictAssert(
conversation.throttledFetchSMSOnlyUUID !== undefined,
'Conversation model should be initialized'
);
drop(conversation.throttledFetchSMSOnlyUUID());
const ourUuid = window.textsecure.storage.user.getUuid(UUIDKind.ACI);
if (
!isGroup(conversation.attributes) ||
(ourUuid && conversation.hasMember(ourUuid))
) {
strictAssert(
conversation.throttledGetProfiles !== undefined,
'Conversation model should be initialized'
);
await conversation.throttledGetProfiles();
}
drop(conversation.updateVerified());
replaceAttachments(
conversation.get('id'),
conversation.get('draftAttachments') || []
)(dispatch, getState, undefined);
dispatch(resetComposer(conversationId));
};
}
function onConversationClosed(
conversationId: string,
reason: string
): ThunkAction<void, RootStateType, unknown, ConversationUnloadedActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onConversationClosed: Conversation not found');
}
const logId = `onConversationClosed/${conversation.idForLogging()}`;
log.info(`${logId}: unloading due to ${reason}`);
if (conversation.get('draftChanged')) {
if (conversation.hasDraft()) {
log.info(`${logId}: new draft info needs update`);
const now = Date.now();
const activeAt = conversation.get('active_at') || now;
conversation.set({
active_at: activeAt,
draftChanged: false,
draftTimestamp: now,
timestamp: now,
});
} else {
log.info(`${logId}: clearing draft info`);
conversation.set({
draftChanged: false,
draftTimestamp: null,
});
}
window.Signal.Data.updateConversation(conversation.attributes);
drop(conversation.updateLastMessage());
}
removeLinkPreview(conversationId);
dispatch({
type: CONVERSATION_UNLOADED,
payload: {
conversationId,
},
});
};
}
function showArchivedConversations(): ShowArchivedConversationsActionType {
return {
type: 'SHOW_ARCHIVED_CONVERSATIONS',
payload: null,
};
}
function doubleCheckMissingQuoteReference(messageId: string): NoopActionType {
const message = window.MessageController.getById(messageId);
if (message) {
void message.doubleCheckMissingQuoteReference();
}
return {
type: 'NOOP',
payload: null,
};
}
// Reducer
export function getEmptyState(): ConversationsStateType {
return {
conversationLookup: {},
conversationsByE164: {},
conversationsByUuid: {},
conversationsByGroupId: {},
conversationsByUsername: {},
verificationDataByConversation: {},
messagesByConversation: {},
messagesLookup: {},
targetedMessage: undefined,
targetedMessageCounter: 0,
targetedMessageSource: undefined,
lastSelectedMessage: undefined,
selectedMessageIds: undefined,
showArchived: false,
targetedConversationPanels: [],
};
}
export function updateConversationLookups(
added: ConversationType | undefined,
removed: ConversationType | undefined,
state: ConversationsStateType
): Pick<
ConversationsStateType,
| 'conversationsByE164'
| 'conversationsByUuid'
| 'conversationsByGroupId'
| 'conversationsByUsername'
> {
const result = {
conversationsByE164: state.conversationsByE164,
conversationsByUuid: state.conversationsByUuid,
conversationsByGroupId: state.conversationsByGroupId,
conversationsByUsername: state.conversationsByUsername,
};
if (removed && removed.e164) {
result.conversationsByE164 = omit(result.conversationsByE164, removed.e164);
}
if (removed && removed.uuid) {
result.conversationsByUuid = omit(result.conversationsByUuid, removed.uuid);
}
if (removed && removed.pni) {
result.conversationsByUuid = omit(result.conversationsByUuid, removed.pni);
}
if (removed && removed.groupId) {
result.conversationsByGroupId = omit(
result.conversationsByGroupId,
removed.groupId
);
}
if (removed && removed.username) {
result.conversationsByUsername = omit(
result.conversationsByUsername,
removed.username
);
}
if (added && added.e164) {
result.conversationsByE164 = {
...result.conversationsByE164,
[added.e164]: added,
};
}
if (added && added.uuid) {
result.conversationsByUuid = {
...result.conversationsByUuid,
[added.uuid]: added,
};
}
if (added && added.pni) {
result.conversationsByUuid = {
...result.conversationsByUuid,
[added.pni]: added,
};
}
if (added && added.groupId) {
result.conversationsByGroupId = {
...result.conversationsByGroupId,
[added.groupId]: added,
};
}
if (added && added.username) {
result.conversationsByUsername = {
...result.conversationsByUsername,
[added.username]: added,
};
}
return result;
}
function closeComposerModal(
state: Readonly<ConversationsStateType>,
modalToClose: 'maximumGroupSizeModalState' | 'recommendedGroupSizeModalState'
): ConversationsStateType {
const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
assertDev(
false,
"Can't close the modal in this composer step. Doing nothing"
);
return state;
}
if (composer[modalToClose] !== OneTimeModalState.Showing) {
return state;
}
return {
...state,
composer: {
...composer,
[modalToClose]: OneTimeModalState.Shown,
},
};
}
function getVerificationDataForConversation({
conversationId,
distributionId,
state,
untrustedUuids,
}: {
conversationId: string;
distributionId?: string;
state: Readonly<VerificationDataByConversation>;
untrustedUuids: ReadonlyArray<UUIDStringType>;
}): VerificationDataByConversation {
const existing = getOwn(state, conversationId);
if (
!existing ||
existing.type === ConversationVerificationState.VerificationCancelled
) {
return {
[conversationId]: {
type: ConversationVerificationState.PendingVerification as const,
uuidsNeedingVerification: distributionId ? [] : untrustedUuids,
...(distributionId
? {
byDistributionId: {
[distributionId]: {
uuidsNeedingVerification: untrustedUuids,
},
},
}
: undefined),
},
};
}
const existingUuids = distributionId
? existing.byDistributionId?.[distributionId]?.uuidsNeedingVerification
: existing.uuidsNeedingVerification;
const uuidsNeedingVerification: ReadonlyArray<UUIDStringType> = Array.from(
new Set([...(existingUuids || []), ...untrustedUuids])
);
return {
[conversationId]: {
...existing,
type: ConversationVerificationState.PendingVerification as const,
...(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 = ReadonlyDeep<
(
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;
}
function maybeUpdateSelectedMessageForDetails(
{
messageId,
targetedMessageForDetails,
}: {
messageId: string;
targetedMessageForDetails: MessageAttributesType | undefined;
},
state: ConversationsStateType
): ConversationsStateType {
if (!state.targetedMessageForDetails) {
return state;
}
if (state.targetedMessageForDetails.id !== messageId) {
return state;
}
return {
...state,
targetedMessageForDetails,
};
}
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType | StoryDistributionListsActionType>
): ConversationsStateType {
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
return {
...state,
verificationDataByConversation: {},
};
}
if (action.type === CLEAR_CANCELLED_VERIFICATION) {
const { conversationId } = action.payload;
const { verificationDataByConversation } = state;
const existing = getOwn(verificationDataByConversation, conversationId);
// If there are active verifications required, this will do nothing.
if (
existing &&
existing.type === ConversationVerificationState.PendingVerification
) {
return state;
}
return {
...state,
verificationDataByConversation: omit(
verificationDataByConversation,
conversationId
),
};
}
if (action.type === CANCEL_CONVERSATION_PENDING_VERIFICATION) {
const { canceledAt } = action.payload;
const { verificationDataByConversation } = state;
const newverificationDataByConversation: Record<
string,
ConversationVerificationData
> = {};
const entries = Object.entries(verificationDataByConversation);
if (!entries.length) {
log.warn(
'CANCEL_CONVERSATION_PENDING_VERIFICATION: No conversations pending verification'
);
return state;
}
for (const [conversationId, data] of entries) {
if (
data.type === ConversationVerificationState.VerificationCancelled &&
data.canceledAt > canceledAt
) {
newverificationDataByConversation[conversationId] = data;
} else {
newverificationDataByConversation[conversationId] = {
type: ConversationVerificationState.VerificationCancelled,
canceledAt,
};
}
}
return {
...state,
verificationDataByConversation: newverificationDataByConversation,
};
}
if (action.type === 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP') {
return omit(state, 'invitedUuidsForNewlyCreatedGroup');
}
if (action.type === 'CLEAR_GROUP_CREATION_ERROR') {
const { composer } = state;
if (composer?.step !== ComposerStep.SetGroupMetadata) {
assertDev(
false,
"Can't clear group creation error in this composer state. Doing nothing"
);
return state;
}
return {
...state,
composer: {
...composer,
hasError: false,
},
};
}
if (action.type === 'CLOSE_CONTACT_SPOOFING_REVIEW') {
return omit(state, 'contactSpoofingReview');
}
if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') {
return closeComposerModal(state, 'maximumGroupSizeModalState' as const);
}
if (action.type === 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL') {
return closeComposerModal(state, 'recommendedGroupSizeModalState' as const);
}
if (action.type === DISCARD_MESSAGES) {
if (state.selectedMessageIds != null) {
log.info('Not discarding messages because we are in select mode');
return state;
}
const { conversationId } = action.payload;
if ('numberToKeepAtBottom' in action.payload) {
const { numberToKeepAtBottom } = action.payload;
const conversationMessages = getOwn(
state.messagesByConversation,
conversationId
);
if (!conversationMessages) {
return state;
}
const { messageIds: oldMessageIds } = conversationMessages;
if (oldMessageIds.length <= numberToKeepAtBottom) {
return state;
}
const messageIdsToRemove = oldMessageIds.slice(0, -numberToKeepAtBottom);
const messageIdsToKeep = oldMessageIds.slice(-numberToKeepAtBottom);
return {
...state,
messagesLookup: omit(state.messagesLookup, messageIdsToRemove),
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...conversationMessages,
messageIds: messageIdsToKeep,
},
},
};
}
if ('numberToKeepAtTop' in action.payload) {
const { numberToKeepAtTop } = action.payload;
const conversationMessages = getOwn(
state.messagesByConversation,
conversationId
);
if (!conversationMessages) {
return state;
}
const { messageIds: oldMessageIds } = conversationMessages;
if (oldMessageIds.length <= numberToKeepAtTop) {
return state;
}
const messageIdsToRemove = oldMessageIds.slice(numberToKeepAtTop);
const messageIdsToKeep = oldMessageIds.slice(0, numberToKeepAtTop);
return {
...state,
messagesLookup: omit(state.messagesLookup, messageIdsToRemove),
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...conversationMessages,
messageIds: messageIdsToKeep,
},
},
};
}
throw missingCaseError(action.payload);
}
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
const { payload } = action;
const { data } = payload;
return {
...state,
preJoinConversation: data,
};
}
if (action.type === 'CONVERSATION_ADDED') {
const { payload } = action;
const { id, data } = payload;
const { conversationLookup } = state;
return {
...state,
conversationLookup: {
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, undefined, state),
};
}
if (action.type === 'CONVERSATION_CHANGED') {
const { payload } = action;
const { id, data } = payload;
const { conversationLookup } = state;
const { selectedConversationId } = state;
let { showArchived } = state;
const existing = conversationLookup[id];
// We only modify the lookup if we already had that conversation and the conversation
// changed.
if (!existing || data === existing) {
return state;
}
const keysToOmit: Array<keyof ConversationsStateType> = [];
if (selectedConversationId === id) {
// Archived -> Inbox: we go back to the normal inbox view
if (existing.isArchived && !data.isArchived) {
showArchived = false;
}
// Inbox -> Archived: no conversation is selected
// Note: With today's stacked conversations architecture, this can result in weird
// behavior - no selected conversation in the left pane, but a conversation show
// in the right pane.
if (!existing.isArchived && data.isArchived) {
keysToOmit.push('selectedConversationId');
}
if (!existing.isBlocked && data.isBlocked) {
keysToOmit.push('contactSpoofingReview');
}
}
return {
...omit(state, keysToOmit),
selectedConversationId,
showArchived,
conversationLookup: {
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, existing, state),
};
}
if (action.type === 'CONVERSATION_REMOVED') {
const { payload } = action;
const { id } = payload;
const { conversationLookup } = state;
const existing = getOwn(conversationLookup, id);
// No need to make a change if we didn't have a record of this conversation!
if (!existing) {
return state;
}
return {
...state,
conversationLookup: omit(conversationLookup, [id]),
...updateConversationLookups(undefined, existing, state),
};
}
if (action.type === CONVERSATION_UNLOADED) {
const { payload } = action;
const { conversationId } = payload;
const existingConversation = state.messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
const { messageIds } = existingConversation;
const selectedConversationId =
state.selectedConversationId !== conversationId
? state.selectedConversationId
: undefined;
return {
...omit(state, 'contactSpoofingReview'),
selectedConversationId,
targetedConversationPanels: [],
messagesLookup: omit(state.messagesLookup, [...messageIds]),
messagesByConversation: omit(state.messagesByConversation, [
conversationId,
]),
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
if (action.type === 'CREATE_GROUP_PENDING') {
const { composer } = state;
if (composer?.step !== ComposerStep.SetGroupMetadata) {
// This should be unlikely, but it can happen if someone closes the composer while
// a group is being created.
return state;
}
return {
...state,
composer: {
...composer,
hasError: false,
isCreating: true,
},
};
}
if (action.type === 'CREATE_GROUP_FULFILLED') {
// We don't do much here and instead rely on `showConversation` to do most of
// the work.
return {
...state,
invitedUuidsForNewlyCreatedGroup: action.payload.invitedUuids,
};
}
if (action.type === 'CREATE_GROUP_REJECTED') {
const { composer } = state;
if (composer?.step !== ComposerStep.SetGroupMetadata) {
// This should be unlikely, but it can happen if someone closes the composer while
// a group is being created.
return state;
}
return {
...state,
composer: {
...composer,
hasError: true,
isCreating: false,
},
};
}
if (action.type === 'MESSAGE_TARGETED') {
const { messageId, conversationId } = action.payload;
if (state.selectedConversationId !== conversationId) {
return state;
}
return {
...state,
targetedMessage: messageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.Focus,
};
}
if (action.type === 'TOGGLE_SELECT_MESSAGES') {
const { toggledMessageId, messageIds, selected } = action.payload;
let { selectedMessageIds = [] } = state;
if (selected) {
selectedMessageIds = selectedMessageIds.concat(messageIds);
} else {
selectedMessageIds = selectedMessageIds.filter(
id => !messageIds.includes(id)
);
}
const lastSelectedMessage = getOwn(state.messagesLookup, toggledMessageId);
strictAssert(lastSelectedMessage, 'Message not found in lookup');
return {
...state,
lastSelectedMessage: selected
? pick(lastSelectedMessage, 'sent_at', 'received_at')
: undefined,
selectedMessageIds,
};
}
if (action.type === 'TOGGLE_SELECT_MODE') {
const { on } = action.payload;
const { selectedMessageIds = [] } = state;
return {
...state,
lastSelectedMessage: undefined,
selectedMessageIds: on ? selectedMessageIds : undefined,
};
}
if (action.type === MODIFY_LIST) {
const {
id: listId,
isBlockList,
membersToRemove,
membersToAdd,
} = action.payload;
const removedUuids = new Set(isBlockList ? membersToAdd : membersToRemove);
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (listId === id) {
const uuidsNeedingVerification = data.uuidsNeedingVerification.filter(
uuid => !removedUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
return undefined;
}
return {
...data,
uuidsNeedingVerification,
};
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === DELETE_LIST) {
const { listId } = action.payload;
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (listId === id) {
return undefined;
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === HIDE_MY_STORIES_FROM) {
const removedUuids = new Set(action.payload);
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (MY_STORY_ID === id) {
const uuidsNeedingVerification = data.uuidsNeedingVerification.filter(
uuid => !removedUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
return undefined;
}
return {
...data,
uuidsNeedingVerification,
};
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === VIEWERS_CHANGED) {
const { listId, memberUuids } = action.payload;
const newUuids = new Set(memberUuids);
const nextVerificationData = visitListsInVerificationData(
state.verificationDataByConversation,
(id, data): DistributionVerificationData | undefined => {
if (listId === id) {
const uuidsNeedingVerification = data.uuidsNeedingVerification.filter(
uuid => newUuids.has(uuid)
);
if (!uuidsNeedingVerification.length) {
return undefined;
}
return {
...data,
uuidsNeedingVerification,
};
}
return data;
}
);
if (nextVerificationData === state.verificationDataByConversation) {
return state;
}
return {
...state,
verificationDataByConversation: nextVerificationData,
};
}
if (action.type === CONVERSATION_STOPPED_BY_MISSING_VERIFICATION) {
const { conversationId, distributionId, untrustedUuids } = action.payload;
const nextVerificationData = getVerificationDataForConversation({
conversationId,
distributionId,
state: state.verificationDataByConversation,
untrustedUuids,
});
return {
...state,
verificationDataByConversation: {
...state.verificationDataByConversation,
...nextVerificationData,
},
};
}
if (action.type === SHOW_SEND_ANYWAY_DIALOG) {
const verificationDataByConversation = {
...state.verificationDataByConversation,
};
Object.entries(action.payload.untrustedByConversation).forEach(
([conversationId, conversationData]) => {
const nextConversation = getVerificationDataForConversation({
state: verificationDataByConversation,
conversationId,
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);
}
);
}
);
return {
...state,
verificationDataByConversation,
};
}
if (action.type === MESSAGE_CHANGED) {
const { id, conversationId, data } = action.payload;
const existingConversation = state.messagesByConversation[conversationId];
// We don't keep track of messages unless their conversation is loaded...
if (!existingConversation) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: data },
state
);
}
// ...and we've already loaded that message once
const existingMessage = getOwn(state.messagesLookup, id);
if (!existingMessage) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: data },
state
);
}
const conversationAttrs = state.conversationLookup[conversationId];
const isGroupStoryReply = isGroup(conversationAttrs) && data.storyId;
if (isGroupStoryReply) {
return state;
}
const toIncrement =
data.reactions?.length ||
existingMessage.editHistory?.length !== data.editHistory?.length
? 1
: 0;
const updatedMessage = {
...data,
displayLimit: existingMessage.displayLimit,
isSpoilerExpanded: existingMessage.isSpoilerExpanded,
};
return {
...maybeUpdateSelectedMessageForDetails(
{
messageId: id,
targetedMessageForDetails: updatedMessage,
},
state
),
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...existingConversation,
messageChangeCounter:
(existingConversation.messageChangeCounter || 0) + toIncrement,
},
},
messagesLookup: {
...state.messagesLookup,
[id]: updatedMessage,
},
};
}
if (action.type === MESSAGE_EXPIRED) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: action.payload.id, targetedMessageForDetails: undefined },
state
);
}
if (action.type === 'MESSAGE_EXPANDED') {
const { id, displayLimit } = action.payload;
const existingMessage = state.messagesLookup[id];
if (!existingMessage) {
return state;
}
const updatedMessage = {
...existingMessage,
displayLimit,
};
return {
...state,
...maybeUpdateSelectedMessageForDetails(
{
messageId: id,
targetedMessageForDetails: updatedMessage,
},
state
),
messagesLookup: {
...state.messagesLookup,
[id]: updatedMessage,
},
};
}
if (action.type === SHOW_SPOILER) {
const { id, data } = action.payload;
const existingMessage = state.messagesLookup[id];
if (!existingMessage) {
return state;
}
const updatedMessage = {
...existingMessage,
isSpoilerExpanded: data,
};
return {
...state,
...maybeUpdateSelectedMessageForDetails(
{
messageId: id,
targetedMessageForDetails: updatedMessage,
},
state
),
messagesLookup: {
...state.messagesLookup,
[id]: updatedMessage,
},
};
}
if (action.type === 'MESSAGES_RESET') {
const {
conversationId,
messages,
metrics,
scrollToMessageId,
unboundedFetch,
} = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
const lookup = fromPairs(messages.map(message => [message.id, message]));
const sorted = orderBy(
values(lookup),
['received_at', 'sent_at'],
['ASC', 'ASC']
);
let { newest, oldest } = metrics;
// If our metrics are a little out of date, we'll fix them up
if (sorted.length > 0) {
const first = sorted[0];
if (first && (!oldest || first.received_at <= oldest.received_at)) {
oldest = pick(first, ['id', 'received_at', 'sent_at']);
}
const last = sorted[sorted.length - 1];
if (
last &&
(!newest || unboundedFetch || last.received_at >= newest.received_at)
) {
newest = pick(last, ['id', 'received_at', 'sent_at']);
}
}
const messageIds = sorted.map(message => message.id);
return {
...state,
...(state.selectedConversationId === conversationId
? {
targetedMessage: scrollToMessageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.Reset,
}
: {}),
messagesLookup: {
...messagesLookup,
...lookup,
},
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
messageChangeCounter: 0,
scrollToMessageId,
scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1
: 0,
messageIds,
metrics: {
...metrics,
newest,
oldest,
},
},
},
};
}
if (action.type === 'SET_MESSAGE_LOADING_STATE') {
const { payload } = action;
const { conversationId, messageLoadingState } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
messageLoadingState,
},
},
};
}
if (action.type === 'SET_NEAR_BOTTOM') {
const { payload } = action;
const { conversationId, isNearBottom } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (
!existingConversation ||
existingConversation.isNearBottom === isNearBottom
) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
isNearBottom,
},
},
};
}
if (action.type === 'SCROLL_TO_MESSAGE') {
const { payload } = action;
const { conversationId, messageId } = payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
if (!messagesLookup[messageId]) {
return state;
}
if (!existingConversation.messageIds.includes(messageId)) {
return state;
}
return {
...state,
targetedMessage: messageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.NavigateToMessage,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
messageLoadingState: undefined,
scrollToMessageId: messageId,
scrollToMessageCounter:
existingConversation.scrollToMessageCounter + 1,
},
},
};
}
if (action.type === MESSAGE_DELETED) {
const { id, conversationId } = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: undefined },
state
);
}
// Assuming that we always have contiguous groups of messages in memory, the removal
// of one message at one end of our message set be replaced with the message right
// next to it.
const oldIds = existingConversation.messageIds;
let { newest, oldest } = existingConversation.metrics;
if (oldIds.length > 1) {
const firstId = oldIds[0];
const lastId = oldIds[oldIds.length - 1];
if (oldest && oldest.id === firstId && firstId === id) {
const second = messagesLookup[oldIds[1]];
oldest = second
? pick(second, ['id', 'received_at', 'sent_at'])
: undefined;
}
if (newest && newest.id === lastId && lastId === id) {
const penultimate = messagesLookup[oldIds[oldIds.length - 2]];
newest = penultimate
? pick(penultimate, ['id', 'received_at', 'sent_at'])
: undefined;
}
}
// Removing it from our caches
const messageIds = without(existingConversation.messageIds, id);
let metrics;
if (messageIds.length === 0) {
metrics = {
totalUnseen: 0,
};
} else {
metrics = {
...existingConversation.metrics,
oldest,
newest,
};
}
return {
...maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: undefined },
state
),
messagesLookup: omit(messagesLookup, id),
messagesByConversation: {
[conversationId]: {
...existingConversation,
messageIds,
metrics,
},
},
};
}
if (action.type === 'REPAIR_NEWEST_MESSAGE') {
const { conversationId } = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = getOwn(messagesByConversation, conversationId);
if (!existingConversation) {
return state;
}
const { messageIds } = existingConversation;
const lastId =
messageIds && messageIds.length
? messageIds[messageIds.length - 1]
: undefined;
const last = lastId ? getOwn(messagesLookup, lastId) : undefined;
const newest = last
? pick(last, ['id', 'received_at', 'sent_at'])
: undefined;
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
metrics: {
...existingConversation.metrics,
newest,
},
},
},
};
}
if (action.type === 'REPAIR_OLDEST_MESSAGE') {
const { conversationId } = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = getOwn(messagesByConversation, conversationId);
if (!existingConversation) {
return state;
}
const { messageIds } = existingConversation;
const firstId = messageIds && messageIds.length ? messageIds[0] : undefined;
const first = firstId ? getOwn(messagesLookup, firstId) : undefined;
const oldest = first
? pick(first, ['id', 'received_at', 'sent_at'])
: undefined;
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
metrics: {
...existingConversation.metrics,
oldest,
},
},
},
};
}
if (action.type === 'REVIEW_GROUP_MEMBER_NAME_COLLISION') {
return {
...state,
contactSpoofingReview: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
...action.payload,
},
};
}
if (action.type === 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION') {
return {
...state,
contactSpoofingReview: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
...action.payload,
},
};
}
if (action.type === 'MESSAGES_ADDED') {
const { conversationId, isActive, isJustSent, isNewMessage, messages } =
action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
let { newest, oldest, oldestUnseen, totalUnseen } =
existingConversation.metrics;
if (messages.length < 1) {
return state;
}
const lookup = fromPairs(
existingConversation.messageIds.map(id => [id, messagesLookup[id]])
);
messages.forEach(message => {
lookup[message.id] = message;
});
const sorted = orderBy(
values(lookup),
['received_at', 'sent_at'],
['ASC', 'ASC']
);
const messageIds = sorted.map(message => message.id);
const first = sorted[0];
const last = sorted[sorted.length - 1];
if (!newest) {
newest = pick(first, ['id', 'received_at', 'sent_at']);
}
if (!oldest) {
oldest = pick(last, ['id', 'received_at', 'sent_at']);
}
const existingTotal = existingConversation.messageIds.length;
if (isNewMessage && existingTotal > 0) {
const lastMessageId = existingConversation.messageIds[existingTotal - 1];
// If our messages in memory don't include the most recent messages, then we
// won't add new messages to our message list.
const haveLatest = newest && newest.id === lastMessageId;
if (!haveLatest) {
if (isJustSent) {
log.warn(
'reducer/MESSAGES_ADDED: isJustSent is true, but haveLatest is false'
);
}
return state;
}
}
// Update oldest and newest if we receive older/newer
// messages (or duplicated timestamps!)
if (first && oldest && first.received_at <= oldest.received_at) {
oldest = pick(first, ['id', 'received_at', 'sent_at']);
}
if (last && newest && last.received_at >= newest.received_at) {
newest = pick(last, ['id', 'received_at', 'sent_at']);
}
const newIds = messages.map(message => message.id);
const newMessageIds = difference(newIds, existingConversation.messageIds);
const { isNearBottom } = existingConversation;
if ((!isNearBottom || !isActive) && !oldestUnseen) {
const oldestId = newMessageIds.find(messageId => {
const message = lookup[messageId];
return message && isMessageUnread(message);
});
if (oldestId) {
oldestUnseen = pick(lookup[oldestId], [
'id',
'received_at',
'sent_at',
]) as MessagePointerType;
}
}
// If this is a new incoming message, we'll increment our totalUnseen count
if (isNewMessage && !isJustSent && oldestUnseen) {
const newUnread: number = newMessageIds.reduce((sum, messageId) => {
const message = lookup[messageId];
return sum + (message && isMessageUnread(message) ? 1 : 0);
}, 0);
totalUnseen = (totalUnseen || 0) + newUnread;
}
return {
...state,
messagesLookup: {
...messagesLookup,
...lookup,
},
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
messageIds,
messageLoadingState: undefined,
scrollToMessageId: isJustSent ? last.id : undefined,
metrics: {
...existingConversation.metrics,
newest,
oldest,
totalUnseen,
oldestUnseen,
},
},
},
};
}
if (action.type === 'CLEAR_TARGETED_MESSAGE') {
return {
...state,
targetedMessage: undefined,
targetedMessageCounter: 0,
targetedMessageSource: undefined,
};
}
if (action.type === 'CLEAR_UNREAD_METRICS') {
const { payload } = action;
const { conversationId } = payload;
const existingConversation = state.messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...existingConversation,
metrics: {
...existingConversation.metrics,
oldestUnseen: undefined,
totalUnseen: 0,
},
},
},
};
}
if (action.type === TARGETED_CONVERSATION_CHANGED) {
const { payload } = action;
const { conversationId, messageId, switchToAssociatedView } = payload;
let conversation: ConversationType | undefined;
if (conversationId) {
conversation = getOwn(state.conversationLookup, conversationId);
if (!conversation) {
log.error(`Unknown conversation selected, id: [${conversationId}]`);
return state;
}
}
const nextState = {
...omit(state, 'contactSpoofingReview'),
selectedConversationId: conversationId,
targetedMessage: messageId,
targetedMessageSource: TargetedMessageSource.NavigateToMessage,
};
if (switchToAssociatedView && conversation) {
return {
...omit(nextState, 'composer', 'selectedMessageIds'),
showArchived: Boolean(conversation.isArchived),
};
}
return nextState;
}
if (action.type === 'SHOW_INBOX') {
return {
...omit(state, 'composer'),
showArchived: false,
};
}
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
return {
...omit(state, 'composer'),
showArchived: true,
};
}
if (action.type === PUSH_PANEL) {
if (action.payload.type === PanelType.MessageDetails) {
return {
...state,
targetedConversationPanels: [
...state.targetedConversationPanels,
action.payload,
],
targetedMessageForDetails: action.payload.args.message,
};
}
return {
...state,
targetedConversationPanels: [
...state.targetedConversationPanels,
action.payload,
],
};
}
if (action.type === POP_PANEL) {
const { targetedConversationPanels: selectedConversationPanels } = state;
const nextPanels = [...selectedConversationPanels];
const panel = nextPanels.pop();
if (!panel) {
return state;
}
if (panel.type === PanelType.MessageDetails) {
return {
...state,
targetedConversationPanels: nextPanels,
targetedMessageForDetails: undefined,
};
}
return {
...state,
targetedConversationPanels: nextPanels,
};
}
if (action.type === 'SET_RECENT_MEDIA_ITEMS') {
const { id, recentMediaItems } = action.payload;
const { conversationLookup } = state;
const conversationData = conversationLookup[id];
if (!conversationData) {
return state;
}
const data = {
...conversationData,
recentMediaItems,
};
return {
...state,
conversationLookup: {
...conversationLookup,
[id]: data,
},
...updateConversationLookups(data, undefined, state),
};
}
if (action.type === 'START_COMPOSING') {
if (state.composer?.step === ComposerStep.StartDirectConversation) {
return state;
}
return {
...state,
showArchived: false,
composer: {
step: ComposerStep.StartDirectConversation,
searchTerm: '',
uuidFetchState: {},
},
};
}
if (action.type === 'SHOW_CHOOSE_GROUP_MEMBERS') {
let selectedConversationIds: ReadonlyArray<string>;
let recommendedGroupSizeModalState: OneTimeModalState;
let maximumGroupSizeModalState: OneTimeModalState;
let groupName: string;
let groupAvatar: undefined | Uint8Array;
let groupExpireTimer: DurationInSeconds;
let userAvatarData = getDefaultAvatars(true);
switch (state.composer?.step) {
case ComposerStep.ChooseGroupMembers:
return state;
case ComposerStep.SetGroupMetadata:
({
selectedConversationIds,
recommendedGroupSizeModalState,
maximumGroupSizeModalState,
groupName,
groupAvatar,
groupExpireTimer,
userAvatarData,
} = state.composer);
break;
default:
selectedConversationIds = [];
recommendedGroupSizeModalState = OneTimeModalState.NeverShown;
maximumGroupSizeModalState = OneTimeModalState.NeverShown;
groupName = '';
groupExpireTimer = universalExpireTimer.get();
break;
}
return {
...state,
showArchived: false,
composer: {
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
uuidFetchState: {},
selectedConversationIds,
recommendedGroupSizeModalState,
maximumGroupSizeModalState,
groupName,
groupAvatar,
groupExpireTimer,
userAvatarData,
},
};
}
if (action.type === 'START_SETTING_GROUP_METADATA') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
return {
...state,
showArchived: false,
composer: {
step: ComposerStep.SetGroupMetadata,
isEditingAvatar: false,
isCreating: false,
hasError: false,
...pick(composer, [
'groupAvatar',
'groupName',
'groupExpireTimer',
'maximumGroupSizeModalState',
'recommendedGroupSizeModalState',
'selectedConversationIds',
'userAvatarData',
]),
},
};
case ComposerStep.SetGroupMetadata:
return state;
default:
assertDev(
false,
'Cannot transition to setting group metadata from this state'
);
return state;
}
}
if (action.type === 'SET_COMPOSE_GROUP_AVATAR') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
groupAvatar: action.payload.groupAvatar,
},
};
default:
assertDev(
false,
'Setting compose group avatar at this step is a no-op'
);
return state;
}
}
if (action.type === 'SET_COMPOSE_GROUP_NAME') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
groupName: action.payload.groupName,
},
};
default:
assertDev(false, 'Setting compose group name at this step is a no-op');
return state;
}
}
if (action.type === 'SET_COMPOSE_GROUP_EXPIRE_TIMER') {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
groupExpireTimer: action.payload.groupExpireTimer,
},
};
default:
assertDev(false, 'Setting compose group name at this step is a no-op');
return state;
}
}
if (action.type === 'SET_COMPOSE_SEARCH_TERM') {
const { composer } = state;
if (!composer) {
assertDev(
false,
'Setting compose search term with the composer closed is a no-op'
);
return state;
}
if (
composer.step !== ComposerStep.StartDirectConversation &&
composer.step !== ComposerStep.ChooseGroupMembers
) {
assertDev(
false,
`Setting compose search term at step ${composer.step} is a no-op`
);
return state;
}
return {
...state,
composer: {
...composer,
searchTerm: action.payload.searchTerm,
},
};
}
if (action.type === 'SET_IS_FETCHING_UUID') {
const { composer } = state;
if (!composer) {
assertDev(
false,
'Setting compose uuid fetch state with the composer closed is a no-op'
);
return state;
}
if (
composer.step !== ComposerStep.StartDirectConversation &&
composer.step !== ComposerStep.ChooseGroupMembers
) {
assertDev(
false,
'Setting compose uuid fetch state at this step is a no-op'
);
return state;
}
const { identifier, isFetching } = action.payload;
const { uuidFetchState } = composer;
return {
...state,
composer: {
...composer,
uuidFetchState: isFetching
? {
...composer.uuidFetchState,
[identifier]: isFetching,
}
: omit(uuidFetchState, identifier),
},
};
}
if (action.type === COMPOSE_TOGGLE_EDITING_AVATAR) {
const { composer } = state;
switch (composer?.step) {
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
isEditingAvatar: !composer.isEditingAvatar,
},
};
default:
assertDev(false, 'Setting editing avatar at this step is a no-op');
return state;
}
}
if (action.type === COMPOSE_ADD_AVATAR) {
const { payload } = action;
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
userAvatarData: [
{
...payload,
id: getNextAvatarId(composer.userAvatarData),
},
...composer.userAvatarData,
],
},
};
default:
assertDev(false, 'Adding an avatar at this step is a no-op');
return state;
}
}
if (action.type === COMPOSE_REMOVE_AVATAR) {
const { payload } = action;
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
userAvatarData: filterAvatarData(composer.userAvatarData, payload),
},
};
default:
assertDev(false, 'Removing an avatar at this step is a no-op');
return state;
}
}
if (action.type === COMPOSE_REPLACE_AVATAR) {
const { curr, prev } = action.payload;
const { composer } = state;
switch (composer?.step) {
case ComposerStep.ChooseGroupMembers:
case ComposerStep.SetGroupMetadata:
return {
...state,
composer: {
...composer,
userAvatarData: [
{
...curr,
id: prev?.id ?? getNextAvatarId(composer.userAvatarData),
},
...(prev
? filterAvatarData(composer.userAvatarData, prev)
: composer.userAvatarData),
],
},
};
default:
assertDev(false, 'Replacing an avatar at this step is a no-op');
return state;
}
}
if (action.type === 'TOGGLE_CONVERSATION_IN_CHOOSE_MEMBERS') {
const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
assertDev(
false,
'Toggling conversation members is a no-op in this composer step'
);
return state;
}
return {
...state,
composer: {
...composer,
...toggleSelectedContactForGroupAddition(
action.payload.conversationId,
{
maxGroupSize: action.payload.maxGroupSize,
maxRecommendedGroupSize: action.payload.maxRecommendedGroupSize,
maximumGroupSizeModalState: composer.maximumGroupSizeModalState,
// We say you're already in the group, even though it hasn't been created yet.
numberOfContactsAlreadyInGroup: 1,
recommendedGroupSizeModalState:
composer.recommendedGroupSizeModalState,
selectedConversationIds: composer.selectedConversationIds,
}
),
},
};
}
if (action.type === COLORS_CHANGED) {
const { conversationLookup } = state;
const { conversationColor, customColorData } = action.payload;
const nextState = {
...state,
};
Object.keys(conversationLookup).forEach(id => {
const existing = conversationLookup[id];
const added = {
...existing,
conversationColor,
customColor: customColorData?.value,
customColorId: customColorData?.id,
};
Object.assign(
nextState,
updateConversationLookups(added, existing, nextState),
{
conversationLookup: {
...nextState.conversationLookup,
[id]: added,
},
}
);
});
return nextState;
}
if (action.type === COLOR_SELECTED) {
const { conversationLookup } = state;
const { conversationId, conversationColor, customColorData } =
action.payload;
const existing = conversationLookup[conversationId];
if (!existing) {
return state;
}
const changed = {
...existing,
conversationColor,
customColor: customColorData?.value,
customColorId: customColorData?.id,
};
return {
...state,
conversationLookup: {
...conversationLookup,
[conversationId]: changed,
},
...updateConversationLookups(changed, existing, state),
};
}
if (action.type === CUSTOM_COLOR_REMOVED) {
const { conversationLookup } = state;
const { colorId } = action.payload;
const nextState = {
...state,
};
Object.keys(conversationLookup).forEach(id => {
const existing = conversationLookup[id];
if (existing.customColorId !== colorId) {
return;
}
const changed = {
...existing,
conversationColor: undefined,
customColor: undefined,
customColorId: undefined,
};
Object.assign(
nextState,
updateConversationLookups(changed, existing, nextState),
{
conversationLookup: {
...nextState.conversationLookup,
[id]: changed,
},
}
);
});
return nextState;
}
if (action.type === REPLACE_AVATARS) {
const { conversationLookup } = state;
const { conversationId, avatars } = action.payload;
const conversation = conversationLookup[conversationId];
if (!conversation) {
return state;
}
const changed = {
...conversation,
avatars,
};
return {
...state,
conversationLookup: {
...conversationLookup,
[conversationId]: changed,
},
...updateConversationLookups(changed, conversation, state),
};
}
return state;
}