import { createSelector } from 'reselect'; import { StateType } from '../reducer'; import { ConversationLookupType, ConversationsStateType, MentionsMembersType, MessageModelProps, MessagePropsDetails, ReduxConversationType, SortedMessageModelProps, } from '../ducks/conversations'; import { getIntl, getOurNumber } from './user'; import { BlockedNumberController } from '../../util'; import { ConversationTypeEnum } from '../../models/conversation'; import { LocalizerType } from '../../types/Util'; import { ConversationHeaderProps, ConversationHeaderTitleProps, } from '../../components/conversation/ConversationHeader'; import { LightBoxOptions } from '../../components/session/conversation/SessionConversation'; import { ReplyingToMessageProps } from '../../components/session/conversation/SessionCompositionBox'; import { getConversationController } from '../../session/conversations'; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; export const getConversationLookup = createSelector( getConversations, (state: ConversationsStateType): ConversationLookupType => { return state.conversationLookup; } ); export const getSelectedConversationKey = createSelector( getConversations, (state: ConversationsStateType): string | undefined => { return state.selectedConversation; } ); export const getSelectedConversation = createSelector( getConversations, (state: ConversationsStateType): ReduxConversationType | undefined => { return state.selectedConversation ? state.conversationLookup[state.selectedConversation] : undefined; } ); export const getOurPrimaryConversation = createSelector( getConversations, (state: ConversationsStateType): ReduxConversationType => state.conversationLookup[window.storage.get('primaryDevicePubKey')] ); const getMessagesOfSelectedConversation = createSelector( getConversations, (state: ConversationsStateType): Array => state.messages ); // Redux recommends to do filtered and deriving state in a selector rather than ourself export const getSortedMessagesOfSelectedConversation = createSelector( getMessagesOfSelectedConversation, (messages: Array): Array => { if (messages.length === 0) { return []; } const convoId = messages[0].propsForMessage.convoId; const convo = getConversationController().get(convoId); if (!convo) { return []; } const isPublic = convo.isPublic() || false; const isPrivate = convo.isPrivate() || false; const sortedMessage = sortMessages(messages, isPublic); return updateFirstMessageOfSeries(sortedMessage, { isPublic, isPrivate }); } ); function getConversationTitle( conversation: ReduxConversationType, testingi18n?: LocalizerType ): string { if (conversation.name) { return conversation.name; } if (conversation.type === 'group') { return (testingi18n || window.i18n)('unknown'); } return conversation.id; } const collator = new Intl.Collator(); export const _getConversationComparator = (testingi18n?: LocalizerType) => { return (left: ReduxConversationType, right: ReduxConversationType): number => { // Pin is the first criteria to check if (left.isPinned && !right.isPinned) { return -1; } if (!left.isPinned && right.isPinned) { return 1; } // Then if none is pinned, check other criteria const leftActiveAt = left.activeAt; const rightActiveAt = right.activeAt; if (leftActiveAt && !rightActiveAt) { return -1; } if (rightActiveAt && !leftActiveAt) { return 1; } if (leftActiveAt && rightActiveAt && leftActiveAt !== rightActiveAt) { return rightActiveAt - leftActiveAt; } const leftTitle = getConversationTitle(left, testingi18n).toLowerCase(); const rightTitle = getConversationTitle(right, testingi18n).toLowerCase(); return collator.compare(leftTitle, rightTitle); }; }; export const getConversationComparator = createSelector(getIntl, _getConversationComparator); // export only because we use it in some of our tests export const _getLeftPaneLists = ( lookup: ConversationLookupType, comparator: (left: ReduxConversationType, right: ReduxConversationType) => number, selectedConversation?: string ): { conversations: Array; contacts: Array; unreadCount: number; } => { const values = Object.values(lookup); const sorted = values.sort(comparator); const conversations: Array = []; const directConversations: Array = []; let index = 0; let unreadCount = 0; for (let conversation of sorted) { if (selectedConversation === conversation.id) { conversation = { ...conversation, isSelected: true, }; } const isBlocked = BlockedNumberController.isBlocked(conversation.id) || BlockedNumberController.isGroupBlocked(conversation.id); if (isBlocked) { conversation = { ...conversation, isBlocked: true, }; } // Add Open Group to list as soon as the name has been set if (conversation.isPublic && (!conversation.name || conversation.name === 'Unknown group')) { continue; } // Remove all invalid conversations and conversatons of devices associated // with cancelled attempted links if (!conversation.isPublic && !conversation.activeAt) { continue; } if (conversation.activeAt !== undefined && conversation.type === ConversationTypeEnum.PRIVATE) { directConversations.push(conversation); } if (unreadCount < 9 && conversation.unreadCount > 0) { unreadCount += conversation.unreadCount; } conversations.push(conversation); index++; } return { conversations, contacts: directConversations, unreadCount, }; }; export const getLeftPaneLists = createSelector( getConversationLookup, getConversationComparator, getSelectedConversationKey, _getLeftPaneLists ); export const getMe = createSelector( [getConversationLookup, getOurNumber], (lookup: ConversationLookupType, ourNumber: string): ReduxConversationType => { return lookup[ourNumber]; } ); export const getDirectContacts = createSelector( getLeftPaneLists, (state: { conversations: Array; contacts: Array; unreadCount: number; }) => state.contacts ); export const getUnreadMessageCount = createSelector(getLeftPaneLists, (state): number => { return state.unreadCount; }); export const getConversationHeaderTitleProps = createSelector(getSelectedConversation, (state): | ConversationHeaderTitleProps | undefined => { if (!state) { return undefined; } return { isKickedFromGroup: state.isKickedFromGroup, phoneNumber: state.phoneNumber, isMe: state.isMe, members: state.members || [], isPublic: state.isPublic, profileName: state.profileName, name: state.name, subscriberCount: state.subscriberCount, isGroup: state.type === 'group', currentNotificationSetting: state.currentNotificationSetting, }; }); /** * Returns the formatted text for notification setting. */ export const getCurrentNotificationSettingText = createSelector(getSelectedConversation, (state): | string | undefined => { if (!state) { return undefined; } switch (state.currentNotificationSetting) { case 'all': return window.i18n('notificationForConvo_all'); case 'mentions_only': return window.i18n('notificationForConvo_mentions_only'); case 'disabled': return window.i18n('notificationForConvo_mentions_disabled'); default: return window.i18n('notificationForConvo_all'); } }); export const getConversationHeaderProps = createSelector(getSelectedConversation, (state): | ConversationHeaderProps | undefined => { if (!state) { return undefined; } const expirationSettingName = state.expireTimer ? window.Whisper.ExpirationTimerOptions.getName(state.expireTimer || 0) : null; return { id: state.id, isPrivate: state.isPrivate, notificationForConvo: state.notificationForConvo, currentNotificationSetting: state.currentNotificationSetting, isBlocked: state.isBlocked, left: state.left, avatarPath: state.avatarPath, expirationSettingName: expirationSettingName, hasNickname: state.hasNickname, weAreAdmin: state.weAreAdmin, isKickedFromGroup: state.isKickedFromGroup, phoneNumber: state.phoneNumber, isMe: state.isMe, members: state.members || [], isPublic: state.isPublic, profileName: state.profileName, name: state.name, subscriberCount: state.subscriberCount, isGroup: state.isGroup, }; }); export const getNumberOfPinnedConversations = createSelector(getConversations, (state): number => { const values = Object.values(state.conversationLookup); return values.filter(conversation => conversation.isPinned).length; }); export const isMessageDetailView = createSelector( getConversations, (state: ConversationsStateType): boolean => state.messageDetailProps !== undefined ); export const getMessageDetailsViewProps = createSelector( getConversations, (state: ConversationsStateType): MessagePropsDetails | undefined => state.messageDetailProps ); export const isRightPanelShowing = createSelector( getConversations, (state: ConversationsStateType): boolean => state.showRightPanel ); export const isMessageSelectionMode = createSelector( getConversations, (state: ConversationsStateType): boolean => state.selectedMessageIds.length > 0 ); export const getSelectedMessageIds = createSelector( getConversations, (state: ConversationsStateType): Array => state.selectedMessageIds ); export const getLightBoxOptions = createSelector( getConversations, (state: ConversationsStateType): LightBoxOptions | undefined => state.lightBox ); export const getQuotedMessage = createSelector( getConversations, (state: ConversationsStateType): ReplyingToMessageProps | undefined => state.quotedMessage ); export const areMoreMessagesBeingFetched = createSelector( getConversations, (state: ConversationsStateType): boolean => state.areMoreMessagesBeingFetched || false ); export const getHaveDoneFirstScroll = createSelector( getConversations, (state: ConversationsStateType): boolean => state.haveDoneFirstScroll ); export const getShowScrollButton = createSelector( getConversations, (state: ConversationsStateType): boolean => state.showScrollButton || false ); export const getQuotedMessageToAnimate = createSelector( getConversations, (state: ConversationsStateType): string | undefined => state.animateQuotedMessageId || undefined ); export const getNextMessageToPlayId = createSelector( getConversations, (state: ConversationsStateType): string | undefined => state.nextMessageToPlayId || undefined ); export const getMentionsInput = createSelector( getConversations, (state: ConversationsStateType): MentionsMembersType => state.mentionMembers ); export const getDraftForCurrentConversation = createSelector( getConversations, (state: ConversationsStateType): string => { if (state.selectedConversation) { return ( state.draftsForConversations.find(c => c.conversationKey === state.selectedConversation) ?.draft || '' ); } return ''; } ); /// Those calls are just related to ordering messages in the redux store. function updateFirstMessageOfSeries( messageModelsProps: Array, convoOpts: { isPrivate: boolean; isPublic: boolean } ): Array { // messages are got from the more recent to the oldest, so we need to check if // the next messages in the list is still the same author. // The message is the first of the series if the next message is not from the same author const sortedMessageProps: Array = []; if (convoOpts.isPrivate) { // we don't really care do do that logic for private chats return messageModelsProps.map(p => { return { ...p, firstMessageOfSeries: true }; }); } for (let i = 0; i < messageModelsProps.length; i++) { const currentSender = messageModelsProps[i].propsForMessage?.authorPhoneNumber; const nextSender = i < messageModelsProps.length - 1 ? messageModelsProps[i + 1].propsForMessage?.authorPhoneNumber : undefined; // Handle firstMessageOfSeries for conditional avatar rendering if (i >= 0 && currentSender === nextSender) { sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries: false }); } else { sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries: true }); } } return sortedMessageProps; } function sortMessages( messages: Array, isPublic: boolean ): Array { // we order by serverTimestamp for public convos // be sure to update the sorting order to fetch messages from the DB too at getMessagesByConversation if (isPublic) { return messages.slice().sort((a, b) => { return (b.propsForMessage.serverTimestamp || 0) - (a.propsForMessage.serverTimestamp || 0); }); } if (messages.some(n => !n.propsForMessage.timestamp && !n.propsForMessage.receivedAt)) { throw new Error('Found some messages without any timestamp set'); } // for non public convos, we order by sent_at or received_at timestamp. // we assume that a message has either a sent_at or a received_at field set. const messagesSorted = messages .slice() .sort( (a, b) => (b.propsForMessage.timestamp || b.propsForMessage.receivedAt || 0) - (a.propsForMessage.timestamp || a.propsForMessage.receivedAt || 0) ); return messagesSorted; } export const getFirstUnreadMessageId = createSelector( getConversations, (state: ConversationsStateType): string | undefined => { return state.firstUnreadMessageId; } ); export const getMostRecentMessageId = createSelector( getSortedMessagesOfSelectedConversation, (messages: Array): string | undefined => { return messages.length ? messages[0].propsForMessage.id : undefined; } ); export const getOldestMessageId = createSelector( getSortedMessagesOfSelectedConversation, (messages: Array): string | undefined => { const oldest = messages.length > 0 ? messages[messages.length - 1].propsForMessage.id : undefined; return oldest; } ); export const getLoadedMessagesLength = createSelector( getConversations, (state: ConversationsStateType): number => { return state.messages.length || 0; } ); export const isFirstUnreadMessageIdAbove = createSelector( getConversations, (state: ConversationsStateType): boolean => { if (!state.firstUnreadMessageId) { return false; } const isNotPresent = !state.messages.some( m => m.propsForMessage.id === state.firstUnreadMessageId ); return isNotPresent; } );