diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js index d26083bd5..33076a612 100644 --- a/keyserver/src/fetchers/message-fetchers.js +++ b/keyserver/src/fetchers/message-fetchers.js @@ -1,784 +1,834 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, } 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 { type RawMessageInfo, type RawComposableMessageInfo, type RawRobotextMessageInfo, messageTypes, type MessageType, type EditMessageContent, assertMessageType, type MessageSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, defaultMaxMessageAge, + type FetchPinnedMessagesRequest, + type FetchPinnedMessagesResult, } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-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 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, }; } 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, 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 derivedMessages = await fetchDerivedMessages(messageRows, viewer); + + const parsedPinnedMessages = await parseMessageSQLResult( + messageRows, + derivedMessages, + viewer, + ); + + const pinnedRawMessageInfos = parsedPinnedMessages.map( + message => message.rawMessageInfo, + ); + + const shimmedPinnedRawMessageInfos = shimUnsupportedRawMessageInfos( + pinnedRawMessageInfos, + 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); } } 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; if (rawMessageInfo.id) { invariant( rawMessageInfo.type !== messageTypes.SIDEBAR_SOURCE && rawMessageInfo.type !== messageTypes.REACTION && rawMessageInfo.type !== messageTypes.EDIT_MESSAGE, 'SIDEBAR_SOURCE should not point to a SIDEBAR_SOURCE, REACTION or EDIT_MESSAGE', ); 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; } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, fetchThreadMessagesCount, fetchLatestEditMessageContentByID, + fetchPinnedMessageInfos, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 376f62fcb..6493ee13b 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,602 +1,610 @@ // @flow import invariant from 'invariant'; import { type ClientDBMediaInfo } from './media-types.js'; import type { AddMembersMessageData, AddMembersMessageInfo, RawAddMembersMessageInfo, } from './messages/add-members.js'; import type { ChangeRoleMessageData, ChangeRoleMessageInfo, RawChangeRoleMessageInfo, } from './messages/change-role.js'; import type { ChangeSettingsMessageData, ChangeSettingsMessageInfo, RawChangeSettingsMessageInfo, } from './messages/change-settings.js'; import type { CreateEntryMessageData, CreateEntryMessageInfo, RawCreateEntryMessageInfo, } from './messages/create-entry.js'; import type { CreateSidebarMessageData, CreateSidebarMessageInfo, RawCreateSidebarMessageInfo, } from './messages/create-sidebar.js'; import type { CreateSubthreadMessageData, CreateSubthreadMessageInfo, RawCreateSubthreadMessageInfo, } from './messages/create-subthread.js'; import type { CreateThreadMessageData, CreateThreadMessageInfo, RawCreateThreadMessageInfo, } from './messages/create-thread.js'; import type { DeleteEntryMessageData, DeleteEntryMessageInfo, RawDeleteEntryMessageInfo, } from './messages/delete-entry.js'; import type { EditEntryMessageData, EditEntryMessageInfo, RawEditEntryMessageInfo, } from './messages/edit-entry.js'; import type { RawEditMessageInfo, EditMessageData, EditMessageInfo, } from './messages/edit.js'; import type { ImagesMessageData, ImagesMessageInfo, RawImagesMessageInfo, } from './messages/images.js'; import type { JoinThreadMessageData, JoinThreadMessageInfo, RawJoinThreadMessageInfo, } from './messages/join-thread.js'; import type { LeaveThreadMessageData, LeaveThreadMessageInfo, RawLeaveThreadMessageInfo, } from './messages/leave-thread.js'; import type { MediaMessageData, MediaMessageInfo, MediaMessageServerDBContent, RawMediaMessageInfo, } from './messages/media.js'; import type { ReactionMessageData, RawReactionMessageInfo, ReactionMessageInfo, } from './messages/reaction.js'; import type { RawRemoveMembersMessageInfo, RemoveMembersMessageData, RemoveMembersMessageInfo, } from './messages/remove-members.js'; import type { RawRestoreEntryMessageInfo, RestoreEntryMessageData, RestoreEntryMessageInfo, } from './messages/restore-entry.js'; import type { RawTextMessageInfo, TextMessageData, TextMessageInfo, } from './messages/text.js'; import type { RawUnsupportedMessageInfo, UnsupportedMessageInfo, } from './messages/unsupported.js'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; import type { CallServerEndpointResultInfoInterface } from '../utils/call-server-endpoint.js'; export const messageTypes = Object.freeze({ TEXT: 0, // Appears in the newly created thread CREATE_THREAD: 1, ADD_MEMBERS: 2, // Appears in the parent when a child thread is created // (historically also when a sidebar was created) CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, UPDATE_RELATIONSHIP: 16, SIDEBAR_SOURCE: 17, // Appears in the newly created sidebar CREATE_SIDEBAR: 18, REACTION: 19, EDIT_MESSAGE: 20, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15 || ourMessageType === 16 || ourMessageType === 17 || ourMessageType === 18 || ourMessageType === 19 || ourMessageType === 20, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function assertComposableRawMessage( message: RawMessageInfo, ): RawComposableMessageInfo { invariant( message.type === messageTypes.TEXT || message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, 'Message is not composable', ); return message; } export function messageDataLocalID(messageData: MessageData): ?string { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA && messageData.type !== messageTypes.REACTION ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assertMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type SidebarSourceMessageData = { +type: 17, +threadID: string, +creatorID: string, +time: number, +sourceMessage?: RawComposableMessageInfo | RawRobotextMessageInfo, }; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData | ReactionMessageData | EditMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo; export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo | RawReactionMessageInfo | RawEditMessageInfo; export type LocallyComposedMessageInfo = | ({ ...RawImagesMessageInfo, +localID: string, } & RawImagesMessageInfo) | ({ ...RawMediaMessageInfo, +localID: string, } & RawMediaMessageInfo) | ({ ...RawTextMessageInfo, +localID: string, } & RawTextMessageInfo) | ({ ...RawReactionMessageInfo, +localID: string, } & RawReactionMessageInfo); export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo | ReactionMessageInfo; export type SidebarSourceMessageInfo = { +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ComposableMessageInfo | RobotextMessageInfo, }; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo | ReactionMessageInfo | EditMessageInfo; export type ThreadMessageInfo = { messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp }; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = { +sendFailed?: boolean, }; export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: { +[threadID: string]: ThreadMessageInfo }, +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, }; export type RemoveMessageOperation = { +type: 'remove', +payload: { +ids: $ReadOnlyArray }, }; export type RemoveMessagesForThreadsOperation = { +type: 'remove_messages_for_threads', +payload: { +threadIDs: $ReadOnlyArray }, }; export type ReplaceMessageOperation = { +type: 'replace', +payload: { +id: string, +messageInfo: RawMessageInfo }, }; export type RekeyMessageOperation = { +type: 'rekey', +payload: { +from: string, +to: string }, }; export type RemoveAllMessagesOperation = { +type: 'remove_all', }; // We were initially using `number`s` for `thread`, `type`, `future_type`, etc. // However, we ended up changing `thread` to `string` to account for thread IDs // including information about the keyserver (eg 'GENESIS|123') in the future. // // At that point we discussed whether we should switch the remaining `number` // fields to `string`s for consistency and flexibility. We researched whether // there was any performance cost to using `string`s instead of `number`s and // found the differences to be negligible. We also concluded using `string`s // may be safer after considering `jsi::Number` and the various C++ number // representations on the CommCoreModule side. export type ClientDBMessageInfo = { +id: string, +local_id: ?string, +thread: string, +user: string, +type: string, +future_type: ?string, +content: ?string, +time: string, +media_infos: ?$ReadOnlyArray, }; export type ClientDBReplaceMessageOperation = { +type: 'replace', +payload: ClientDBMessageInfo, }; export type MessageStoreOperation = | RemoveMessageOperation | ReplaceMessageOperation | RekeyMessageOperation | RemoveMessagesForThreadsOperation | RemoveAllMessagesOperation; export type ClientDBMessageStoreOperation = | RemoveMessageOperation | ClientDBReplaceMessageOperation | RekeyMessageOperation | RemoveMessagesForThreadsOperation | RemoveAllMessagesOperation; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: MessageSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { +[threadID: string]: ?string }; export type MessageSelectionCriteria = { +threadCursors?: ?ThreadCursors, +joinedThreads?: ?boolean, +newerThan?: ?number, }; export type FetchMessageInfosRequest = { +cursors: ThreadCursors, +numberPerThread?: ?number, }; export type FetchMessageInfosResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type FetchMessageInfosResult = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export type FetchMessageInfosPayload = { +threadID: string, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, }; export type MessagesResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +currentAsOf: number, }; export type SimpleMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export const defaultNumberPerThread = 20; export const defaultMaxMessageAge = 14 * 24 * 60 * 60 * 1000; // 2 weeks export type SendMessageResponse = { +newMessageInfo: RawMessageInfo, }; export type SendMessageResult = { +id: string, +time: number, +interface: CallServerEndpointResultInfoInterface, }; export type SendMessagePayload = { +localID: string, +serverID: string, +threadID: string, +time: number, +interface: CallServerEndpointResultInfoInterface, }; export type SendTextMessageRequest = { +threadID: string, +localID?: string, +text: string, +sidebarCreation?: boolean, }; export type SendMultimediaMessageRequest = // This option is only used for messageTypes.IMAGES | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaIDs: $ReadOnlyArray, } | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaMessageContents: $ReadOnlyArray, }; export type SendReactionMessageRequest = { +threadID: string, +localID?: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export type SendEditMessageRequest = { +targetMessageID: string, +text: string, }; export type SendEditMessageResponse = { +newMessageInfos: $ReadOnlyArray, }; export type EditMessagePayload = SendEditMessageResponse; export type SendEditMessageResult = SendEditMessageResponse; export type EditMessageContent = { +text: string, }; // Used for the message info included in log-in type actions export type GenericMessagesResult = { +messageInfos: RawMessageInfo[], +truncationStatus: MessageTruncationStatuses, +watchedIDsAtRequestTime: $ReadOnlyArray, +currentAsOf: number, }; export type SaveMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +updatesCurrentAsOf: number, }; export type NewMessagesPayload = { +messagesResult: MessagesResponse, }; export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; + +export type FetchPinnedMessagesRequest = { + +threadID: string, +}; + +export type FetchPinnedMessagesResult = { + +pinnedMessages: $ReadOnlyArray, +};