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

1337 lines
38 KiB
TypeScript

// 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<ConversationType> => Object.values(lookup)
);
export const getAllSignalConnections = createSelector(
getAllConversations,
(conversations): Array<ConversationType> =>
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<string> | 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<AvatarDataType> =>
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<ConversationType>;
archivedConversations: ReadonlyArray<ConversationType>;
pinnedConversations: ReadonlyArray<ConversationType>;
}>;
export const _getLeftPaneLists = (
lookup: ConversationLookupType,
comparator: (left: ConversationType, right: ConversationType) => number,
selectedConversation?: string,
pinnedConversationIds?: ReadonlyArray<string>
): LeftPaneLists => {
const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = [];
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<ConversationType> =>
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<ConversationType> =>
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<ConversationType> =>
Object.values(conversationLookup).filter(
conversation =>
conversation.type === 'direct' && canComposeConversation(conversation)
)
);
export const getCandidateContactsForNewGroup = createSelector(
getConversationLookup,
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
Object.values(conversationLookup).filter(
conversation =>
conversation.type === 'direct' &&
!conversation.isMe &&
canComposeConversation(conversation)
)
);
export const getComposableGroups = createSelector(
getConversationLookup,
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
Object.values(conversationLookup).filter(
conversation =>
conversation.type === 'group' && canComposeConversation(conversation)
)
);
const getConversationIdsWithStories = createSelector(
(state: StateType): StoriesStateType => state.stories,
(stories: StoriesStateType): Set<string> => {
return new Set(stories.stories.map(({ conversationId }) => conversationId));
}
);
export const getNonGroupStories = createSelector(
getComposableGroups,
getConversationIdsWithStories,
(
groups: Array<ConversationType>,
conversationIdsWithStories: Set<string>
): Array<ConversationType> => {
return groups.filter(
group => !isGroupInStoryMode(group, conversationIdsWithStories)
);
}
);
export const selectMostRecentActiveStoryTimestampByGroupOrDistributionList =
createSelector(
(state: StateType): ReadonlyArray<StoryDataType> => state.stories.stories,
(stories: ReadonlyArray<StoryDataType>): Record<string, number> => {
return reduce<StoryDataType, Record<string, number>>(
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<string>,
hasStoriesSelector
): Array<ConversationWithStoriesType> => {
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<ConversationType>,
regionCode: string | undefined
): Array<ConversationType> => {
return filterAndSortConversations(contacts, searchTerm, regionCode);
}
);
export const getFilteredComposeGroups = createSelector(
getNormalizedComposerConversationSearchTerm,
getComposableGroups,
getRegionCode,
(
searchTerm: string,
groups: ReadonlyArray<ConversationType>,
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<string>;
} => {
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<ConversationType> =>
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<string, ContactNameColorType> = 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<string, string>,
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<ConversationType> =>
deconstructLookup(conversationLookup, invitedServiceIdsForNewlyCreatedGroup)
);
export const getConversationsWithCustomColorSelector = createSelector(
getAllConversations,
conversations => {
return (colorId: string): Array<ConversationType> => {
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<ConversationType> => {
const {
groupId,
groupVersion,
memberships = [],
} = conversationSelector(conversationId);
if (
!isGroupV2({
groupId,
groupVersion,
})
) {
return [];
}
const admins: Array<ConversationType> = [];
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<ConversationsStateType>
): Record<string, ConversationVerificationData> =>
conversations.verificationDataByConversation
);
export const getConversationIdsStoppedForVerification = createSelector(
getConversationVerificationData,
(verificationDataByConversation): Array<string> =>
Object.keys(verificationDataByConversation)
);
export const getConversationServiceIdsStoppingSend = createSelector(
getConversationVerificationData,
(pendingData): Array<ServiceIdString> => {
const result = new Set<ServiceIdString>();
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<ServiceIdString>
): Array<ConversationType> => {
const conversations = serviceIds.map(serviceId =>
conversationSelector(serviceId)
);
return sortByTitle(conversations);
}
);
export const getHideStoryConversationIds = createSelector(
getConversationLookup,
(conversationLookup): Array<string> =>
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;
}
);