// Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import memoizee from 'memoizee'; import { isNumber, pick } from 'lodash'; import { createSelector } from 'reselect'; import type { StateType } from '../reducer'; import type { ConversationLookupType, ConversationMessageType, ConversationsStateType, ConversationType, ConversationVerificationData, MessageLookupType, MessagesByConversationType, MessageTimestamps, PreJoinConversationType, } from '../ducks/conversations'; import type { StoriesStateType, StoryDataType } from '../ducks/stories'; import { ComposerStep, OneTimeModalState, ConversationVerificationState, } from '../ducks/conversationsEnums'; import { getOwn } from '../../util/getOwn'; import type { UUIDFetchStateType } from '../../util/uuidFetchState'; import { deconstructLookup } from '../../util/deconstructLookup'; import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import { assertDev } from '../../util/assert'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import { filterAndSortConversations } from '../../util/filterAndSortConversations'; import type { ContactNameColorType } from '../../types/Colors'; import { ContactNameColors } from '../../types/Colors'; import type { AvatarDataType } from '../../types/Avatar'; import type { AciString, ServiceIdString } from '../../types/ServiceId'; import { normalizeServiceId } from '../../types/ServiceId'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isSignalConnection } from '../../util/getSignalConnections'; import { sortByTitle } from '../../util/sortByTitle'; import { DurationInSeconds } from '../../util/durations'; import { isDirectConversation, isGroupV1, isGroupV2, } from '../../util/whatTypeOfConversation'; import { isGroupInStoryMode } from '../../util/isGroupInStoryMode'; import { getIntl, getRegionCode, getUserConversationId, getUserNumber, } from './user'; import { getPinnedConversationIds } from './items'; import * as log from '../../logging/log'; import { TimelineMessageLoadingState } from '../../util/timelineUtil'; import { isSignalConversation } from '../../util/isSignalConversation'; import { reduce } from '../../util/iterables'; import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType'; import type { PanelRenderType } from '../../types/Panels'; import type { HasStories } from '../../types/Stories'; import { getHasStoriesSelector } from './stories2'; import { canEditMessage } from '../../util/canEditMessage'; import { isOutgoing } from '../../messages/helpers'; import { countAllConversationsUnreadStats, type UnreadStats, } from '../../util/countUnreadStats'; export type ConversationWithStoriesType = ConversationType & { hasStories?: HasStories; }; let placeholderContact: ConversationType; export const getPlaceholderContact = (): ConversationType => { if (placeholderContact) { return placeholderContact; } placeholderContact = { acceptedMessageRequest: false, badges: [], id: 'placeholder-contact', type: 'direct', title: window.i18n('icu:unknownContact'), isMe: false, sharedGroupNames: [], }; return placeholderContact; }; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; export const getPreJoinConversation = createSelector( getConversations, (state: ConversationsStateType): PreJoinConversationType | undefined => { return state.preJoinConversation; } ); export const getConversationLookup = createSelector( getConversations, (state: ConversationsStateType): ConversationLookupType => { return state.conversationLookup; } ); export const getConversationsByServiceId = createSelector( getConversations, (state: ConversationsStateType): ConversationLookupType => { return state.conversationsByServiceId; } ); export const getConversationsByE164 = createSelector( getConversations, (state: ConversationsStateType): ConversationLookupType => { return state.conversationsByE164; } ); export const getConversationsByGroupId = createSelector( getConversations, (state: ConversationsStateType): ConversationLookupType => { return state.conversationsByGroupId; } ); export const getHasPanelOpen = createSelector( getConversations, (state: ConversationsStateType): boolean => { return state.targetedConversationPanels.watermark > 0; } ); export const getConversationsByUsername = createSelector( getConversations, (state: ConversationsStateType): ConversationLookupType => { return state.conversationsByUsername; } ); export const getAllConversations = createSelector( getConversationLookup, (lookup): Array => Object.values(lookup) ); export const getAllSignalConnections = createSelector( getAllConversations, (conversations): Array => conversations.filter(isSignalConnection) ); export const getSafeConversationWithSameTitle = createSelector( getAllConversations, ( _state: StateType, { possiblyUnsafeConversation, }: { possiblyUnsafeConversation: ConversationType; } ) => possiblyUnsafeConversation, (conversations, possiblyUnsafeConversation): ConversationType | undefined => { const conversationsWithSameTitle = conversations.filter(conversation => { return conversation.title === possiblyUnsafeConversation.title; }); assertDev( conversationsWithSameTitle.length, 'Expected at least 1 conversation with the same title (this one)' ); const safeConversation = conversationsWithSameTitle.find( otherConversation => otherConversation.acceptedMessageRequest && otherConversation.type === 'direct' && otherConversation.id !== possiblyUnsafeConversation.id ); return safeConversation; } ); export const getSelectedConversationId = createSelector( getConversations, (state: ConversationsStateType): string | undefined => { return state.selectedConversationId; } ); type TargetedMessageType = { id: string; counter: number; }; export const getTargetedMessage = createSelector( getConversations, (state: ConversationsStateType): TargetedMessageType | undefined => { if (!state.targetedMessage) { return undefined; } return { id: state.targetedMessage, counter: state.targetedMessageCounter, }; } ); export const getTargetedMessageSource = createSelector( getConversations, (state: ConversationsStateType): string | undefined => { return state.targetedMessageSource; } ); export const getSelectedMessageIds = createSelector( getConversations, (state: ConversationsStateType): ReadonlyArray | undefined => { return state.selectedMessageIds; } ); export const getLastSelectedMessage = createSelector( getConversations, (state: ConversationsStateType): MessageTimestamps | undefined => { return state.lastSelectedMessage; } ); export const getShowArchived = createSelector( getConversations, (state: ConversationsStateType): boolean => { return Boolean(state.showArchived); } ); const getComposerState = createSelector( getConversations, (state: ConversationsStateType) => state.composer ); export const getComposerStep = createSelector( getComposerState, (composerState): undefined | ComposerStep => composerState?.step ); export const hasGroupCreationError = createSelector( getComposerState, (composerState): boolean => { if (composerState?.step === ComposerStep.SetGroupMetadata) { return composerState.hasError; } return false; } ); export const isCreatingGroup = createSelector( getComposerState, (composerState): boolean => composerState?.step === ComposerStep.SetGroupMetadata && composerState.isCreating ); export const isEditingAvatar = createSelector( getComposerState, (composerState): boolean => composerState?.step === ComposerStep.SetGroupMetadata && composerState.isEditingAvatar ); export const getComposeAvatarData = createSelector( getComposerState, (composerState): ReadonlyArray => composerState?.step === ComposerStep.SetGroupMetadata ? composerState.userAvatarData : [] ); export const getMessages = createSelector( getConversations, (state: ConversationsStateType): MessageLookupType => { return state.messagesLookup; } ); export const getMessagesByConversation = createSelector( getConversations, (state: ConversationsStateType): MessagesByConversationType => { return state.messagesByConversation; } ); export const getConversationMessages = createSelector( getSelectedConversationId, getMessagesByConversation, ( conversationId, messagesByConversation ): ConversationMessageType | undefined => { return conversationId ? messagesByConversation[conversationId] : undefined; } ); const collator = new Intl.Collator(); // Note: we will probably want to put i18n and regionCode back when we are formatting // phone numbers and contacts from scratch here again. export const _getConversationComparator = () => { return (left: ConversationType, right: ConversationType): number => { // These two fields can be sorted with each other; they are timestamps const leftTimestamp = left.lastMessageReceivedAtMs || left.timestamp; const rightTimestamp = right.lastMessageReceivedAtMs || right.timestamp; if (leftTimestamp && !rightTimestamp) { return -1; } if (rightTimestamp && !leftTimestamp) { return 1; } if (leftTimestamp && rightTimestamp && leftTimestamp !== rightTimestamp) { return rightTimestamp - leftTimestamp; } // This field looks like a timestamp, but is actually a counter const leftCounter = left.lastMessageReceivedAt; const rightCounter = right.lastMessageReceivedAt; if (leftCounter && !rightCounter) { return -1; } if (rightCounter && !leftCounter) { return 1; } if (leftCounter && rightCounter && leftCounter !== rightCounter) { return rightCounter - leftCounter; } if ( typeof left.inboxPosition === 'number' && typeof right.inboxPosition === 'number' ) { return right.inboxPosition > left.inboxPosition ? -1 : 1; } if (typeof left.inboxPosition === 'number' && right.inboxPosition == null) { return -1; } if (typeof right.inboxPosition === 'number' && left.inboxPosition == null) { return 1; } return collator.compare(left.title, right.title); }; }; export const getConversationComparator = createSelector( getIntl, getRegionCode, _getConversationComparator ); type LeftPaneLists = Readonly<{ conversations: ReadonlyArray; archivedConversations: ReadonlyArray; pinnedConversations: ReadonlyArray; }>; export const _getLeftPaneLists = ( lookup: ConversationLookupType, comparator: (left: ConversationType, right: ConversationType) => number, selectedConversation?: string, pinnedConversationIds?: ReadonlyArray ): LeftPaneLists => { const conversations: Array = []; const archivedConversations: Array = []; const pinnedConversations: Array = []; const values = Object.values(lookup); const max = values.length; for (let i = 0; i < max; i += 1) { let conversation = values[i]; if (selectedConversation === conversation.id) { conversation = { ...conversation, isSelected: true, }; } if (isSignalConversation(conversation)) { continue; } // We always show pinned conversations if (conversation.isPinned) { pinnedConversations.push(conversation); continue; } if (conversation.activeAt) { if (conversation.isArchived) { archivedConversations.push(conversation); } else { conversations.push(conversation); } } } conversations.sort(comparator); archivedConversations.sort(comparator); pinnedConversations.sort( (a, b) => (pinnedConversationIds || []).indexOf(a.id) - (pinnedConversationIds || []).indexOf(b.id) ); return { conversations, archivedConversations, pinnedConversations }; }; export const getLeftPaneLists = createSelector( getConversationLookup, getConversationComparator, getSelectedConversationId, getPinnedConversationIds, _getLeftPaneLists ); export const getMaximumGroupSizeModalState = createSelector( getComposerState, (composerState): OneTimeModalState => { switch (composerState?.step) { case ComposerStep.ChooseGroupMembers: case ComposerStep.SetGroupMetadata: return composerState.maximumGroupSizeModalState; default: assertDev( false, 'Can\'t get the maximum group size modal state in this composer state; returning "never shown"' ); return OneTimeModalState.NeverShown; } } ); export const getRecommendedGroupSizeModalState = createSelector( getComposerState, (composerState): OneTimeModalState => { switch (composerState?.step) { case ComposerStep.ChooseGroupMembers: case ComposerStep.SetGroupMetadata: return composerState.recommendedGroupSizeModalState; default: assertDev( false, 'Can\'t get the recommended group size modal state in this composer state; returning "never shown"' ); return OneTimeModalState.NeverShown; } } ); export const getMe = createSelector( [getConversationLookup, getUserConversationId], ( lookup: ConversationLookupType, ourConversationId: string | undefined ): ConversationType => { if (!ourConversationId) { return getPlaceholderContact(); } return lookup[ourConversationId] || getPlaceholderContact(); } ); export const getComposerConversationSearchTerm = createSelector( getComposerState, (composer): string => { if (!composer) { assertDev( false, 'getComposerConversationSearchTerm: composer is not open' ); return ''; } if (composer.step === ComposerStep.SetGroupMetadata) { assertDev( false, 'getComposerConversationSearchTerm: composer does not have a search term' ); return ''; } return composer.searchTerm; } ); export const getComposerSelectedRegion = createSelector( getComposerState, (composer): string => { if (!composer) { assertDev(false, 'getComposerSelectedRegion: composer is not open'); return ''; } if (composer.step !== ComposerStep.FindByPhoneNumber) { assertDev( false, 'getComposerSelectedRegion: composer does not have a selected region' ); return ''; } return composer.selectedRegion; } ); export const getComposerUUIDFetchState = createSelector( getComposerState, (composer): UUIDFetchStateType => { if (!composer) { assertDev(false, 'getIsFetchingUsername: composer is not open'); return {}; } if ( composer.step !== ComposerStep.StartDirectConversation && composer.step !== ComposerStep.FindByUsername && composer.step !== ComposerStep.FindByPhoneNumber && composer.step !== ComposerStep.ChooseGroupMembers ) { assertDev( false, `getComposerUUIDFetchState: step ${composer.step} ` + 'has no uuidFetchState key' ); return {}; } return composer.uuidFetchState; } ); export const getHasContactSpoofingReview = createSelector( getConversations, (state: ConversationsStateType): boolean => { return state.hasContactSpoofingReview; } ); function isTrusted(conversation: ConversationType): boolean { if (conversation.type === 'group') { return true; } return Boolean( isInSystemContacts(conversation) || conversation.sharedGroupNames.length > 0 || conversation.profileSharing || conversation.isMe ); } function hasDisplayInfo(conversation: ConversationType): boolean { if (conversation.type === 'group') { return Boolean(conversation.name); } return Boolean( conversation.name || conversation.profileName || conversation.phoneNumber || conversation.isMe ); } function canComposeConversation(conversation: ConversationType): boolean { return Boolean( !isSignalConversation(conversation) && !conversation.isBlocked && !conversation.removalStage && ((isGroupV2(conversation) && !conversation.left) || !isConversationUnregistered(conversation)) && hasDisplayInfo(conversation) && isTrusted(conversation) ); } export const getAllComposableConversations = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( conversation => !isSignalConversation(conversation) && !conversation.isBlocked && !conversation.removalStage && !conversation.isGroupV1AndDisabled && ((isGroupV2(conversation) && !conversation.left) || !isConversationUnregistered(conversation)) && // All conversation should have a title except in weird cases where // they don't, in that case we don't want to show these for Forwarding. conversation.titleNoDefault && hasDisplayInfo(conversation) ) ); export const getAllGroupsWithInviteAccess = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter(conversation => { return ( conversation.type === 'group' && conversation.title && conversation.canAddNewMembers ); }) ); export const getAllConversationsUnreadStats = createSelector( getAllConversations, (conversations): UnreadStats => { return countAllConversationsUnreadStats(conversations, { includeMuted: false, }); } ); /** * getComposableContacts/getCandidateContactsForNewGroup both return contacts for the * composer and group members, a different list from your primary system contacts. * This list may include false positives, which is better than missing contacts. * * Note: the key difference between them: * getComposableContacts includes Note to Self * getCandidateContactsForNewGroup does not include Note to Self * * Because they filter unregistered contacts and that's (partially) determined by the * current time, it's possible for them to return stale contacts that have unregistered * if no other conversations change. This should be a rare false positive. */ export const getComposableContacts = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( conversation => conversation.type === 'direct' && canComposeConversation(conversation) ) ); export const getCandidateContactsForNewGroup = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( conversation => conversation.type === 'direct' && !conversation.isMe && canComposeConversation(conversation) ) ); export const getComposableGroups = createSelector( getConversationLookup, (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( conversation => conversation.type === 'group' && canComposeConversation(conversation) ) ); const getConversationIdsWithStories = createSelector( (state: StateType): StoriesStateType => state.stories, (stories: StoriesStateType): Set => { return new Set(stories.stories.map(({ conversationId }) => conversationId)); } ); export const getNonGroupStories = createSelector( getComposableGroups, getConversationIdsWithStories, ( groups: Array, conversationIdsWithStories: Set ): Array => { return groups.filter( group => !isGroupInStoryMode(group, conversationIdsWithStories) ); } ); export const selectMostRecentActiveStoryTimestampByGroupOrDistributionList = createSelector( (state: StateType): ReadonlyArray => state.stories.stories, (stories: ReadonlyArray): Record => { return reduce>( stories, (acc, story) => { const distributionListOrConversationId = story.storyDistributionListId ?? story.conversationId; const cur = acc[distributionListOrConversationId]; if (cur && story.timestamp < cur) { return acc; } return { ...acc, [distributionListOrConversationId]: story.timestamp, }; }, {} ); } ); export const getGroupStories = createSelector( getConversationLookup, getConversationIdsWithStories, getHasStoriesSelector, ( conversationLookup: ConversationLookupType, conversationIdsWithStories: Set, hasStoriesSelector ): Array => { return Object.values(conversationLookup) .filter( conversation => isGroupInStoryMode(conversation, conversationIdsWithStories) && !conversation.left ) .map(conversation => ({ ...conversation, hasStories: hasStoriesSelector(conversation.id), })); } ); const getNormalizedComposerConversationSearchTerm = createSelector( getComposerConversationSearchTerm, (searchTerm: string): string => searchTerm.trim() ); export const getFilteredComposeContacts = createSelector( getNormalizedComposerConversationSearchTerm, getComposableContacts, getRegionCode, ( searchTerm: string, contacts: ReadonlyArray, regionCode: string | undefined ): Array => { return filterAndSortConversations(contacts, searchTerm, regionCode); } ); export const getFilteredComposeGroups = createSelector( getNormalizedComposerConversationSearchTerm, getComposableGroups, getRegionCode, ( searchTerm: string, groups: ReadonlyArray, regionCode: string | undefined ): Array< ConversationType & { membersCount: number; disabledReason: undefined; memberships: ReadonlyArray<{ aci: AciString; isAdmin: boolean; }>; } > => { return filterAndSortConversations(groups, searchTerm, regionCode).map( group => ({ ...group, // we don't disable groups when composing, already filtered disabledReason: undefined, // should always be populated for a group membersCount: group.membersCount ?? 0, memberships: group.memberships ?? [], }) ); } ); export const getFilteredCandidateContactsForNewGroup = createSelector( getCandidateContactsForNewGroup, getNormalizedComposerConversationSearchTerm, getRegionCode, filterAndSortConversations ); const getGroupCreationComposerState = createSelector( getComposerState, ( composerState ): { groupName: string; groupAvatar: undefined | Uint8Array; groupExpireTimer: DurationInSeconds; selectedConversationIds: ReadonlyArray; } => { switch (composerState?.step) { case ComposerStep.ChooseGroupMembers: case ComposerStep.SetGroupMetadata: return composerState; default: assertDev( false, 'getSetGroupMetadataComposerState: expected step to be SetGroupMetadata' ); return { groupName: '', groupAvatar: undefined, groupExpireTimer: DurationInSeconds.ZERO, selectedConversationIds: [], }; } } ); export const getComposeGroupAvatar = createSelector( getGroupCreationComposerState, (composerState): undefined | Uint8Array => composerState.groupAvatar ); export const getComposeGroupName = createSelector( getGroupCreationComposerState, (composerState): string => composerState.groupName ); export const getComposeGroupExpireTimer = createSelector( getGroupCreationComposerState, (composerState): DurationInSeconds => composerState.groupExpireTimer ); export const getComposeSelectedContacts = createSelector( getConversationLookup, getGroupCreationComposerState, (conversationLookup, composerState): Array => deconstructLookup(conversationLookup, composerState.selectedConversationIds) ); // This is where we will put Conversation selector logic, replicating what // is currently in models/conversation.getProps() // What needs to happen to pull that selector logic here? // 1) contactTypingTimers - that UI-only state needs to be moved to redux // 2) all of the message selectors need to be reselect-based; today those // Backbone-based prop-generation functions expect to get Conversation information // directly via ConversationController export function _conversationSelector( conversation?: ConversationType // regionCode: string, // userNumber: string ): ConversationType { if (conversation) { return conversation; } return getPlaceholderContact(); } // A little optimization to reset our selector cache when high-level application data // changes: regionCode and userNumber. type CachedConversationSelectorType = ( conversation?: ConversationType ) => ConversationType; export const getCachedSelectorForConversation = createSelector( getRegionCode, getUserNumber, (): CachedConversationSelectorType => { // Note: memoizee will check all parameters provided, and only run our selector // if any of them have changed. return memoizee(_conversationSelector, { max: 2000 }); } ); export type GetConversationByAnyIdSelectorType = ( id?: string ) => ConversationType | undefined; export const getConversationByAnyIdSelector = createSelector( getConversationLookup, getConversationsByServiceId, getConversationsByE164, getConversationsByGroupId, ( byId: ConversationLookupType, byServiceId: ConversationLookupType, byE164: ConversationLookupType, byGroupId: ConversationLookupType ): GetConversationByAnyIdSelectorType => { return (id?: string) => { if (!id) { return undefined; } const onGroupId = getOwn(byGroupId, id); if (onGroupId) { return onGroupId; } const onServiceId = getOwn( byServiceId, normalizeServiceId(id, 'getConversationSelector') ); if (onServiceId) { return onServiceId; } const onE164 = getOwn(byE164, id); if (onE164) { return onE164; } const onId = getOwn(byId, id); if (onId) { return onId; } return undefined; }; } ); export type GetConversationByIdType = (id?: string) => ConversationType; export const getConversationSelector = createSelector( getCachedSelectorForConversation, getConversationByAnyIdSelector, ( selector: CachedConversationSelectorType, getById: GetConversationByAnyIdSelectorType ): GetConversationByIdType => { return (id?: string) => { if (!id) { return selector(undefined); } const byId = getById(id); if (byId) { return selector(byId); } log.warn(`getConversationSelector: No conversation found for id ${id}`); // This will return a placeholder contact return selector(undefined); }; } ); export const getConversationByIdSelector = createSelector( getConversationLookup, conversationLookup => (id: string): undefined | ConversationType => getOwn(conversationLookup, id) ); export const getConversationByServiceIdSelector = createSelector( getConversationsByServiceId, conversationsByServiceId => (serviceId: ServiceIdString): undefined | ConversationType => getOwn(conversationsByServiceId, serviceId) ); export const getCachedConversationMemberColorsSelector = createSelector( getConversationSelector, getUserConversationId, ( conversationSelector: GetConversationByIdType, ourConversationId: string | undefined ) => { return memoizee( (conversationId: string | undefined) => { const contactNameColors: Map = new Map(); const { sortedGroupMembers = [], type, id: theirId, } = conversationSelector(conversationId); if (type === 'direct') { if (ourConversationId) { contactNameColors.set(ourConversationId, ContactNameColors[0]); } contactNameColors.set(theirId, ContactNameColors[0]); return contactNameColors; } [...sortedGroupMembers] .sort((left, right) => String(left.serviceId) > String(right.serviceId) ? 1 : -1 ) .forEach((member, i) => { contactNameColors.set( member.id, ContactNameColors[i % ContactNameColors.length] ); }); return contactNameColors; }, { max: 100 } ); } ); export type ContactNameColorSelectorType = ( conversationId: string, contactId: string | undefined ) => ContactNameColorType; export const getContactNameColorSelector = createSelector( getCachedConversationMemberColorsSelector, conversationMemberColorsSelector => { return ( conversationId: string, contactId: string | undefined ): ContactNameColorType => { const contactNameColors = conversationMemberColorsSelector(conversationId); return getContactNameColor(contactNameColors, contactId); }; } ); export const getContactNameColor = ( contactNameColors: Map, contactId: string | undefined ): string => { if (!contactId) { log.warn('No color generated for missing contactId'); return ContactNameColors[0]; } const color = contactNameColors.get(contactId); if (!color) { log.warn(`No color generated for contact ${contactId}`); return ContactNameColors[0]; } return color; }; export function _conversationMessagesSelector( conversation: ConversationMessageType ): TimelinePropsType { const { isNearBottom = null, messageChangeCounter, messageIds, messageLoadingState = null, metrics, scrollToMessageCounter, scrollToMessageId, } = conversation; const firstId = messageIds[0]; const lastId = messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1]; const { oldestUnseen } = metrics; const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id; const haveOldest = !metrics.oldest || !firstId || firstId === metrics.oldest.id; const items = messageIds; const oldestUnseenIndex = oldestUnseen ? messageIds.findIndex(id => id === oldestUnseen.id) : null; const scrollToIndex = scrollToMessageId ? messageIds.findIndex(id => id === scrollToMessageId) : null; const { totalUnseen } = metrics; return { haveNewest, haveOldest, isNearBottom, items, messageChangeCounter, messageLoadingState, oldestUnseenIndex: isNumber(oldestUnseenIndex) && oldestUnseenIndex >= 0 ? oldestUnseenIndex : null, scrollToIndex: isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : null, scrollToIndexCounter: scrollToMessageCounter, totalUnseen, }; } type CachedConversationMessagesSelectorType = ( conversation: ConversationMessageType ) => TimelinePropsType; export const getCachedSelectorForConversationMessages = createSelector( getRegionCode, getUserNumber, (): CachedConversationMessagesSelectorType => { // Note: memoizee will check all parameters provided, and only run our selector // if any of them have changed. return memoizee(_conversationMessagesSelector, { max: 50 }); } ); export const getConversationMessagesSelector = createSelector( getCachedSelectorForConversationMessages, getMessagesByConversation, ( conversationMessagesSelector: CachedConversationMessagesSelectorType, messagesByConversation: MessagesByConversationType ) => { return (id: string): TimelinePropsType => { const conversation = messagesByConversation[id]; if (!conversation) { // TODO: DESKTOP-2340 return { haveNewest: false, haveOldest: false, messageChangeCounter: 0, messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad, scrollToIndexCounter: 0, totalUnseen: 0, items: [], isNearBottom: null, oldestUnseenIndex: null, scrollToIndex: null, }; } return conversationMessagesSelector(conversation); }; } ); export const getInvitedContactsForNewlyCreatedGroup = createSelector( getConversationsByServiceId, getConversations, ( conversationLookup, { invitedServiceIdsForNewlyCreatedGroup = [] } ): Array => deconstructLookup(conversationLookup, invitedServiceIdsForNewlyCreatedGroup) ); export const getConversationsWithCustomColorSelector = createSelector( getAllConversations, conversations => { return (colorId: string): Array => { return conversations.filter( conversation => conversation.customColorId === colorId ); }; } ); export function isMissingRequiredProfileSharing( conversation: ConversationType ): boolean { const doesConversationRequireIt = !conversation.isMe && !conversation.left && !conversation.removalStage && (isGroupV1(conversation) || isDirectConversation(conversation)); return Boolean( doesConversationRequireIt && !conversation.profileSharing && conversation.hasMessages ); } export const getGroupAdminsSelector = createSelector( getConversationSelector, (conversationSelector: GetConversationByIdType) => { return (conversationId: string): Array => { const { groupId, groupVersion, memberships = [], } = conversationSelector(conversationId); if ( !isGroupV2({ groupId, groupVersion, }) ) { return []; } const admins: Array = []; memberships.forEach(membership => { if (membership.isAdmin) { const admin = conversationSelector(membership.aci); admins.push(admin); } }); return admins; }; } ); export const getContactSelector = createSelector( getConversationSelector, conversationSelector => { return (contactId: string) => pick(conversationSelector(contactId), 'id', 'title', 'serviceId'); } ); export const getConversationVerificationData = createSelector( getConversations, ( conversations: Readonly ): Record => conversations.verificationDataByConversation ); export const getConversationIdsStoppedForVerification = createSelector( getConversationVerificationData, (verificationDataByConversation): Array => Object.keys(verificationDataByConversation) ); export const getConversationServiceIdsStoppingSend = createSelector( getConversationVerificationData, (pendingData): Array => { const result = new Set(); Object.values(pendingData).forEach(item => { if (item.type === ConversationVerificationState.PendingVerification) { item.serviceIdsNeedingVerification.forEach(serviceId => { result.add(serviceId); }); if (item.byDistributionId) { Object.values(item.byDistributionId).forEach(distribution => { distribution.serviceIdsNeedingVerification.forEach(serviceId => { result.add(serviceId); }); }); } } }); return Array.from(result); } ); export const getConversationsStoppingSend = createSelector( getConversationSelector, getConversationServiceIdsStoppingSend, ( conversationSelector: GetConversationByIdType, serviceIds: ReadonlyArray ): Array => { const conversations = serviceIds.map(serviceId => conversationSelector(serviceId) ); return sortByTitle(conversations); } ); export const getHideStoryConversationIds = createSelector( getConversationLookup, (conversationLookup): Array => Object.keys(conversationLookup).filter( conversationId => conversationLookup[conversationId].hideStory ) ); export const getActivePanel = createSelector( getConversations, (conversations): PanelRenderType | undefined => conversations.targetedConversationPanels.stack[ conversations.targetedConversationPanels.watermark ] ); type PanelInformationType = { currPanel: PanelRenderType | undefined; direction: 'push' | 'pop'; prevPanel: PanelRenderType | undefined; }; export const getPanelInformation = createSelector( getConversations, getActivePanel, (conversations, currPanel): PanelInformationType | undefined => { const { direction, watermark } = conversations.targetedConversationPanels; if (!direction) { return; } const watermarkDirection = direction === 'push' ? watermark - 1 : watermark + 1; const prevPanel = conversations.targetedConversationPanels.stack[watermarkDirection]; return { currPanel, direction, prevPanel, }; } ); export const getIsPanelAnimating = createSelector( getConversations, (conversations): boolean => { return conversations.targetedConversationPanels.isAnimating; } ); export const getWasPanelAnimated = createSelector( getConversations, (conversations): boolean => { return conversations.targetedConversationPanels.wasAnimated; } ); export const getConversationTitle = createSelector( getIntl, getActivePanel, (i18n, panel): string | undefined => getConversationTitleForPanelType(i18n, panel?.type) ); // Note that this doesn't take into account max edit count. See canEditMessage. export const getLastEditableMessageId = createSelector( getConversationMessages, getMessages, (conversationMessages, messagesLookup): string | undefined => { if (!conversationMessages) { return; } for (let i = conversationMessages.messageIds.length - 1; i >= 0; i -= 1) { const messageId = conversationMessages.messageIds[i]; const message = messagesLookup[messageId]; if (!message) { continue; } if (isOutgoing(message)) { return canEditMessage(message) ? message.id : undefined; } } return undefined; } );