diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index e314c0a67..e059d91b7 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,754 +1,736 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import uuid from 'uuid'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadIsGroupChat } from './thread-utils.js'; import { useStringForUser } from '../hooks/ens-cache.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { contentStringForMediaArray } from '../media/media-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors.js'; import { isWebPlatform, type PlatformDetails } from '../types/device-types.js'; import type { Media } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type ComposableMessageInfo, type MessageData, type MessageInfo, type MessageStore, type MessageTruncationStatus, messageTruncationStatus, type MultimediaMessageData, type RawComposableMessageInfo, type RawMessageInfo, type RawMultimediaMessageInfo, type RobotextMessageInfo, type ThreadMessageInfo, } from '../types/message-types.js'; import type { EditMessageInfo, RawEditMessageInfo, } from '../types/messages/edit.js'; import type { ImagesMessageData } from '../types/messages/images.js'; import type { MediaMessageData } from '../types/messages/media.js'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { LegacyRawThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { type EntityText, ET, useEntityTextAsString, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; const localIDPrefix = 'local'; const defaultMediaMessageOptions = Object.freeze({}); // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ?ThreadInfo, parentThreadInfo: ?ThreadInfo, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo, parentThreadInfo }); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { +[id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; const creator = { id: rawMessageInfo.creatorID, username: creatorInfo ? creatorInfo.username : 'anonymous', isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } -type LengthResult = { - +local: number, - +realized: number, -}; -function findMessageIDMaxLengths( - messageIDs: $ReadOnlyArray, -): LengthResult { - const result = { - local: 0, - realized: 0, - }; +function findMessageIDMaxLength(messageIDs: $ReadOnlyArray): number { + let result = 0; for (const id of messageIDs) { - if (!id) { + if (!id || id.startsWith(localIDPrefix)) { continue; } - if (id.startsWith(localIDPrefix)) { - result.local = Math.max(result.local, id.length - localIDPrefix.length); - } else { - result.realized = Math.max(result.realized, id.length); - } + + result = Math.max(result, id.length); } return result; } -function extendMessageID(id: ?string, lengths: LengthResult): ?string { - if (!id) { +function extendMessageID(id: ?string, length: number): ?string { + if (!id || id.startsWith(localIDPrefix)) { return id; } - if (id.startsWith(localIDPrefix)) { - const zeroPaddedID = id - .substr(localIDPrefix.length) - .padStart(lengths.local, '0'); - return `${localIDPrefix}${zeroPaddedID}`; - } - return id.padStart(lengths.realized, '0'); + return id.padStart(length, '0'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { - const lengths = findMessageIDMaxLengths( - messageInfos.map(message => message?.id ?? message?.localID), + const length = findMessageIDMaxLength( + messageInfos.map(message => message?.id), ); return _orderBy([ 'time', - (message: T) => extendMessageID(message?.id ?? message?.localID, lengths), + (message: T) => extendMessageID(message?.id ?? message?.localID, length), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo, }) => (messageIDs: $ReadOnlyArray) => string[] = messages => messageIDs => { - const lengths = findMessageIDMaxLengths(messageIDs); + const length = findMessageIDMaxLength(messageIDs); return _orderBy([ (id: string) => messages[id].time, - (id: string) => extendMessageID(id, lengths), + (id: string) => extendMessageID(id, length), ])(['desc', 'desc'])(messageIDs); }; function rawMessageInfoFromMessageData( messageData: MessageData, id: ?string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && isWebPlatform(platformDetails.platform)) { return [...rawMessageInfos]; } return rawMessageInfos.map(rawMessageInfo => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = { +threadID: string, +creatorID: string, +media: $ReadOnlyArray, +localID?: ?string, +time?: ?number, +sidebarCreation?: ?boolean, ... }; function createMediaMessageData( input: MediaMessageDataCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const singleMedia of input.media) { if (singleMedia.type !== 'photo') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID, sidebarCreation } = input; const { forceMultimediaMessageType = false } = options; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos && !forceMultimediaMessageType) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } return messageData; } type MediaMessageInfoCreationInput = { ...$Exact, +id?: ?string, }; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input, options); const createRawMessageInfo = messageSpecs[messageData.type].rawMessageInfoFromMessageData; invariant( createRawMessageInfo, 'multimedia message spec should have rawMessageInfoFromMessageData', ); const result = createRawMessageInfo(messageData, input.id); invariant( result.type === messageTypes.MULTIMEDIA || result.type === messageTypes.IMAGES, `media messageSpec returned MessageType ${result.type}`, ); return result; } function stripLocalID( rawMessageInfo: | RawComposableMessageInfo | RawReactionMessageInfo | RawEditMessageInfo, ) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string): string { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageQuote(message: string): string { // add `>` to each line to include empty lines in the quote return message.replace(/^/gm, '> '); } function createMessageReply(message: string): string { return createMessageQuote(message) + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find(id => !id.startsWith(localIDPrefix)); } function getOldestNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; if (!thread) { return thread; } const { messageIDs } = thread; for (let i = messageIDs.length - 1; i >= 0; i--) { const id = messageIDs[i]; if (!id.startsWith(localIDPrefix)) { return id; } } return undefined; } function getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo | EditMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, markdownRules: ParserRules, ): EntityText { const { messageTitle } = messageSpecs[messageInfo.type]; if (messageTitle) { return messageTitle({ messageInfo, threadInfo, markdownRules }); } invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA && messageInfo.type !== messageTypes.REACTION && messageInfo.type !== messageTypes.EDIT_MESSAGE, 'messageTitle can only be auto-generated for RobotextMessageInfo', ); return robotextForMessageInfo(messageInfo, threadInfo, parentThreadInfo); } function mergeThreadMessageInfos( first: ThreadMessageInfo, second: ThreadMessageInfo, messages: { +[id: string]: RawMessageInfo }, ): ThreadMessageInfo { let firstPointer = 0; let secondPointer = 0; const mergedMessageIDs = []; let firstCandidate = first.messageIDs[firstPointer]; let secondCandidate = second.messageIDs[secondPointer]; while (firstCandidate !== undefined || secondCandidate !== undefined) { if (firstCandidate === undefined) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else if (secondCandidate === undefined) { mergedMessageIDs.push(firstCandidate); firstPointer++; } else if (firstCandidate === secondCandidate) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else { const firstMessage = messages[firstCandidate]; const secondMessage = messages[secondCandidate]; invariant( firstMessage && secondMessage, 'message in messageIDs not present in MessageStore', ); if ( (firstMessage.id && secondMessage.id && firstMessage.id === secondMessage.id) || (firstMessage.localID && secondMessage.localID && firstMessage.localID === secondMessage.localID) ) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else if (firstMessage.time < secondMessage.time) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else { mergedMessageIDs.push(firstCandidate); firstPointer++; } } firstCandidate = first.messageIDs[firstPointer]; secondCandidate = second.messageIDs[secondPointer]; } return { messageIDs: mergedMessageIDs, startReached: first.startReached && second.startReached, }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; export type MessagePreviewResult = { +message: MessagePreviewPart, +username: ?MessagePreviewPart, }; function useMessagePreview( originalMessageInfo: ?MessageInfo, threadInfo: ThreadInfo, markdownRules: ParserRules, ): ?MessagePreviewResult { let messageInfo; if ( originalMessageInfo && originalMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { messageInfo = originalMessageInfo.sourceMessage; } else { messageInfo = originalMessageInfo; } const { parentThreadID } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const hasUsername = threadIsGroupChat(threadInfo) || threadInfo.name !== '' || messageInfo?.creator.isViewer; const shouldDisplayUser = messageInfo?.type === messageTypes.TEXT && hasUsername; const stringForUser = useStringForUser( shouldDisplayUser ? messageInfo?.creator : null, ); const { unread } = threadInfo.currentUser; const username = React.useMemo(() => { if (!shouldDisplayUser) { return null; } invariant( stringForUser, 'useStringForUser should only return falsey if pass null or undefined', ); return { text: stringForUser, style: unread ? 'unread' : 'secondary', }; }, [shouldDisplayUser, stringForUser, unread]); const messageTitleEntityText = React.useMemo(() => { if (!messageInfo) { return messageInfo; } return getMessageTitle( messageInfo, threadInfo, parentThreadInfo, markdownRules, ); }, [messageInfo, threadInfo, parentThreadInfo, markdownRules]); const threadID = threadInfo.id; const entityTextToStringParams = React.useMemo( () => ({ threadID, }), [threadID], ); const messageTitle = useEntityTextAsString( messageTitleEntityText, entityTextToStringParams, ); const isTextMessage = messageInfo?.type === messageTypes.TEXT; const message = React.useMemo(() => { if (messageTitle === null || messageTitle === undefined) { return messageTitle; } let style; if (unread) { style = 'unread'; } else if (isTextMessage) { style = 'primary'; } else { style = 'secondary'; } return { text: messageTitle, style }; }, [messageTitle, unread, isTextMessage]); return React.useMemo(() => { if (!message) { return message; } return { message, username }; }, [message, username]); } function useMessageCreationSideEffectsFunc( messageType: $PropertyType, ): CreationSideEffectsFunc { const messageSpec = messageSpecs[messageType]; invariant(messageSpec, `we're not aware of messageType ${messageType}`); invariant( messageSpec.useCreationSideEffectsFunc, `no useCreationSideEffectsFunc in message spec for ${messageType}`, ); return messageSpec.useCreationSideEffectsFunc(); } function getPinnedContentFromMessage(targetMessage: RawMessageInfo): string { let pinnedContent; if ( targetMessage.type === messageTypes.IMAGES || targetMessage.type === messageTypes.MULTIMEDIA ) { pinnedContent = contentStringForMediaArray(targetMessage.media); } else { pinnedContent = 'a message'; } return pinnedContent; } function modifyItemForResultScreen( item: ChatMessageInfoItem, ): ChatMessageInfoItem { if (item.messageInfoType === 'composable') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return item; } function constructChangeRoleEntityText( affectedUsers: EntityText | string, roleName: ?string, ): EntityText { if (!roleName) { return ET`assigned ${affectedUsers} a new role`; } return ET`assigned ${affectedUsers} the "${roleName}" role`; } function getNextLocalID(): string { const nextLocalID = uuid.v4(); return `${localIDPrefix}${nextLocalID}`; } function isInvalidSidebarSource( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( (message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE || message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.TOGGLE_PIN) && !messageSpecs[message.type].canBeSidebarSource ); } // Prefer checking isInvalidPinSourceForThread below. This function doesn't // check whether the user is attempting to pin a SIDEBAR_SOURCE in the context // of its parent thread, so it's not suitable for permission checks. We only // use it in the message-fetchers.js code where we don't have access to the // RawThreadInfo and don't need to do permission checks. function isInvalidPinSource( messageInfo: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[messageInfo.type].canBePinned; } function isInvalidPinSourceForThread( messageInfo: RawMessageInfo | MessageInfo, threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): boolean { const isValidPinSource = !isInvalidPinSource(messageInfo); const isFirstMessageInSidebar = threadInfo.sourceMessageID === messageInfo.id; return !isValidPinSource || isFirstMessageInSidebar; } function isUnableToBeRenderedIndependently( message: RawMessageInfo | MessageInfo, ): boolean { return messageSpecs[message.type].canBeRenderedIndependently === false; } function findNewestMessageTimePerKeyserver( messageInfos: $ReadOnlyArray, ): { [keyserverID: string]: number } { const timePerKeyserver: { [keyserverID: string]: number } = {}; for (const messageInfo of messageInfos) { const keyserverID = extractKeyserverIDFromID(messageInfo.threadID); if ( !timePerKeyserver[keyserverID] || timePerKeyserver[keyserverID] < messageInfo.time ) { timePerKeyserver[keyserverID] = messageInfo.time; } } return timePerKeyserver; } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getOldestNonLocalMessageID, getMessageTitle, mergeThreadMessageInfos, useMessagePreview, useMessageCreationSideEffectsFunc, getPinnedContentFromMessage, modifyItemForResultScreen, constructChangeRoleEntityText, getNextLocalID, isInvalidSidebarSource, isInvalidPinSource, isInvalidPinSourceForThread, isUnableToBeRenderedIndependently, findNewestMessageTimePerKeyserver, }; diff --git a/lib/shared/message-utils.test.js b/lib/shared/message-utils.test.js index 216c582c7..273e48028 100644 --- a/lib/shared/message-utils.test.js +++ b/lib/shared/message-utils.test.js @@ -1,986 +1,1026 @@ // @flow import { isInvalidSidebarSource, isInvalidPinSource, isUnableToBeRenderedIndependently, findNewestMessageTimePerKeyserver, sortMessageInfoList, sortMessageIDs, } from './message-utils.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import type { RawMessageInfo, RawSidebarSourceMessageInfo, } from '../types/message-types'; import { messageTypes } from '../types/message-types-enum.js'; import type { RawAddMembersMessageInfo } from '../types/messages/add-members.js'; import type { RawChangeRoleMessageInfo } from '../types/messages/change-role.js'; import type { RawChangeSettingsMessageInfo } from '../types/messages/change-settings.js'; import type { RawCreateEntryMessageInfo } from '../types/messages/create-entry.js'; import type { RawCreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { RawCreateSubthreadMessageInfo } from '../types/messages/create-subthread.js'; import type { RawCreateThreadMessageInfo } from '../types/messages/create-thread.js'; import type { RawDeleteEntryMessageInfo } from '../types/messages/delete-entry.js'; import type { RawEditEntryMessageInfo } from '../types/messages/edit-entry.js'; import type { RawEditMessageInfo } from '../types/messages/edit.js'; import type { RawImagesMessageInfo } from '../types/messages/images.js'; import type { RawJoinThreadMessageInfo } from '../types/messages/join-thread.js'; import type { RawLeaveThreadMessageInfo } from '../types/messages/leave-thread.js'; import type { RawLegacyUpdateRelationshipMessageInfo } from '../types/messages/legacy-update-relationship.js'; import type { RawMediaMessageInfo } from '../types/messages/media.js'; import type { RawReactionMessageInfo } from '../types/messages/reaction.js'; import type { RawRemoveMembersMessageInfo } from '../types/messages/remove-members.js'; import type { RawRestoreEntryMessageInfo } from '../types/messages/restore-entry.js'; import type { RawTextMessageInfo } from '../types/messages/text.js'; import type { RawTogglePinMessageInfo } from '../types/messages/toggle-pin.js'; import { threadTypes } from '../types/thread-types-enum.js'; const textMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, localID: 'local1', threadID: '10001', creatorID: '123', time: 10000, text: 'This is a text message', id: '1', }; const createThreadMessageInfo: RawCreateThreadMessageInfo = { type: 1, threadID: '10001', creatorID: '123', time: 10000, initialThreadState: { type: threadTypes.COMMUNITY_ROOT, name: 'Random Thread', parentThreadID: '10000', color: '#FFFFFF', memberIDs: ['1', '2', '3'], }, id: '1', }; const addMembersMessageInfo: RawAddMembersMessageInfo = { type: messageTypes.ADD_MEMBERS, threadID: '10001', creatorID: '123', time: 10000, addedUserIDs: ['4', '5'], id: '1', }; const createSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { type: messageTypes.CREATE_SUB_THREAD, threadID: '10001', creatorID: '123', time: 10000, childThreadID: '10002', id: '1', }; const changeSettingsMessageInfo: RawChangeSettingsMessageInfo = { type: messageTypes.CHANGE_SETTINGS, threadID: '10000', creatorID: '123', time: 10000, field: 'color', value: '#FFFFFF', id: '1', }; const removeMembersMessageInfo: RawRemoveMembersMessageInfo = { type: messageTypes.REMOVE_MEMBERS, threadID: '10000', creatorID: '123', time: 10000, removedUserIDs: ['1', '2', '3'], id: '1', }; const changeRoleMessageinfo: RawChangeRoleMessageInfo = { type: messageTypes.CHANGE_ROLE, threadID: '10000', creatorID: '123', time: 10000, userIDs: ['1', '2', '3'], newRole: '101', roleName: 'Moderators', id: '1', }; const leaveThreadMessageInfo: RawLeaveThreadMessageInfo = { type: messageTypes.LEAVE_THREAD, threadID: '10000', creatorID: '123', time: 10000, id: '1', }; const joinThreadMessageInfo: RawJoinThreadMessageInfo = { type: messageTypes.JOIN_THREAD, threadID: '10000', creatorID: '123', time: 10000, id: '1', }; const createEntryMessageInfo: RawCreateEntryMessageInfo = { type: messageTypes.CREATE_ENTRY, threadID: '10000', creatorID: '123', time: 10000, entryID: '001', date: '2018-01-01', text: 'This is a calendar entry', id: '1', }; const editEntryMessageInfo: RawEditEntryMessageInfo = { type: messageTypes.EDIT_ENTRY, threadID: '10000', creatorID: '123', time: 10000, entryID: '001', date: '2018-01-01', text: 'This is an edited calendar entry', id: '1', }; const deleteEntryMessageInfo: RawDeleteEntryMessageInfo = { type: messageTypes.DELETE_ENTRY, threadID: '10000', creatorID: '123', time: 10000, entryID: '001', date: '2018-01-01', text: 'This is a deleted calendar entry', id: '1', }; const restoreEntryMessageInfo: RawRestoreEntryMessageInfo = { type: messageTypes.RESTORE_ENTRY, threadID: '10000', creatorID: '123', time: 10000, entryID: '001', date: '2018-01-01', text: 'This is a restored calendar entry', id: '1', }; const updateRelationshipMessageInfo: RawLegacyUpdateRelationshipMessageInfo = { type: messageTypes.LEGACY_UPDATE_RELATIONSHIP, threadID: '10000', creatorID: '123', targetID: '456', time: 10000, operation: 'request_sent', id: '1', }; const imageMessageInfo: RawImagesMessageInfo = { type: messageTypes.IMAGES, localID: 'local1', threadID: '10001', creatorID: '123', time: 10000, media: [ { id: '1', uri: 'https://example.com/image1.jpg', type: 'photo', dimensions: { height: 100, width: 100, }, thumbHash: '1234567890', }, ], id: '1', }; const mediaMessageInfo: RawMediaMessageInfo = { type: messageTypes.MULTIMEDIA, localID: 'local1', threadID: '10001', creatorID: '123', time: 10000, media: [ { id: '1', uri: 'https://example.com/image1.jpg', type: 'photo', dimensions: { height: 100, width: 100, }, thumbHash: '1234567890', }, ], id: '1', }; const sidebarSourceMessageInfo: RawSidebarSourceMessageInfo = { type: messageTypes.SIDEBAR_SOURCE, threadID: '10000', creatorID: '123', time: 10000, sourceMessage: { type: messageTypes.TEXT, localID: 'local1', threadID: '10001', creatorID: '123', time: 10000, text: 'This is a text message', id: '1', }, id: '1', }; const createSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: messageTypes.CREATE_SIDEBAR, threadID: '10000', creatorID: '123', time: 10000, sourceMessageAuthorID: '123', initialThreadState: { name: 'Random Thread', parentThreadID: '10000', color: '#FFFFFF', memberIDs: ['1', '2', '3'], }, id: '1', }; const reactionMessageInfo: RawReactionMessageInfo = { type: messageTypes.REACTION, localID: 'local1', threadID: '10001', creatorID: '123', time: 10000, targetMessageID: '1', reaction: 'like', action: 'add_reaction', id: '1', }; const editMessageInfo: RawEditMessageInfo = { type: messageTypes.EDIT_MESSAGE, threadID: '10000', creatorID: '123', time: 10000, targetMessageID: '1', text: 'This is an edited message', id: '1', }; const togglePinMessageInfo: RawTogglePinMessageInfo = { type: messageTypes.TOGGLE_PIN, threadID: '10000', targetMessageID: '1', action: 'pin', pinnedContent: 'a message', creatorID: '123', time: 10000, id: '1', }; describe('isInvalidSidebarSource & canBeSidebarSource', () => { it('should return false for RawTextMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.TEXT]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(textMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawCreateThreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_THREAD]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( createThreadMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawAddMembersMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.ADD_MEMBERS]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( addMembersMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawCreateSubthreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_SUB_THREAD]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( createSubthreadMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawChangeSettingsMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CHANGE_SETTINGS]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( changeSettingsMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawRemoveMembersMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.REMOVE_MEMBERS]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( removeMembersMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawChangeRoleMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CHANGE_ROLE]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( changeRoleMessageinfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawLeaveThreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.LEAVE_THREAD]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( leaveThreadMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawJoinThreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.JOIN_THREAD]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( joinThreadMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawCreateEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_ENTRY]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( createEntryMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawEditEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.EDIT_ENTRY]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(editEntryMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawDeleteEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.DELETE_ENTRY]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( deleteEntryMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawRestoreEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.RESTORE_ENTRY]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( restoreEntryMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawUpdateRelationshipMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.LEGACY_UPDATE_RELATIONSHIP]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( updateRelationshipMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawImagesMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.IMAGES]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(imageMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return false for RawMediaMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.MULTIMEDIA]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(mediaMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return true for RawSidebarSourceMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.SIDEBAR_SOURCE]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( sidebarSourceMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(true); expect(canBeSidebarSource).toBe(false); }); it('should return false for RawCreateSidebarMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_SIDEBAR]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource( createSidebarMessageInfo, ); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(false); expect(canBeSidebarSource).toBe(true); }); it('should return true for RawReactionMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.REACTION]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(reactionMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(true); expect(canBeSidebarSource).toBe(false); }); it('should return true for RawEditMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.EDIT_MESSAGE]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(editMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(true); expect(canBeSidebarSource).toBe(false); }); it('should return true for RawTogglePinMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.TOGGLE_PIN]; const shouldBeInvalidSidebarSource = isInvalidSidebarSource(togglePinMessageInfo); const canBeSidebarSource = messageSpec.canBeSidebarSource; expect(shouldBeInvalidSidebarSource).toBe(true); expect(canBeSidebarSource).toBe(false); }); }); describe('isInvalidPinSource & canBePinned', () => { it('should return true for RawTextMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.TEXT]; const shouldBeInvalidPinSource = isInvalidPinSource(textMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(false); expect(canBePinned).toBe(true); }); it('should return false for RawCreateThreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_THREAD]; const shouldBeInvalidPinSource = isInvalidPinSource( createThreadMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawAddMembersMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.ADD_MEMBERS]; const shouldBeInvalidPinSource = isInvalidPinSource(addMembersMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawCreateSubthreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_SUB_THREAD]; const shouldBeInvalidPinSource = isInvalidPinSource( createSubthreadMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawChangeSettingsMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CHANGE_SETTINGS]; const shouldBeInvalidPinSource = isInvalidPinSource( changeSettingsMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawRemoveMembersMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.REMOVE_MEMBERS]; const shouldBeInvalidPinSource = isInvalidPinSource( removeMembersMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawChangeRoleMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CHANGE_ROLE]; const shouldBeInvalidPinSource = isInvalidPinSource(changeRoleMessageinfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawLeaveThreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.LEAVE_THREAD]; const shouldBeInvalidPinSource = isInvalidPinSource(leaveThreadMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawJoinThreadMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.JOIN_THREAD]; const shouldBeInvalidPinSource = isInvalidPinSource(joinThreadMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawCreateEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_ENTRY]; const shouldBeInvalidPinSource = isInvalidPinSource(createEntryMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawEditEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.EDIT_ENTRY]; const shouldBeInvalidPinSource = isInvalidPinSource(editEntryMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawDeleteEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.DELETE_ENTRY]; const shouldBeInvalidPinSource = isInvalidPinSource(deleteEntryMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawRestoreEntryMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.RESTORE_ENTRY]; const shouldBeInvalidPinSource = isInvalidPinSource( restoreEntryMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawUpdateRelationshipMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.LEGACY_UPDATE_RELATIONSHIP]; const shouldBeInvalidPinSource = isInvalidPinSource( updateRelationshipMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return true for RawImagesMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.IMAGES]; const shouldBeInvalidPinSource = isInvalidPinSource(imageMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(false); expect(canBePinned).toBe(true); }); it('should return true for RawMediaMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.MULTIMEDIA]; const shouldBeInvalidPinSource = isInvalidPinSource(mediaMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(false); expect(canBePinned).toBe(true); }); it('should return false for RawSidebarSourceMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.SIDEBAR_SOURCE]; const shouldBeInvalidPinSource = isInvalidPinSource( sidebarSourceMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawCreateSidebarMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.CREATE_SIDEBAR]; const shouldBeInvalidPinSource = isInvalidPinSource( createSidebarMessageInfo, ); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawReactionMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.REACTION]; const shouldBeInvalidPinSource = isInvalidPinSource(reactionMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawEditMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.EDIT_MESSAGE]; const shouldBeInvalidPinSource = isInvalidPinSource(editMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); it('should return false for RawTogglePinMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.TOGGLE_PIN]; const shouldBeInvalidPinSource = isInvalidPinSource(togglePinMessageInfo); const canBePinned = messageSpec.canBePinned; expect(shouldBeInvalidPinSource).toBe(true); expect(canBePinned).toBe(false); }); describe('isUnableToBeRenderedIndependently & canBeRenderedIndependently', () => { it('should return false for RawReactionMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.REACTION]; const shouldBeUnableToBeRenderedIndependently = isUnableToBeRenderedIndependently(reactionMessageInfo); const canBeRenderedIndependently = messageSpec.canBeRenderedIndependently; expect(shouldBeUnableToBeRenderedIndependently).toBe(true); expect(canBeRenderedIndependently).toBe(false); }); it('should return false for RawEditMessageInfo', () => { const messageSpec = messageSpecs[messageTypes.EDIT_MESSAGE]; const shouldBeUnableToBeRenderedIndependently = isUnableToBeRenderedIndependently(editMessageInfo); const canBeRenderedIndependently = messageSpec.canBeRenderedIndependently; expect(shouldBeUnableToBeRenderedIndependently).toBe(true); expect(canBeRenderedIndependently).toBe(false); }); }); }); describe('findNewestMessageTimePerKeyserver', () => { it('should return the time of the newest message for every keyserver', () => { const messages: $ReadOnlyArray = [ { type: 0, threadID: '256|100', creatorID: '256', time: 4, text: 'test', }, { type: 0, threadID: '1|100', creatorID: '256', time: 2, text: 'test', }, { type: 0, threadID: '1|100', creatorID: '256', time: 3, text: 'test', }, { type: 0, threadID: '256|100', creatorID: '256', time: 1, text: 'test', }, ]; const result = { ['256']: 4, ['1']: 3 }; expect(findNewestMessageTimePerKeyserver(messages)).toEqual(result); }); }); describe('sortMessageInfoList', () => { - it('should sort messages by time and then by the number in id', () => { - const messages: $ReadOnlyArray = [ - { - type: 0, - threadID: '256|100', - creatorID: '256', - time: 4, - text: 'test', - id: '256|9', - }, - { - type: 0, - threadID: '256|100', - creatorID: '256', - time: 2, - text: 'test', - localID: 'local100', - }, - { - type: 0, - threadID: '256|100', - creatorID: '256', - time: 1, - text: 'test', - id: '256|1', - }, - { - type: 0, - threadID: '256|100', - creatorID: '256', - time: 1, - text: 'test', - localID: 'local10', - }, - { - type: 0, - threadID: '256|100', - creatorID: '256', - time: 1, - text: 'test', - localID: 'local200', - }, - { - type: 0, - threadID: '256|100', - creatorID: '256', - time: 1, - text: 'test', - id: '256|20', - }, - ]; - const result = sortMessageInfoList(messages); - expect(result.map(item => item.id ?? item.localID)).toEqual([ - '256|9', - 'local100', - 'local200', - 'local10', - '256|20', - '256|1', - ]); - }); + it( + 'should sort messages by time and then by the number in id. ' + + 'Local ids should be sorted lexicographically', + () => { + const messages: $ReadOnlyArray = [ + { + type: 0, + threadID: '256|100', + creatorID: '256', + time: 4, + text: 'test', + id: '256|9', + }, + { + type: 0, + threadID: '256|100', + creatorID: '256', + time: 2, + text: 'test', + localID: 'local100', + }, + { + type: 0, + threadID: '256|100', + creatorID: '256', + time: 1, + text: 'test', + id: '256|1', + }, + { + type: 0, + threadID: '256|100', + creatorID: '256', + time: 1, + text: 'test', + localID: 'local20', + }, + { + type: 0, + threadID: '256|100', + creatorID: '256', + time: 1, + text: 'test', + localID: 'local100', + }, + { + type: 0, + threadID: '256|100', + creatorID: '256', + time: 1, + text: 'test', + id: '256|20', + }, + ]; + const result = sortMessageInfoList(messages); + expect(result.map(item => item.id ?? item.localID)).toEqual([ + '256|9', + 'local100', + 'local20', + 'local100', + '256|20', + '256|1', + ]); + }, + ); it('on the keyserver, should sort messages by time and then by id', () => { const messages: $ReadOnlyArray = [ { type: 0, threadID: '100', creatorID: '256', time: 4, text: 'test', id: '9', }, { type: 0, threadID: '100', creatorID: '256', time: 2, text: 'test', localID: '100', }, { type: 0, threadID: '100', creatorID: '256', time: 1, text: 'test', id: '1', }, { type: 0, threadID: '100', creatorID: '256', time: 1, text: 'test', localID: '10', }, { type: 0, threadID: '100', creatorID: '256', time: 1, text: 'test', localID: '200', }, { type: 0, threadID: '100', creatorID: '256', time: 1, text: 'test', id: '20', }, ]; const result = sortMessageInfoList(messages); expect(result.map(item => item.id ?? item.localID)).toEqual([ '9', '100', '200', '20', '10', '1', ]); }); }); describe('sortMessageIDs', () => { - it('should sort messages by time and then by the number in id', () => { + it( + 'should sort messages by time and then by the number in id. ' + + 'Local ids should be sorted lexicographically', + () => { + const messages = { + ['256|0']: { + type: 0, + threadID: '100', + creatorID: '256', + time: 5, + text: 'test', + id: '256|0', + }, + ['256|100']: { + type: 0, + threadID: '100', + creatorID: '256', + time: 1, + text: 'test', + id: '256|100', + }, + ['local333']: { + type: 0, + threadID: '100', + creatorID: '256', + time: 1, + text: 'test', + localID: 'local333', + }, + ['256|1325993']: { + type: 0, + threadID: '100', + creatorID: '256', + time: 1, + text: 'test', + localID: '256|1325993', + }, + ['256|1']: { + type: 0, + threadID: '100', + creatorID: '256', + time: 1, + text: 'test', + id: '256|1', + }, + }; + expect( + sortMessageIDs(messages)([ + '256|0', + '256|100', + 'local333', + '256|1325993', + '256|1', + ]), + ).toEqual(['256|0', 'local333', '256|1325993', '256|100', '256|1']); + }, + ); + + it('should sort local ids lexicographically', () => { const messages = { - ['256|0']: { + ['local9']: { type: 0, threadID: '100', creatorID: '256', time: 5, text: 'test', id: '256|0', }, - ['256|100']: { + ['local100']: { type: 0, threadID: '100', creatorID: '256', time: 1, text: 'test', id: '256|100', }, ['local333']: { type: 0, threadID: '100', creatorID: '256', time: 1, text: 'test', localID: 'local333', }, - ['256|1325993']: { - type: 0, - threadID: '100', - creatorID: '256', - time: 1, - text: 'test', - localID: '256|1325993', - }, - ['256|1']: { - type: 0, - threadID: '100', - creatorID: '256', - time: 1, - text: 'test', - id: '256|1', - }, }; expect( - sortMessageIDs(messages)([ - '256|0', - '256|100', - 'local333', - '256|1325993', - '256|1', - ]), - ).toEqual(['256|0', 'local333', '256|1325993', '256|100', '256|1']); + sortMessageIDs(messages)(['local9', 'local100', 'local333']), + ).toEqual(['local9', 'local333', 'local100']); }); });