You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			965 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			965 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			TypeScript
		
	
| /* eslint-disable no-restricted-syntax */
 | |
| import { createSelector } from '@reduxjs/toolkit';
 | |
| import { filter, isEmpty, isFinite, isNumber, pick, sortBy, toNumber } from 'lodash';
 | |
| 
 | |
| import {
 | |
|   ConversationLookupType,
 | |
|   ConversationsStateType,
 | |
|   lookupQuote,
 | |
|   MentionsMembersType,
 | |
|   MessageModelPropsWithConvoProps,
 | |
|   MessageModelPropsWithoutConvoProps,
 | |
|   MessagePropsDetails,
 | |
|   PropsForQuote,
 | |
|   QuoteLookupType,
 | |
|   ReduxConversationType,
 | |
|   SortedMessageModelProps,
 | |
| } from '../ducks/conversations';
 | |
| import { StateType } from '../reducer';
 | |
| 
 | |
| import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox';
 | |
| import { MessageAttachmentSelectorProps } from '../../components/conversation/message/message-content/MessageAttachment';
 | |
| import { MessageContentSelectorProps } from '../../components/conversation/message/message-content/MessageContent';
 | |
| import { MessageContentWithStatusSelectorProps } from '../../components/conversation/message/message-content/MessageContentWithStatus';
 | |
| import { MessageTextSelectorProps } from '../../components/conversation/message/message-content/MessageText';
 | |
| import { GenericReadableMessageSelectorProps } from '../../components/conversation/message/message-item/GenericReadableMessage';
 | |
| import { LightBoxOptions } from '../../components/conversation/SessionConversation';
 | |
| import { hasValidIncomingRequestValues } from '../../models/conversation';
 | |
| import { CONVERSATION_PRIORITIES, isOpenOrClosedGroup } from '../../models/conversationAttributes';
 | |
| import { getConversationController } from '../../session/conversations';
 | |
| import { UserUtils } from '../../session/utils';
 | |
| import { LocalizerType } from '../../types/Util';
 | |
| import { BlockedNumberController } from '../../util';
 | |
| import { Storage } from '../../util/storage';
 | |
| import { getIntl } from './user';
 | |
| 
 | |
| import { MessageReactsSelectorProps } from '../../components/conversation/message/message-content/MessageReactions';
 | |
| import { processQuoteAttachment } from '../../models/message';
 | |
| import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
 | |
| import { PubKey } from '../../session/types';
 | |
| import { getSelectedConversationKey } from './selectedConversation';
 | |
| import { getModeratorsOutsideRedux } from './sogsRoomInfo';
 | |
| 
 | |
| export const getConversations = (state: StateType): ConversationsStateType => state.conversations;
 | |
| 
 | |
| export const getConversationLookup = (state: StateType): ConversationLookupType => {
 | |
|   return state.conversations.conversationLookup;
 | |
| };
 | |
| 
 | |
| export const getConversationsCount = createSelector(getConversationLookup, (state): number => {
 | |
|   return Object.keys(state).length;
 | |
| });
 | |
| 
 | |
| const getConversationQuotes = (state: StateType): QuoteLookupType | undefined => {
 | |
|   return state.conversations.quotes;
 | |
| };
 | |
| 
 | |
| export const getOurPrimaryConversation = createSelector(
 | |
|   getConversations,
 | |
|   (state: ConversationsStateType): ReduxConversationType =>
 | |
|     state.conversationLookup[Storage.get('primaryDevicePubKey') as string]
 | |
| );
 | |
| 
 | |
| const getMessagesOfSelectedConversation = (
 | |
|   state: StateType
 | |
| ): Array<MessageModelPropsWithoutConvoProps> => state.conversations.messages;
 | |
| 
 | |
| // Redux recommends to do filtered and deriving state in a selector rather than ourself
 | |
| export const getSortedMessagesOfSelectedConversation = createSelector(
 | |
|   getMessagesOfSelectedConversation,
 | |
|   (messages: Array<MessageModelPropsWithoutConvoProps>): Array<SortedMessageModelProps> => {
 | |
|     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 sortedMessage = sortMessages(messages, isPublic);
 | |
| 
 | |
|     return updateFirstMessageOfSeries(sortedMessage);
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const hasSelectedConversationIncomingMessages = createSelector(
 | |
|   getSortedMessagesOfSelectedConversation,
 | |
|   (messages: Array<MessageModelPropsWithoutConvoProps>): boolean => {
 | |
|     return messages.some(m => m.propsForMessage.direction === 'incoming');
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getFirstUnreadMessageId = (state: StateType): string | undefined => {
 | |
|   return state.conversations.firstUnreadMessageId;
 | |
| };
 | |
| 
 | |
| export type MessagePropsType =
 | |
|   | 'group-notification'
 | |
|   | 'group-invitation'
 | |
|   | 'data-extraction'
 | |
|   | 'message-request-response'
 | |
|   | 'timer-notification'
 | |
|   | 'regular-message'
 | |
|   | 'unread-indicator'
 | |
|   | 'call-notification';
 | |
| 
 | |
| export const getSortedMessagesTypesOfSelectedConversation = createSelector(
 | |
|   getSortedMessagesOfSelectedConversation,
 | |
|   getFirstUnreadMessageId,
 | |
|   (sortedMessages, firstUnreadId) => {
 | |
|     const maxMessagesBetweenTwoDateBreaks = 5;
 | |
|     // we want to show the date break if there is a large jump in time
 | |
|     // remember that messages are sorted from the most recent to the oldest
 | |
|     return sortedMessages.map((msg, index) => {
 | |
|       const isFirstUnread = Boolean(firstUnreadId === msg.propsForMessage.id);
 | |
|       const messageTimestamp = msg.propsForMessage.serverTimestamp || msg.propsForMessage.timestamp;
 | |
|       // do not show the date break if we are the oldest message (no previous)
 | |
|       // this is to smooth a bit the loading of older message (to avoid a jump once new messages are rendered)
 | |
|       const previousMessageTimestamp =
 | |
|         index + 1 >= sortedMessages.length
 | |
|           ? Number.MAX_SAFE_INTEGER
 | |
|           : sortedMessages[index + 1].propsForMessage.serverTimestamp ||
 | |
|             sortedMessages[index + 1].propsForMessage.timestamp;
 | |
| 
 | |
|       const showDateBreak =
 | |
|         messageTimestamp - previousMessageTimestamp > maxMessagesBetweenTwoDateBreaks * 60 * 1000
 | |
|           ? messageTimestamp
 | |
|           : undefined;
 | |
| 
 | |
|       const common = { showUnreadIndicator: isFirstUnread, showDateBreak };
 | |
| 
 | |
|       if (msg.propsForDataExtractionNotification) {
 | |
|         return {
 | |
|           ...common,
 | |
|           message: {
 | |
|             messageType: 'data-extraction',
 | |
|             props: { ...msg.propsForDataExtractionNotification, messageId: msg.propsForMessage.id },
 | |
|           },
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       if (msg.propsForMessageRequestResponse) {
 | |
|         return {
 | |
|           ...common,
 | |
|           message: {
 | |
|             messageType: 'message-request-response',
 | |
|             props: { ...msg.propsForMessageRequestResponse, messageId: msg.propsForMessage.id },
 | |
|           },
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       if (msg.propsForGroupInvitation) {
 | |
|         return {
 | |
|           ...common,
 | |
|           message: {
 | |
|             messageType: 'group-invitation',
 | |
|             props: { ...msg.propsForGroupInvitation, messageId: msg.propsForMessage.id },
 | |
|           },
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       if (msg.propsForGroupUpdateMessage) {
 | |
|         return {
 | |
|           ...common,
 | |
|           message: {
 | |
|             messageType: 'group-notification',
 | |
|             props: { ...msg.propsForGroupUpdateMessage, messageId: msg.propsForMessage.id },
 | |
|           },
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       if (msg.propsForTimerNotification) {
 | |
|         return {
 | |
|           ...common,
 | |
|           message: {
 | |
|             messageType: 'timer-notification',
 | |
|             props: { ...msg.propsForTimerNotification, messageId: msg.propsForMessage.id },
 | |
|           },
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       if (msg.propsForCallNotification) {
 | |
|         return {
 | |
|           ...common,
 | |
|           message: {
 | |
|             messageType: 'call-notification',
 | |
|             props: {
 | |
|               ...msg.propsForCallNotification,
 | |
|               messageId: msg.propsForMessage.id,
 | |
|             },
 | |
|           },
 | |
|         };
 | |
|       }
 | |
| 
 | |
|       return {
 | |
|         showUnreadIndicator: isFirstUnread,
 | |
|         showDateBreak,
 | |
|         message: {
 | |
|           messageType: 'regular-message',
 | |
|           props: { messageId: msg.propsForMessage.id },
 | |
|         },
 | |
|       };
 | |
|     });
 | |
|   }
 | |
| );
 | |
| 
 | |
| function getConversationTitle(
 | |
|   conversation: ReduxConversationType,
 | |
|   testingi18n?: LocalizerType
 | |
| ): string {
 | |
|   if (conversation.displayNameInProfile) {
 | |
|     return conversation.displayNameInProfile;
 | |
|   }
 | |
| 
 | |
|   if (isOpenOrClosedGroup(conversation.type)) {
 | |
|     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
 | |
|     const leftPriority = left.priority || 0;
 | |
|     const rightPriority = right.priority || 0;
 | |
|     if (leftPriority > rightPriority) {
 | |
|       return -1;
 | |
|     }
 | |
|     if (rightPriority > leftPriority) {
 | |
|       return 1;
 | |
|     }
 | |
|     // Then if none are 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);
 | |
| 
 | |
| const _getLeftPaneConversationIds = (
 | |
|   sortedConversations: Array<ReduxConversationType>
 | |
| ): Array<string> => {
 | |
|   return sortedConversations
 | |
|     .filter(conversation => {
 | |
|       if (conversation.isBlocked) {
 | |
|         return false;
 | |
|       }
 | |
| 
 | |
|       // a non private conversation is always returned here
 | |
|       if (!conversation.isPrivate) {
 | |
|         return true;
 | |
|       }
 | |
| 
 | |
|       // a private conversation not approved is a message request. Exclude them from the left pane lists
 | |
|       if (!conversation.isApproved) {
 | |
|         return false;
 | |
|       }
 | |
| 
 | |
|       // a hidden contact conversation is only visible from the contact list, not from the global conversation list
 | |
|       if (conversation.priority && conversation.priority <= CONVERSATION_PRIORITIES.default) {
 | |
|         return false;
 | |
|       }
 | |
| 
 | |
|       return true;
 | |
|     })
 | |
|     .map(m => m.id);
 | |
| };
 | |
| 
 | |
| const _getPrivateFriendsConversations = (
 | |
|   sortedConversations: Array<ReduxConversationType>
 | |
| ): Array<ReduxConversationType> => {
 | |
|   return sortedConversations.filter(convo => {
 | |
|     return (
 | |
|       convo.isPrivate &&
 | |
|       !convo.isMe &&
 | |
|       !convo.isBlocked &&
 | |
|       convo.isApproved &&
 | |
|       convo.didApproveMe &&
 | |
|       convo.activeAt !== undefined
 | |
|     );
 | |
|   });
 | |
| };
 | |
| 
 | |
| const _getGlobalUnreadCount = (sortedConversations: Array<ReduxConversationType>): number => {
 | |
|   let globalUnreadCount = 0;
 | |
|   for (const conversation of sortedConversations) {
 | |
|     // Blocked conversation are now only visible from the settings, not in the conversation list, so don't add it neither to the contacts list nor the conversation list
 | |
|     if (conversation.isBlocked) {
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     // a private conversation not approved is a message request. Exclude them from the unread count
 | |
|     if (conversation.isPrivate && !conversation.isApproved) {
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     // a hidden contact conversation is only visible from the contact list, not from the global conversation list
 | |
|     if (
 | |
|       conversation.isPrivate &&
 | |
|       conversation.priority &&
 | |
|       conversation.priority <= CONVERSATION_PRIORITIES.default
 | |
|     ) {
 | |
|       // dont increase unread counter, don't push to convo list.
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     if (
 | |
|       globalUnreadCount < 100 &&
 | |
|       isNumber(conversation.unreadCount) &&
 | |
|       isFinite(conversation.unreadCount) &&
 | |
|       conversation.unreadCount > 0 &&
 | |
|       conversation.currentNotificationSetting !== 'disabled'
 | |
|     ) {
 | |
|       globalUnreadCount += conversation.unreadCount;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return globalUnreadCount;
 | |
| };
 | |
| 
 | |
| export const _getSortedConversations = (
 | |
|   lookup: ConversationLookupType,
 | |
|   comparator: (left: ReduxConversationType, right: ReduxConversationType) => number
 | |
| ): Array<ReduxConversationType> => {
 | |
|   const values = Object.values(lookup);
 | |
|   const sorted = values.sort(comparator);
 | |
| 
 | |
|   const sortedConversations: Array<ReduxConversationType> = [];
 | |
| 
 | |
|   for (const conversation of sorted) {
 | |
|     // Remove all invalid conversations and conversatons of devices associated
 | |
|     //  with cancelled attempted links
 | |
|     if (!conversation.isPublic && !conversation.activeAt) {
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     const isBlocked = BlockedNumberController.isBlocked(conversation.id);
 | |
| 
 | |
|     sortedConversations.push({
 | |
|       ...conversation,
 | |
|       isBlocked: isBlocked || undefined,
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   return sortedConversations;
 | |
| };
 | |
| 
 | |
| export const getSortedConversations = createSelector(
 | |
|   getConversationLookup,
 | |
|   getConversationComparator,
 | |
|   getSelectedConversationKey,
 | |
|   _getSortedConversations
 | |
| );
 | |
| 
 | |
| /**
 | |
|  *
 | |
|  * @param sortedConversations List of conversations that are valid for both requests and regular conversation inbox
 | |
|  * @returns A list of message request conversations.
 | |
|  */
 | |
| const _getConversationRequests = (
 | |
|   sortedConversations: Array<ReduxConversationType>
 | |
| ): Array<ReduxConversationType> => {
 | |
|   return filter(sortedConversations, conversation => {
 | |
|     const { isApproved, isBlocked, isPrivate, isMe, activeAt, didApproveMe } = conversation;
 | |
|     const isIncomingRequest = hasValidIncomingRequestValues({
 | |
|       isApproved: isApproved || false,
 | |
|       isBlocked: isBlocked || false,
 | |
|       isPrivate: isPrivate || false,
 | |
|       isMe: isMe || false,
 | |
|       activeAt: activeAt || 0,
 | |
|       didApproveMe: didApproveMe || false,
 | |
|     });
 | |
|     return isIncomingRequest;
 | |
|   });
 | |
| };
 | |
| 
 | |
| export const getConversationRequests = createSelector(
 | |
|   getSortedConversations,
 | |
|   _getConversationRequests
 | |
| );
 | |
| 
 | |
| export const getConversationRequestsIds = createSelector(getConversationRequests, requests =>
 | |
|   requests.map(m => m.id)
 | |
| );
 | |
| 
 | |
| export const hasConversationRequests = (state: StateType) => {
 | |
|   return !!getConversationRequests(state).length;
 | |
| };
 | |
| 
 | |
| const _getUnreadConversationRequests = (
 | |
|   sortedConversationRequests: Array<ReduxConversationType>
 | |
| ): Array<ReduxConversationType> => {
 | |
|   return filter(sortedConversationRequests, conversation => {
 | |
|     return Boolean(conversation && conversation.unreadCount && conversation.unreadCount > 0);
 | |
|   });
 | |
| };
 | |
| 
 | |
| export const getUnreadConversationRequests = createSelector(
 | |
|   getConversationRequests,
 | |
|   _getUnreadConversationRequests
 | |
| );
 | |
| 
 | |
| /**
 | |
|  * Returns all the conversation ids of private conversations which are
 | |
|  * - private
 | |
|  * - not me
 | |
|  * - not blocked
 | |
|  * - approved (or message requests are disabled)
 | |
|  * - active_at is set to something truthy
 | |
|  */
 | |
| 
 | |
| export const getLeftPaneConversationIds = createSelector(
 | |
|   getSortedConversations,
 | |
|   _getLeftPaneConversationIds
 | |
| );
 | |
| 
 | |
| const getDirectContacts = createSelector(getSortedConversations, _getPrivateFriendsConversations);
 | |
| 
 | |
| export const getPrivateContactsPubkeys = createSelector(getDirectContacts, state =>
 | |
|   state.map(m => m.id)
 | |
| );
 | |
| 
 | |
| export const getDirectContactsCount = createSelector(
 | |
|   getDirectContacts,
 | |
|   (contacts: Array<ReduxConversationType>) => contacts.length
 | |
| );
 | |
| 
 | |
| export type DirectContactsByNameType = {
 | |
|   displayName?: string;
 | |
|   id: string;
 | |
| };
 | |
| 
 | |
| // make sure that createSelector is called here so this function is memoized
 | |
| export const getDirectContactsByName = createSelector(
 | |
|   getDirectContacts,
 | |
|   (contacts: Array<ReduxConversationType>): Array<DirectContactsByNameType> => {
 | |
|     const us = UserUtils.getOurPubKeyStrFromCache();
 | |
|     const extractedContacts = contacts
 | |
|       .filter(m => m.id !== us)
 | |
|       .map(m => {
 | |
|         return {
 | |
|           id: m.id,
 | |
|           displayName: m.nickname || m.displayNameInProfile,
 | |
|         };
 | |
|       });
 | |
|     const extractedContactsNoDisplayName = sortBy(
 | |
|       extractedContacts.filter(m => !m.displayName),
 | |
|       'id'
 | |
|     );
 | |
|     const extractedContactsWithDisplayName = sortBy(
 | |
|       extractedContacts.filter(m => Boolean(m.displayName)),
 | |
|       'displayName'
 | |
|     );
 | |
| 
 | |
|     return [...extractedContactsWithDisplayName, ...extractedContactsNoDisplayName];
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getGlobalUnreadMessageCount = createSelector(
 | |
|   getSortedConversations,
 | |
|   _getGlobalUnreadCount
 | |
| );
 | |
| 
 | |
| export const isMessageDetailView = (state: StateType): boolean =>
 | |
|   state.conversations.messageDetailProps !== undefined;
 | |
| 
 | |
| export const getMessageDetailsViewProps = (state: StateType): MessagePropsDetails | undefined =>
 | |
|   state.conversations.messageDetailProps;
 | |
| 
 | |
| export const isRightPanelShowing = (state: StateType): boolean =>
 | |
|   state.conversations.showRightPanel;
 | |
| 
 | |
| export const isMessageSelectionMode = (state: StateType): boolean =>
 | |
|   state.conversations.selectedMessageIds.length > 0;
 | |
| 
 | |
| export const getSelectedMessageIds = (state: StateType): Array<string> =>
 | |
|   state.conversations.selectedMessageIds;
 | |
| 
 | |
| export const getIsMessageSelectionMode = (state: StateType): boolean =>
 | |
|   Boolean(getSelectedMessageIds(state).length);
 | |
| 
 | |
| export const getLightBoxOptions = (state: StateType): LightBoxOptions | undefined =>
 | |
|   state.conversations.lightBox;
 | |
| 
 | |
| export const getQuotedMessage = (state: StateType): ReplyingToMessageProps | undefined =>
 | |
|   state.conversations.quotedMessage;
 | |
| 
 | |
| export const areMoreMessagesBeingFetched = (state: StateType): boolean =>
 | |
|   state.conversations.areMoreMessagesBeingFetched || false;
 | |
| 
 | |
| export const getShowScrollButton = (state: StateType): boolean =>
 | |
|   state.conversations.showScrollButton || false;
 | |
| 
 | |
| export const getQuotedMessageToAnimate = (state: StateType): string | undefined =>
 | |
|   state.conversations.animateQuotedMessageId || undefined;
 | |
| 
 | |
| export const getShouldHighlightMessage = (state: StateType): boolean =>
 | |
|   Boolean(state.conversations.animateQuotedMessageId && state.conversations.shouldHighlightMessage);
 | |
| 
 | |
| export const getNextMessageToPlayId = (state: StateType): string | undefined =>
 | |
|   state.conversations.nextMessageToPlayId || undefined;
 | |
| 
 | |
| export const getMentionsInput = (state: StateType): MentionsMembersType =>
 | |
|   state.conversations.mentionMembers;
 | |
| 
 | |
| /// Those calls are just related to ordering messages in the redux store.
 | |
| 
 | |
| function updateFirstMessageOfSeries(
 | |
|   messageModelsProps: Array<MessageModelPropsWithoutConvoProps>
 | |
| ): Array<SortedMessageModelProps> {
 | |
|   // 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<SortedMessageModelProps> = [];
 | |
| 
 | |
|   for (let i = 0; i < messageModelsProps.length; i++) {
 | |
|     const currentSender = messageModelsProps[i].propsForMessage?.sender;
 | |
|     // most recent message is at index 0, so the previous message sender is 1+index
 | |
|     const previousSender =
 | |
|       i < messageModelsProps.length - 1
 | |
|         ? messageModelsProps[i + 1].propsForMessage?.sender
 | |
|         : undefined;
 | |
|     const nextSender = i > 0 ? messageModelsProps[i - 1].propsForMessage?.sender : undefined;
 | |
|     // Handle firstMessageOfSeries for conditional avatar rendering
 | |
| 
 | |
|     sortedMessageProps.push({
 | |
|       ...messageModelsProps[i],
 | |
|       firstMessageOfSeries: !(i >= 0 && currentSender === previousSender),
 | |
|       lastMessageOfSeries: currentSender !== nextSender,
 | |
|     });
 | |
|   }
 | |
|   return sortedMessageProps;
 | |
| }
 | |
| 
 | |
| function sortMessages(
 | |
|   messages: Array<MessageModelPropsWithoutConvoProps>,
 | |
|   isPublic: boolean
 | |
| ): Array<MessageModelPropsWithoutConvoProps> {
 | |
|   // 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;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This returns the most recent message id in the database. This is not the most recent message shown,
 | |
|  * but the most recent one, which could still not be loaded.
 | |
|  */
 | |
| export const getMostRecentMessageId = (state: StateType): string | null => {
 | |
|   return state.conversations.mostRecentMessageId;
 | |
| };
 | |
| 
 | |
| export const getOldestMessageId = createSelector(
 | |
|   getSortedMessagesOfSelectedConversation,
 | |
|   (messages: Array<MessageModelPropsWithoutConvoProps>): string | undefined => {
 | |
|     const oldest =
 | |
|       messages.length > 0 ? messages[messages.length - 1].propsForMessage.id : undefined;
 | |
| 
 | |
|     return oldest;
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getYoungestMessageId = createSelector(
 | |
|   getSortedMessagesOfSelectedConversation,
 | |
|   (messages: Array<MessageModelPropsWithoutConvoProps>): string | undefined => {
 | |
|     const youngest = messages.length > 0 ? messages[0].propsForMessage.id : undefined;
 | |
| 
 | |
|     return youngest;
 | |
|   }
 | |
| );
 | |
| 
 | |
| function getMessagesFromState(state: StateType) {
 | |
|   return state.conversations.messages;
 | |
| }
 | |
| 
 | |
| export function getLoadedMessagesLength(state: StateType) {
 | |
|   return getMessagesFromState(state).length;
 | |
| }
 | |
| 
 | |
| export function getSelectedHasMessages(state: StateType): boolean {
 | |
|   return !isEmpty(getMessagesFromState(state));
 | |
| }
 | |
| 
 | |
| 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;
 | |
|   }
 | |
| );
 | |
| 
 | |
| const getMessageId = (_whatever: any, id: string | undefined) => id;
 | |
| 
 | |
| /**
 | |
|  * A lot of our UI changes on the main panel need to happen quickly (composition box).
 | |
|  */
 | |
| export const getSelectedConversation = createSelector(
 | |
|   getConversationLookup,
 | |
|   getSelectedConversationKey,
 | |
|   (lookup, selectedConvo) => {
 | |
|     return selectedConvo ? lookup[selectedConvo] : undefined;
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getMessagePropsByMessageId = createSelector(
 | |
|   getSortedMessagesOfSelectedConversation,
 | |
|   getSelectedConversation,
 | |
|   getMessageId,
 | |
|   (
 | |
|     messages: Array<SortedMessageModelProps>,
 | |
|     selectedConvo,
 | |
|     id
 | |
|   ): MessageModelPropsWithConvoProps | undefined => {
 | |
|     if (!id) {
 | |
|       return undefined;
 | |
|     }
 | |
|     const foundMessageProps: SortedMessageModelProps | undefined = messages?.find(
 | |
|       m => m?.propsForMessage?.id === id
 | |
|     );
 | |
| 
 | |
|     if (!foundMessageProps || !foundMessageProps.propsForMessage.convoId) {
 | |
|       return undefined;
 | |
|     }
 | |
|     const sender = foundMessageProps?.propsForMessage?.sender;
 | |
| 
 | |
|     // we can only show messages when the convo is selected.
 | |
|     if (!selectedConvo || !sender) {
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     const ourPubkey = UserUtils.getOurPubKeyStrFromCache();
 | |
|     const isGroup = !selectedConvo.isPrivate;
 | |
|     const isPublic = selectedConvo.isPublic;
 | |
| 
 | |
|     const groupAdmins = (isGroup && selectedConvo.groupAdmins) || [];
 | |
|     const weAreAdmin = groupAdmins.includes(ourPubkey) || false;
 | |
| 
 | |
|     const weAreModerator =
 | |
|       (isPublic && getModeratorsOutsideRedux(selectedConvo.id).includes(ourPubkey)) || false;
 | |
|     // A message is deletable if
 | |
|     // either we sent it,
 | |
|     // or the convo is not a public one (in this case, we will only be able to delete for us)
 | |
|     // or the convo is public and we are an admin or moderator
 | |
|     const isDeletable =
 | |
|       sender === ourPubkey || !isPublic || (isPublic && (weAreAdmin || weAreModerator));
 | |
| 
 | |
|     // A message is deletable for everyone if
 | |
|     // either we sent it no matter what the conversation type,
 | |
|     // or the convo is public and we are an admin or moderator
 | |
|     const isDeletableForEveryone =
 | |
|       sender === ourPubkey || (isPublic && (weAreAdmin || weAreModerator)) || false;
 | |
| 
 | |
|     const isSenderAdmin = groupAdmins.includes(sender);
 | |
| 
 | |
|     const messageProps: MessageModelPropsWithConvoProps = {
 | |
|       ...foundMessageProps,
 | |
|       propsForMessage: {
 | |
|         ...foundMessageProps.propsForMessage,
 | |
|         isBlocked: !!selectedConvo.isBlocked,
 | |
|         isPublic: !!isPublic,
 | |
|         isSenderAdmin,
 | |
|         isDeletable,
 | |
|         isDeletableForEveryone,
 | |
|         weAreAdmin,
 | |
|         conversationType: selectedConvo.type,
 | |
|         sender,
 | |
|         isKickedFromGroup: selectedConvo.isKickedFromGroup || false,
 | |
|       },
 | |
|     };
 | |
| 
 | |
|     return messageProps;
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getMessageReactsProps = createSelector(getMessagePropsByMessageId, (props):
 | |
|   | MessageReactsSelectorProps
 | |
|   | undefined => {
 | |
|   if (!props || isEmpty(props)) {
 | |
|     return undefined;
 | |
|   }
 | |
| 
 | |
|   const msgProps: MessageReactsSelectorProps = pick(props.propsForMessage, [
 | |
|     'convoId',
 | |
|     'conversationType',
 | |
|     'reacts',
 | |
|     'serverId',
 | |
|   ]);
 | |
| 
 | |
|   if (msgProps.reacts) {
 | |
|     // NOTE we don't want to render reactions that have 'senders' as an object this is a deprecated type used during development 25/08/2022
 | |
|     const oldReactions = Object.values(msgProps.reacts).filter(
 | |
|       reaction => !Array.isArray(reaction.senders)
 | |
|     );
 | |
| 
 | |
|     if (oldReactions.length > 0) {
 | |
|       msgProps.reacts = undefined;
 | |
|       return msgProps;
 | |
|     }
 | |
| 
 | |
|     const sortedReacts = Object.entries(msgProps.reacts).sort((a, b) => {
 | |
|       return a[1].index < b[1].index ? -1 : a[1].index > b[1].index ? 1 : 0;
 | |
|     });
 | |
|     msgProps.sortedReacts = sortedReacts;
 | |
|   }
 | |
| 
 | |
|   return msgProps;
 | |
| });
 | |
| 
 | |
| export const getMessageQuoteProps = createSelector(
 | |
|   getConversationLookup,
 | |
|   getMessagesOfSelectedConversation,
 | |
|   getConversationQuotes,
 | |
|   getMessagePropsByMessageId,
 | |
|   (
 | |
|     conversationLookup,
 | |
|     messagesProps,
 | |
|     quotesProps,
 | |
|     msgGlobalProps
 | |
|   ): { quote: PropsForQuote } | undefined => {
 | |
|     if (!msgGlobalProps || isEmpty(msgGlobalProps)) {
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     const msgProps = msgGlobalProps.propsForMessage;
 | |
| 
 | |
|     if (!msgProps.quote || isEmpty(msgProps.quote)) {
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     const { id } = msgProps.quote;
 | |
|     let { author } = msgProps.quote;
 | |
| 
 | |
|     if (!id || !author) {
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     const isFromMe = isUsAnySogsFromCache(author) || false;
 | |
| 
 | |
|     // NOTE the quote lookup map always stores our messages using the unblinded pubkey
 | |
|     if (isFromMe && PubKey.isBlinded(author)) {
 | |
|       author = UserUtils.getOurPubKeyStrFromCache();
 | |
|     }
 | |
| 
 | |
|     // NOTE: if the message is not found, we still want to render the quote
 | |
|     const quoteNotFound = {
 | |
|       quote: {
 | |
|         id,
 | |
|         author,
 | |
|         isFromMe,
 | |
|         referencedMessageNotFound: true,
 | |
|       },
 | |
|     };
 | |
| 
 | |
|     if (!quotesProps || isEmpty(quotesProps)) {
 | |
|       return quoteNotFound;
 | |
|     }
 | |
| 
 | |
|     const sourceMessage = lookupQuote(quotesProps, messagesProps, toNumber(id), author);
 | |
|     if (!sourceMessage) {
 | |
|       return quoteNotFound;
 | |
|     }
 | |
| 
 | |
|     const sourceMsgProps = sourceMessage.propsForMessage;
 | |
|     if (!sourceMsgProps || sourceMsgProps.isDeleted) {
 | |
|       return quoteNotFound;
 | |
|     }
 | |
| 
 | |
|     const convo = conversationLookup[sourceMsgProps.convoId];
 | |
|     if (!convo) {
 | |
|       return quoteNotFound;
 | |
|     }
 | |
| 
 | |
|     const attachment = sourceMsgProps.attachments && sourceMsgProps.attachments[0];
 | |
| 
 | |
|     const quote: PropsForQuote = {
 | |
|       text: sourceMsgProps.text,
 | |
|       attachment: attachment ? processQuoteAttachment(attachment) : undefined,
 | |
|       isFromMe,
 | |
|       author: sourceMsgProps.sender,
 | |
|       id: sourceMsgProps.id,
 | |
|       referencedMessageNotFound: false,
 | |
|       convoId: convo.id,
 | |
|     };
 | |
| 
 | |
|     return {
 | |
|       quote,
 | |
|     };
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (props):
 | |
|   | MessageTextSelectorProps
 | |
|   | undefined => {
 | |
|   if (!props || isEmpty(props)) {
 | |
|     return undefined;
 | |
|   }
 | |
| 
 | |
|   const msgProps: MessageTextSelectorProps = pick(props.propsForMessage, [
 | |
|     'direction',
 | |
|     'status',
 | |
|     'text',
 | |
|     'isDeleted',
 | |
|     'conversationType',
 | |
|   ]);
 | |
| 
 | |
|   return msgProps;
 | |
| });
 | |
| 
 | |
| export const getMessageAttachmentProps = createSelector(getMessagePropsByMessageId, (props):
 | |
|   | MessageAttachmentSelectorProps
 | |
|   | undefined => {
 | |
|   if (!props || isEmpty(props)) {
 | |
|     return undefined;
 | |
|   }
 | |
| 
 | |
|   const msgProps: MessageAttachmentSelectorProps = {
 | |
|     attachments: props.propsForMessage.attachments || [],
 | |
|     ...pick(props.propsForMessage, [
 | |
|       'direction',
 | |
|       'isTrustedForAttachmentDownload',
 | |
|       'timestamp',
 | |
|       'serverTimestamp',
 | |
|       'sender',
 | |
|       'convoId',
 | |
|     ]),
 | |
|   };
 | |
| 
 | |
|   return msgProps;
 | |
| });
 | |
| 
 | |
| export const getIsMessageSelected = createSelector(
 | |
|   getMessagePropsByMessageId,
 | |
|   getSelectedMessageIds,
 | |
|   (props, selectedIds): boolean => {
 | |
|     if (!props || isEmpty(props)) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     const { id } = props.propsForMessage;
 | |
| 
 | |
|     return selectedIds.includes(id);
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getMessageContentSelectorProps = createSelector(getMessagePropsByMessageId, (props):
 | |
|   | MessageContentSelectorProps
 | |
|   | undefined => {
 | |
|   if (!props || isEmpty(props)) {
 | |
|     return undefined;
 | |
|   }
 | |
| 
 | |
|   const msgProps: MessageContentSelectorProps = {
 | |
|     ...pick(props.propsForMessage, [
 | |
|       'direction',
 | |
|       'serverTimestamp',
 | |
|       'text',
 | |
|       'timestamp',
 | |
|       'previews',
 | |
|       'quote',
 | |
|       'attachments',
 | |
|     ]),
 | |
|   };
 | |
| 
 | |
|   return msgProps;
 | |
| });
 | |
| 
 | |
| export const getMessageContentWithStatusesSelectorProps = createSelector(
 | |
|   getMessagePropsByMessageId,
 | |
|   (props): MessageContentWithStatusSelectorProps | undefined => {
 | |
|     if (!props || isEmpty(props)) {
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     const isGroup =
 | |
|       props.propsForMessage.conversationType !== 'private' && !props.propsForMessage.isPublic;
 | |
| 
 | |
|     const msgProps: MessageContentWithStatusSelectorProps = {
 | |
|       ...pick(props.propsForMessage, ['conversationType', 'direction', 'isDeleted']),
 | |
|       isGroup,
 | |
|     };
 | |
| 
 | |
|     return msgProps;
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getGenericReadableMessageSelectorProps = createSelector(
 | |
|   getMessagePropsByMessageId,
 | |
|   (props): GenericReadableMessageSelectorProps | undefined => {
 | |
|     if (!props || isEmpty(props)) {
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     const msgProps: GenericReadableMessageSelectorProps = pick(props.propsForMessage, [
 | |
|       'convoId',
 | |
|       'direction',
 | |
|       'conversationType',
 | |
|       'expirationDurationMs',
 | |
|       'expirationTimestamp',
 | |
|       'isExpired',
 | |
|       'isUnread',
 | |
|       'receivedAt',
 | |
|       'isKickedFromGroup',
 | |
|       'isDeleted',
 | |
|     ]);
 | |
| 
 | |
|     return msgProps;
 | |
|   }
 | |
| );
 | |
| 
 | |
| export const getOldTopMessageId = (state: StateType): string | null =>
 | |
|   state.conversations.oldTopMessageId || null;
 | |
| 
 | |
| export const getOldBottomMessageId = (state: StateType): string | null =>
 | |
|   state.conversations.oldBottomMessageId || null;
 | |
| 
 | |
| export const getIsSelectedConvoInitialLoadingInProgress = (state: StateType): boolean =>
 | |
|   Boolean(getSelectedConversation(state)?.isInitialFetchingInProgress);
 | |
| 
 | |
| export function getCurrentlySelectedConversationOutsideRedux() {
 | |
|   return window?.inboxStore?.getState().conversations.selectedConversation as string | undefined;
 | |
| }
 |