diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js index 7cdb45c4c..c61f20fcb 100644 --- a/keyserver/src/fetchers/message-fetchers.js +++ b/keyserver/src/fetchers/message-fetchers.js @@ -1,989 +1,997 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, isInvalidSidebarSource, + isUnableToBeRenderedIndependently, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { getNotifCollapseKey } from 'lib/shared/notif-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { messageTypes, type MessageType, assertMessageType, } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawComposableMessageInfo, type RawRobotextMessageInfo, type EditMessageContent, type MessageSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, defaultMaxMessageAge, type FetchPinnedMessagesRequest, type FetchPinnedMessagesResult, type SearchMessagesResponse, } from 'lib/types/message-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { constructMediaFromMediaMessageContentsAndUploadRows, imagesFromRow, } from './upload-fetchers.js'; import { dbQuery, SQL, mergeOrConditions, mergeAndConditions, } from '../database/database.js'; import { processQueryForSearch } from '../database/search-utils.js'; import type { SQLStatementType } from '../database/types.js'; import type { PushInfo } from '../push/send.js'; import type { Viewer } from '../session/viewer.js'; import { creationString, localIDFromCreationString, } from '../utils/idempotent.js'; export type CollapsableNotifInfo = { collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], }; export type FetchCollapsableNotifsResult = { [userID: string]: CollapsableNotifInfo[], }; const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (const userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) { const rawMessageInfo = pushInfo[userID].messageInfos[i]; const messageData = pushInfo[userID].messageDatas[i]; const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (const collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return usersToCollapsableNotifInfo; } const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC, m.id DESC`); const [collapseResult] = await dbQuery(collapseQuery); const rowsByUser = new Map(); for (const row of collapseResult) { const user = row.user.toString(); const currentRowsForUser = rowsByUser.get(user); if (currentRowsForUser) { currentRowsForUser.push(row); } else { rowsByUser.set(user, [row]); } } const derivedMessages = await fetchDerivedMessages(collapseResult); for (const userRows of rowsByUser.values()) { const messages = parseMessageSQLResult(userRows, derivedMessages); for (const message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } } for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (const collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return usersToCollapsableNotifInfo; } type MessageSQLResult = $ReadOnlyArray<{ rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, }>; function parseMessageSQLResult( rows: $ReadOnlyArray, derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, viewer?: Viewer, ): MessageSQLResult { const rowsByID = new Map(); for (const row of rows) { const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (const messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows( messageRows, viewer, derivedMessages, ); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map(row => row.id.toString()); console.warn( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rawRows: $ReadOnlyArray, viewer?: Viewer, derivedMessages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, ): ?RawMessageInfo { const rows = rawRows.map(row => ({ ...row, subthread_permissions: JSON.parse(row.subthread_permissions), })); const type = mostRecentRowType(rows); const messageSpec = messageSpecs[type]; if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { let media; if (type === messageTypes.MULTIMEDIA) { const mediaMessageContents = JSON.parse(rows[0].content); media = constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, rows, ); } else { media = rows.filter(row => row.uploadID).map(imagesFromRow); } const [row] = rows; const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromServerDBRow, `multimedia message spec should have rawMessageInfoFromServerDBRow`, ); return messageSpec.rawMessageInfoFromServerDBRow(row, { media, derivedMessages, localID, }); } const row = assertSingleRow(rows); const localID = localIDFromCreationString(viewer, row.creation); invariant( messageSpec.rawMessageInfoFromServerDBRow, `message spec ${type} should have rawMessageInfoFromServerDBRow`, ); return messageSpec.rawMessageInfoFromServerDBRow(row, { derivedMessages, localID, }); } async function fetchMessageInfos( viewer: Viewer, criteria: MessageSelectionCriteria, numberPerThread: number, ): Promise { const { sqlClause: selectionClause, timeFilterData } = parseMessageSelectionCriteria(viewer, criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` WITH thread_window AS ( SELECT m.id, m.thread AS threadID, m.user AS creatorID, m.target_message as targetMessageID, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, ROW_NUMBER() OVER ( PARTITION BY threadID ORDER BY m.time DESC, m.id DESC ) n FROM messages m LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(selectionClause); query.append(SQL` ) SELECT tw.*, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM thread_window tw LEFT JOIN uploads up ON up.container = tw.id WHERE tw.n <= ${numberPerThread} ORDER BY tw.threadID, tw.time DESC, tw.id DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = await parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (const message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (const [threadID, messageCount] of threadToMessageCount) { // If we matched the exact amount we limited to, we're probably truncating // our result set. By setting TRUNCATED here, we tell the client that the // result set might not be continguous with what's already in their // MessageStore. More details about TRUNCATED can be found in // lib/types/message-types.js if (messageCount >= numberPerThread) { // We won't set TRUNCATED if a cursor was specified for a given thread, // since then the result is guaranteed to be contiguous with what the // client has if (criteria.threadCursors && criteria.threadCursors[threadID]) { truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } continue; } const hasTimeFilter = hasTimeFilterForThread(timeFilterData, threadID); if (!hasTimeFilter) { // If there is no time filter for a given thread, and there are fewer // messages returned than the max we queried for a given thread, we can // conclude that our result set includes all messages for that thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const rawMessageInfo of rawMessageInfos) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (const threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus !== null && truncationStatus !== undefined) { continue; } const hasTimeFilter = hasTimeFilterForThread(timeFilterData, threadID); if (!hasTimeFilter) { // If there is no time filter for a given thread, and zero messages were // returned, we can conclude that this thread has zero messages. This is // a case of database corruption that should not be possible, but likely // we have some threads like this on prod (either due to some transient // issues or due to old buggy code) truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else { // If this thread was explicitly queried for, and we got no results, but // we can't conclude that it's EXHAUSTIVE, then we'll set to UNCHANGED. truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function hasTimeFilterForThread( timeFilterData: TimeFilterData, threadID: string, ) { if (timeFilterData.timeFilter === 'ALL') { return true; } else if (timeFilterData.timeFilter === 'NONE') { return false; } else if (timeFilterData.timeFilter === 'ALL_EXCEPT_EXCLUDED') { return !timeFilterData.excludedFromTimeFilter.has(threadID); } else { invariant( false, `unrecognized timeFilter type ${timeFilterData.timeFilter}`, ); } } type TimeFilterData = | { +timeFilter: 'ALL' | 'NONE' } | { +timeFilter: 'ALL_EXCEPT_EXCLUDED', +excludedFromTimeFilter: $ReadOnlySet, }; type ParsedMessageSelectionCriteria = { +sqlClause: SQLStatementType, +timeFilterData: TimeFilterData, }; function parseMessageSelectionCriteria( viewer: Viewer, criteria: MessageSelectionCriteria, ): ParsedMessageSelectionCriteria { const minMessageTime = Date.now() - defaultMaxMessageAge; const shouldApplyTimeFilter = hasMinCodeVersion(viewer.platformDetails, { native: 130, }); let globalTimeFilter; if (criteria.newerThan) { globalTimeFilter = SQL`m.time > ${criteria.newerThan}`; } else if (!criteria.threadCursors && shouldApplyTimeFilter) { globalTimeFilter = SQL`m.time > ${minMessageTime}`; } const threadConditions = []; if ( criteria.joinedThreads === true && shouldApplyTimeFilter && !globalTimeFilter ) { threadConditions.push(SQL`(mm.role > 0 AND m.time > ${minMessageTime})`); } else if (criteria.joinedThreads === true) { threadConditions.push(SQL`mm.role > 0`); } if (criteria.threadCursors) { for (const threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { threadConditions.push( SQL`(m.thread = ${threadID} AND m.id < ${cursor})`, ); } else { threadConditions.push(SQL`m.thread = ${threadID}`); } } } if (threadConditions.length === 0) { throw new ServerError('internal_error'); } const threadClause = mergeOrConditions(threadConditions); let timeFilterData; if (globalTimeFilter) { timeFilterData = { timeFilter: 'ALL' }; } else if (!shouldApplyTimeFilter) { timeFilterData = { timeFilter: 'NONE' }; } else { invariant( criteria.threadCursors, 'ALL_EXCEPT_EXCLUDED should correspond to threadCursors being set', ); const excludedFromTimeFilter = new Set(Object.keys(criteria.threadCursors)); timeFilterData = { timeFilter: 'ALL_EXCEPT_EXCLUDED', excludedFromTimeFilter, }; } const conditions = [globalTimeFilter, threadClause].filter(Boolean); const sqlClause = mergeAndConditions(conditions); return { sqlClause, timeFilterData }; } function messageSelectionCriteriaToInitialTruncationStatuses( criteria: MessageSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (const threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchMessageInfosSince( viewer: Viewer, criteria: MessageSelectionCriteria, maxNumberPerThread: number, ): Promise { const { sqlClause: selectionClause } = parseMessageSelectionCriteria( viewer, criteria, ); const truncationStatuses = messageSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(selectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC, m.id DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = await parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (const message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if (messageSpecs[rawMessageInfo.type].startsThread) { truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): FetchMessageInfosResult { const truncationStatuses = {}; for (const rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchMessageRowsByIDs(messageIDs: $ReadOnlyArray) { const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.id IN (${messageIDs}) `; const [result] = await dbQuery(query); return result; } async function fetchPinnedMessageInfos( viewer: Viewer, request: FetchPinnedMessagesRequest, ): Promise { // The only message types that can be pinned are 0, 14, and 15 // (text, images, and multimedia), so we don't need to worry about // an admin pinning a message about creating a secret subchannel. This is // why we don't check subthread permissions (as opposed to other queries). const messageRowsQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, NULL AS subthread_permissions, u.id AS uploadID, u.type AS uploadType, u.secret AS uploadSecret, u.extra AS uploadExtra FROM messages m LEFT JOIN uploads u ON u.container = m.id LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.pinned = 1 AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY m.pin_time DESC `; const [messageRows] = await dbQuery(messageRowsQuery); if (messageRows.length === 0) { return { pinnedMessages: [] }; } const pinnedAndRelatedMessages = await rawMessageInfoForRowsAndRelatedMessages(messageRows, viewer); const shimmedPinnedRawMessageInfos = shimUnsupportedRawMessageInfos( pinnedAndRelatedMessages, viewer.platformDetails, ); return { pinnedMessages: shimmedPinnedRawMessageInfos, }; } async function fetchDerivedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise< $ReadOnlyMap, > { const requiredIDs = new Set(); for (const row of rows) { if (row.type === messageTypes.SIDEBAR_SOURCE) { const content = JSON.parse(row.content); requiredIDs.add(content.sourceMessageID); } else if (row.type === messageTypes.TOGGLE_PIN) { const content = JSON.parse(row.content); requiredIDs.add(content.targetMessageID); } } const messagesByID = new Map< string, RawComposableMessageInfo | RawRobotextMessageInfo, >(); if (requiredIDs.size === 0) { return messagesByID; } const [result, edits] = await Promise.all([ fetchMessageRowsByIDs([...requiredIDs]), fetchLatestEditMessageContentByIDs([...requiredIDs]), ]); const messages = await parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { let { rawMessageInfo } = message; invariant( !isInvalidSidebarSource(rawMessageInfo), 'SIDEBAR_SOURCE or TOGGLE_PIN should not point to a ' + 'SIDEBAR_SOURCE, REACTION, EDIT_MESSAGE or TOGGLE_PIN', ); if (rawMessageInfo.id) { const editedContent = edits.get(rawMessageInfo.id); if (editedContent && rawMessageInfo.type === messageTypes.TEXT) { rawMessageInfo = { ...rawMessageInfo, text: editedContent.text, }; } invariant(rawMessageInfo.id, 'rawMessageInfo.id should not be null'); messagesByID.set(rawMessageInfo.id, rawMessageInfo); } } return messagesByID; } async function fetchMessageInfoByID( viewer?: Viewer, messageID: string, ): Promise { const result = await fetchMessageRowsByIDs([messageID]); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchThreadMessagesCount(threadID: string): Promise { const query = SQL` SELECT COUNT(*) AS count FROM messages WHERE thread = ${threadID} `; const [result] = await dbQuery(query); return result[0].count; } async function fetchLatestEditMessageContentByIDs( messageIDs: $ReadOnlyArray, ): Promise<$ReadOnlyMap> { const latestEditedMessageQuery = SQL` SELECT m.id, ( SELECT m2.content FROM messages m2 WHERE m.id = m2.target_message AND m.thread = m2.thread AND m2.type = ${messageTypes.EDIT_MESSAGE} ORDER BY time DESC, id DESC LIMIT 1 ) content FROM messages m WHERE m.id IN(${messageIDs}) `; const [result] = await dbQuery(latestEditedMessageQuery); const latestContentByID = new Map(); for (const row of result) { if (!row.content) { continue; } const content = JSON.parse(row.content); latestContentByID.set(row.id.toString(), content); } return latestContentByID; } async function fetchLatestEditMessageContentByID( messageID: string, ): Promise { const result = await fetchLatestEditMessageContentByIDs([messageID]); const content = result.get(messageID); return content; } async function fetchRelatedMessages( viewer?: Viewer, messages: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, ): Promise<$ReadOnlyArray> { if (messages.size === 0) { return []; } const originalMessageIDs = [...messages.keys()]; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON up.container = m.id LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user WHERE m.target_message IN (${originalMessageIDs}) AND ( m.type = ${messageTypes.SIDEBAR_SOURCE} OR m.type = ${messageTypes.REACTION} OR m.type = ${messageTypes.TOGGLE_PIN} ) UNION SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m2 INNER JOIN messages m ON m.id = ( SELECT m3.id FROM messages m3 WHERE m3.target_message = m2.id AND m3.thread = m2.thread AND m3.type = ${messageTypes.EDIT_MESSAGE} ORDER BY time DESC, id DESC LIMIT 1 ) LEFT JOIN uploads up ON up.container = m2.id LEFT JOIN memberships stm ON m2.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m2.content AND stm.user = m2.user WHERE m2.id IN (${originalMessageIDs}) `; const [resultRows] = await dbQuery(query); if (resultRows.length === 0) { return []; } const SQLResult = await parseMessageSQLResult(resultRows, messages, viewer); return SQLResult.map(item => item.rawMessageInfo); } async function rawMessageInfoForRowsAndRelatedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise<$ReadOnlyArray> { const parsedResults = await parseMessageSQLResult(rows, new Map(), viewer); const rawMessageInfoMap = new Map< string, RawComposableMessageInfo | RawRobotextMessageInfo, >(); for (const message of parsedResults) { const { rawMessageInfo } = message; - if (isInvalidSidebarSource(rawMessageInfo)) { + if (isUnableToBeRenderedIndependently(rawMessageInfo)) { continue; } invariant(rawMessageInfo.id, 'rawMessageInfo.id should not be null'); + // Flow fails to refine types correctly since + // isUnableToBeRenderedIndependently introspects into a message spec + // instead of directly checking message types. We use "continue" to avoid + // invalid messages, but Flow doesn't recognize this. The + // alternative is to check against every message type, but that defeats + // the purpose of a 'single source of truth.' + // $FlowFixMe rawMessageInfoMap.set(rawMessageInfo.id, rawMessageInfo); } const rawMessageInfos = parsedResults.map(item => item.rawMessageInfo); const rawRelatedMessageInfos = await fetchRelatedMessages( viewer, rawMessageInfoMap, ); return [...rawMessageInfos, ...rawRelatedMessageInfos]; } const searchMessagesPageSize = defaultNumberPerThread + 1; async function searchMessagesInSingleChat( inputQuery: string, threadID: string, viewer?: Viewer, cursor?: string, ): Promise { if (inputQuery === '') { console.warn('received empty search query'); return { messages: [], endReached: true }; } const pattern = processQueryForSearch(inputQuery); if (pattern === '') { return { endReached: true, messages: [], }; } const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, m.target_message as targetMessageID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM message_search s LEFT JOIN messages m ON m.id = s.original_message_id LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = m.user LEFT JOIN uploads up ON up.container = m.id LEFT JOIN messages m2 ON m2.target_message = m.id AND m2.type = ${messageTypes.SIDEBAR_SOURCE} AND m2.thread = ${threadID} WHERE MATCH(s.processed_content) AGAINST(${pattern} IN BOOLEAN MODE) AND (m.thread = ${threadID} OR m2.id IS NOT NULL) `; if (cursor) { query.append(SQL`AND m.id < ${cursor} `); } query.append(SQL` ORDER BY m.time DESC, m.id DESC LIMIT ${searchMessagesPageSize} `); const [results] = await dbQuery(query); if (results.length === 0) { return { messages: [], endReached: true }; } const endReached = results.length < searchMessagesPageSize; const resultsPage = endReached ? results : results.slice(0, -1); const rawMessageInfos = await rawMessageInfoForRowsAndRelatedMessages( resultsPage, viewer, ); return { messages: shimUnsupportedRawMessageInfos( rawMessageInfos, viewer?.platformDetails, ), endReached: endReached, }; } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, fetchThreadMessagesCount, fetchLatestEditMessageContentByID, fetchPinnedMessageInfos, searchMessagesInSingleChat, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 5e62d0440..bb4afb9d1 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,703 +1,710 @@ // @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 { 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 { 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 { type PlatformDetails, isWebPlatform } from '../types/device-types.js'; import type { Media } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTruncationStatus, type RawComposableMessageInfo, 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 } 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, }; for (const id of messageIDs) { if (!id) { continue; } if (id.startsWith(localIDPrefix)) { result.local = Math.max(result.local, id.length - localIDPrefix.length); } else { result.realized = Math.max(result.realized, id.length); } } return result; } function extendMessageID(id: ?string, lengths: LengthResult): ?string { if (!id) { 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'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { const lengths = findMessageIDMaxLengths( messageInfos.map(message => message?.id), ); return _orderBy([ 'time', (message: T) => extendMessageID(message?.id, lengths), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo }) => ( messageIDs: $ReadOnlyArray, ) => string[] = messages => messageIDs => { const lengths = findMessageIDMaxLengths(messageIDs); return _orderBy([ (id: string) => messages[id].time, (id: string) => extendMessageID(id, lengths), ])(['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 useNextLocalID(): string { const nextLocalID = useSelector(state => state.nextLocalID); 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 ); } function isInvalidPinSource(message: RawMessageInfo | MessageInfo): boolean { return !messageSpecs[message.type].canBePinned; } +function isUnableToBeRenderedIndependently( + message: RawMessageInfo | MessageInfo, +): boolean { + return !messageSpecs[message.type].canBeRenderedIndependently; +} + 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, useNextLocalID, isInvalidSidebarSource, isInvalidPinSource, + isUnableToBeRenderedIndependently, }; diff --git a/lib/shared/message-utils.test.js b/lib/shared/message-utils.test.js index 7971838d9..66ab57897 100644 --- a/lib/shared/message-utils.test.js +++ b/lib/shared/message-utils.test.js @@ -1,750 +1,760 @@ // @flow -import { isInvalidSidebarSource, isInvalidPinSource } from './message-utils.js'; +import { + isInvalidSidebarSource, + isInvalidPinSource, + isUnableToBeRenderedIndependently, +} from './message-utils.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import { messageTypes } from '../types/message-types-enum.js'; import type { RawSidebarSourceMessageInfo } from '../types/message-types.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 { 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 type { RawUpdateRelationshipMessageInfo } from '../types/messages/update-relationship.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: RawUpdateRelationshipMessageInfo = { type: messageTypes.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.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.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('canBeRenderedIndependently', () => { + 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); }); }); });