diff --git a/keyserver/src/creators/thread-creator.js b/keyserver/src/creators/thread-creator.js index 49f37698c..16da74907 100644 --- a/keyserver/src/creators/thread-creator.js +++ b/keyserver/src/creators/thread-creator.js @@ -1,512 +1,512 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/color-utils.js'; import { isInvalidSidebarSource } from 'lib/shared/message-utils.js'; import { getThreadTypeParentRequirement } from 'lib/shared/thread-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, } from 'lib/types/thread-types-enum.js'; import { type ServerNewThreadRequest, type NewThreadResponse, } from 'lib/types/thread-types.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { createInitialRolesForNewThread } from './role-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchLatestEditMessageContentByID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { determineThreadAncestry, personalThreadQuery, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, getChangesetCommitResultForExistingThread, } from '../updaters/thread-permission-updaters.js'; import { joinThread } from '../updaters/thread-updaters.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; const { commbot } = bots; const privateThreadDescription: string = 'This is your private chat, ' + 'where you can set reminders and jot notes in private!'; type CreateThreadOptions = Shape<{ +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, +silentlyFailMembers: boolean, }>; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: ServerNewThreadRequest, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; const silentlyFailMembers = options?.silentlyFailMembers ?? false; const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; let parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDsFromRequest = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? [...new Set(request.initialMemberIDs)] : null; const ghostMemberIDsFromRequest = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? [...new Set(request.ghostMemberIDs)] : null; const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const parentRequirement = getThreadTypeParentRequirement(threadType); if ( (parentRequirement === 'required' && !parentThreadID) || (parentRequirement === 'disabled' && parentThreadID) ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && request.initialMemberIDs?.length !== 1 ) { throw new ServerError('invalid_parameters'); } const requestParentThreadID = parentThreadID; const confirmParentPermissionPromise = (async () => { if (!requestParentThreadID) { return; } const hasParentPermission = await checkThreadPermission( viewer, requestParentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } })(); // This is a temporary hack until we release actual E2E-encrypted local // conversations. For now we are hosting all root threads on Ashoat's // keyserver, so we set them to the have the Genesis community as their // parent thread. if (!parentThreadID && !threadTypeIsCommunityRoot(threadType)) { parentThreadID = genesis.id; } const determineThreadAncestryPromise = determineThreadAncestry( parentThreadID, threadType, ); const validateMembersPromise = (async () => { const threadAncestry = await determineThreadAncestryPromise; const defaultRolePermissions = getRolePermissionBlobs(threadType).Members; const { initialMemberIDs, ghostMemberIDs } = await validateCandidateMembers( viewer, { initialMemberIDs: initialMemberIDsFromRequest, ghostMemberIDs: ghostMemberIDsFromRequest, }, { threadType, parentThreadID, containingThreadID: threadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !shouldCreateRelationships }, ); if ( !silentlyFailMembers && (Number(initialMemberIDs?.length) < Number(initialMemberIDsFromRequest?.length) || Number(ghostMemberIDs?.length) < Number(ghostMemberIDsFromRequest?.length)) ) { throw new ServerError('invalid_credentials'); } return { initialMemberIDs, ghostMemberIDs }; })(); const checkPromises = {}; checkPromises.confirmParentPermission = confirmParentPermissionPromise; checkPromises.threadAncestry = determineThreadAncestryPromise; checkPromises.validateMembers = validateMembersPromise; if (sourceMessageID) { checkPromises.sourceMessage = fetchMessageInfoByID(viewer, sourceMessageID); } const { sourceMessage, threadAncestry, validateMembers: { initialMemberIDs, ghostMemberIDs }, } = await promiseAll(checkPromises); - if (sourceMessage && isInvalidSidebarSource(sourceMessage.type)) { + if (sourceMessage && isInvalidSidebarSource(sourceMessage)) { throw new ServerError('invalid_parameters'); } let { id } = request; if (id === null || id === undefined) { const ids = await createIDs('threads', 1); id = ids[0]; } const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? firstLine(request.name) : null; const description = request.description ? request.description : null; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor([ ...(request.initialMemberIDs ?? []), viewer.id, ]); } const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, threadAncestry.containingThreadID, threadAncestry.community, threadAncestry.depth, newRoles.default.id, sourceMessageID, ]; let existingThreadQuery = null; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); existingThreadQuery = personalThreadQuery(viewer.userID, otherMemberID); } else if (sourceMessageID) { existingThreadQuery = SQL` SELECT t.id FROM threads t WHERE t.source_message = ${sourceMessageID} `; } if (existingThreadQuery) { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) SELECT ${row} WHERE NOT EXISTS (`; query.append(existingThreadQuery).append(SQL`)`); const [result] = await dbQuery(query); if (result.affectedRows === 0) { const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[existingThreadResult]] = await Promise.all([ dbQuery(existingThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant(existingThreadResult.length > 0, 'thread should exist'); const existingThreadID = existingThreadResult[0].id.toString(); invariant(request.calendarQuery, 'calendar query should exist'); const calendarQuery = { ...request.calendarQuery, filters: [ ...request.calendarQuery.filters, { type: 'threads', threadIDs: [existingThreadID] }, ], }; let joinUpdateInfos = []; let userInfos: UserInfos = {}; let newMessageInfos = []; if (threadType !== threadTypes.PERSONAL) { const joinThreadResult = await joinThread(viewer, { threadID: existingThreadID, calendarQuery, }); joinUpdateInfos = joinThreadResult.updatesResult.newUpdates; userInfos = joinThreadResult.userInfos; newMessageInfos = joinThreadResult.rawMessageInfos; } const { viewerUpdates: newUpdates, userInfos: changesetUserInfos } = await getChangesetCommitResultForExistingThread( viewer, existingThreadID, joinUpdateInfos, { calendarQuery, updatesForCurrentSession }, ); userInfos = { ...userInfos, ...changesetUserInfos }; return { newThreadID: existingThreadID, updatesResult: { newUpdates, }, userInfos, newMessageInfos, }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, containing_thread_id, community, depth, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } let initialMemberPromise; if (initialMemberIDs) { initialMemberPromise = changeRole(id, initialMemberIDs, null, { setNewMembersToUnread: true, }); } let ghostMemberPromise; if (ghostMemberIDs) { ghostMemberPromise = changeRole(id, ghostMemberIDs, -1); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberPromise, ghostMemberPromise, recalculateThreadPermissions(id), ]); const { membershipRows: creatorMembershipRows, relationshipChangeset: creatorRelationshipChangeset, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipChangeset = new RelationshipChangeset(); relationshipChangeset.addAll(creatorRelationshipChangeset); relationshipChangeset.addAll(recalculateRelationshipChangeset); if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipChangeset: initialMembersRelationshipChangeset, } = initialMembersChangeset; pushAll(membershipRows, initialMembersMembershipRows); relationshipChangeset.addAll(initialMembersRelationshipChangeset); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipChangeset: ghostMembersRelationshipChangeset, } = ghostMembersChangeset; pushAll(membershipRows, ghostMembersMembershipRows); relationshipChangeset.addAll(ghostMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { viewerUpdates, userInfos } = await commitMembershipChangeset( viewer, changeset, { updatesForCurrentSession, }, ); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); if (!sourceMessage) { throw new ServerError('invalid_parameters'); } invariant( sourceMessage.type !== messageTypes.REACTION && sourceMessage.type !== messageTypes.EDIT_MESSAGE && sourceMessage.type !== messageTypes.SIDEBAR_SOURCE && sourceMessage.type !== messageTypes.TOGGLE_PIN, 'Invalid sidebar source type', ); let editedSourceMessage = sourceMessage; if (sourceMessageID && sourceMessage.type === messageTypes.TEXT) { const editMessageContent = await fetchLatestEditMessageContentByID( sourceMessageID, ); if (editMessageContent) { editedSourceMessage = { ...sourceMessage, text: editMessageContent.text, }; } } messageDatas.push( { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage: editedSourceMessage, }, { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ); } if ( parentThreadID && threadType !== threadTypes.SIDEBAR && (parentThreadID !== genesis.id || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD) ) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } function createPrivateThread(viewer: Viewer): Promise { return createThread( viewer, { type: threadTypes.PRIVATE, description: privateThreadDescription, ghostMemberIDs: [commbot.userID], }, { forceAddMembers: true, }, ); } export { createThread, createPrivateThread, privateThreadDescription }; diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js index c5489b7c4..7cdb45c4c 100644 --- a/keyserver/src/fetchers/message-fetchers.js +++ b/keyserver/src/fetchers/message-fetchers.js @@ -1,989 +1,989 @@ // @flow import invariant from 'invariant'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, + isInvalidSidebarSource, } 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, - isMessageSidebarSourceReactionEditOrPin, 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( - !isMessageSidebarSourceReactionEditOrPin(rawMessageInfo), + !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 (isMessageSidebarSourceReactionEditOrPin(rawMessageInfo)) { + if (isInvalidSidebarSource(rawMessageInfo)) { continue; } invariant(rawMessageInfo.id, 'rawMessageInfo.id should not be null'); 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 b05ff60b4..7e102a8cd 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,695 +1,697 @@ // @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(messageType: number): boolean { +function isInvalidSidebarSource( + message: RawMessageInfo | MessageInfo, +): boolean %checks { return ( - messageType === messageTypes.REACTION || - messageType === messageTypes.EDIT_MESSAGE || - messageType === messageTypes.SIDEBAR_SOURCE || - messageType === messageTypes.TOGGLE_PIN + message.type === messageTypes.REACTION || + message.type === messageTypes.EDIT_MESSAGE || + message.type === messageTypes.SIDEBAR_SOURCE || + message.type === messageTypes.TOGGLE_PIN ); } 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, }; diff --git a/lib/shared/message-utils.test.js b/lib/shared/message-utils.test.js index 548731bc6..59dbe8664 100644 --- a/lib/shared/message-utils.test.js +++ b/lib/shared/message-utils.test.js @@ -1,23 +1,418 @@ // @flow import { isInvalidSidebarSource } from './message-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; -import { values } from '../utils/objects.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'; describe('isInvalidSidebarSource', () => { - it('should return true for all message types except for the ones listed', () => { - values(messageTypes).forEach(messageType => { - const shouldBeInvalidSidebarSource = isInvalidSidebarSource(messageType); - if ( - messageType === messageTypes.REACTION || - messageType === messageTypes.EDIT_MESSAGE || - messageType === messageTypes.SIDEBAR_SOURCE || - messageType === messageTypes.TOGGLE_PIN - ) { - expect(shouldBeInvalidSidebarSource).toBe(true); - } else { - expect(shouldBeInvalidSidebarSource).toBe(false); - } - }); + it('should return false for RawTextMessageInfo', () => { + const textMessageInfo: RawTextMessageInfo = { + type: messageTypes.TEXT, + localID: 'local1', + threadID: '10001', + creatorID: '123', + time: 10000, + text: 'This is a text message', + id: '1', + }; + + const shouldBeInvalidSidebarSource = + isInvalidSidebarSource(textMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawCreateThreadMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = isInvalidSidebarSource( + createThreadMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawAddMembersMessageInfo', () => { + const addMembersMessageInfo: RawAddMembersMessageInfo = { + type: messageTypes.ADD_MEMBERS, + threadID: '10001', + creatorID: '123', + time: 10000, + addedUserIDs: ['4', '5'], + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + addMembersMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawCreateSubthreadMessageInfo', () => { + const createSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { + type: messageTypes.CREATE_SUB_THREAD, + threadID: '10001', + creatorID: '123', + time: 10000, + childThreadID: '10002', + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + createSubthreadMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawChangeSettingsMessageInfo', () => { + const changeSettingsMessageInfo: RawChangeSettingsMessageInfo = { + type: messageTypes.CHANGE_SETTINGS, + threadID: '10000', + creatorID: '123', + time: 10000, + field: 'color', + value: '#FFFFFF', + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + changeSettingsMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawRemoveMembersMessageInfo', () => { + const removeMembersMessageInfo: RawRemoveMembersMessageInfo = { + type: messageTypes.REMOVE_MEMBERS, + threadID: '10000', + creatorID: '123', + time: 10000, + removedUserIDs: ['1', '2', '3'], + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + removeMembersMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawChangeRoleMessageInfo', () => { + const changeRoleMessageinfo: RawChangeRoleMessageInfo = { + type: messageTypes.CHANGE_ROLE, + threadID: '10000', + creatorID: '123', + time: 10000, + userIDs: ['1', '2', '3'], + newRole: '101', + roleName: 'Moderators', + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + changeRoleMessageinfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawLeaveThreadMessageInfo', () => { + const leaveThreadMessageInfo: RawLeaveThreadMessageInfo = { + type: messageTypes.LEAVE_THREAD, + threadID: '10000', + creatorID: '123', + time: 10000, + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + leaveThreadMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawJoinThreadMessageInfo', () => { + const joinThreadMessageInfo: RawJoinThreadMessageInfo = { + type: messageTypes.JOIN_THREAD, + threadID: '10000', + creatorID: '123', + time: 10000, + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + joinThreadMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawCreateEntryMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = isInvalidSidebarSource( + createEntryMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawEditEntryMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = + isInvalidSidebarSource(editEntryMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawDeleteEntryMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = isInvalidSidebarSource( + deleteEntryMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawRestoreEntryMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = isInvalidSidebarSource( + restoreEntryMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawUpdateRelationshipMessageInfo', () => { + const updateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { + type: messageTypes.UPDATE_RELATIONSHIP, + threadID: '10000', + creatorID: '123', + targetID: '456', + time: 10000, + operation: 'request_sent', + id: '1', + }; + + const shouldBeInvalidSidebarSource = isInvalidSidebarSource( + updateRelationshipMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawImagesMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = + isInvalidSidebarSource(imageMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return false for RawMediaMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = + isInvalidSidebarSource(mediaMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return true for RawSidebarSourceMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = isInvalidSidebarSource( + sidebarSourceMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(true); + }); + + it('should return false for RawCreateSidebarMessageInfo', () => { + 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 shouldBeInvalidSidebarSource = isInvalidSidebarSource( + createSidebarMessageInfo, + ); + expect(shouldBeInvalidSidebarSource).toBe(false); + }); + + it('should return true for RawReactionMessageInfo', () => { + const reactionMessageInfo: RawReactionMessageInfo = { + type: messageTypes.REACTION, + localID: 'local1', + threadID: '10001', + creatorID: '123', + time: 10000, + targetMessageID: '1', + reaction: 'like', + action: 'add_reaction', + id: '1', + }; + + const shouldBeInvalidSidebarSource = + isInvalidSidebarSource(reactionMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(true); + }); + + it('should return true for RawEditMessageInfo', () => { + const editMessageInfo: RawEditMessageInfo = { + type: messageTypes.EDIT_MESSAGE, + threadID: '10000', + creatorID: '123', + time: 10000, + targetMessageID: '1', + text: 'This is an edited message', + id: '1', + }; + + const shouldBeInvalidSidebarSource = + isInvalidSidebarSource(editMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(true); + }); + + it('should return true for RawTogglePinMessageInfo', () => { + const togglePinMessageInfo: RawTogglePinMessageInfo = { + type: messageTypes.TOGGLE_PIN, + threadID: '10000', + targetMessageID: '1', + action: 'pin', + pinnedContent: 'a message', + creatorID: '123', + time: 10000, + id: '1', + }; + + const shouldBeInvalidSidebarSource = + isInvalidSidebarSource(togglePinMessageInfo); + expect(shouldBeInvalidSidebarSource).toBe(true); }); }); diff --git a/lib/shared/messages/sidebar-source-message-spec.js b/lib/shared/messages/sidebar-source-message-spec.js index f1331fe1c..eb60a5679 100644 --- a/lib/shared/messages/sidebar-source-message-spec.js +++ b/lib/shared/messages/sidebar-source-message-spec.js @@ -1,173 +1,173 @@ // @flow import invariant from 'invariant'; import { pushTypes, type CreateMessageInfoParams, type MessageSpec, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { type RawSidebarSourceMessageInfo, type SidebarSourceMessageData, type SidebarSourceMessageInfo, type ClientDBMessageInfo, - isMessageSidebarSourceReactionEditOrPin, rawSidebarSourceMessageInfoValidator, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; +import { isInvalidSidebarSource } from '../message-utils.js'; export const sidebarSourceMessageSpec: MessageSpec< SidebarSourceMessageData, RawSidebarSourceMessageInfo, SidebarSourceMessageInfo, > = Object.freeze({ messageContentForServerDB( data: SidebarSourceMessageData | RawSidebarSourceMessageInfo, ): string { const sourceMessageID = data.sourceMessage?.id; invariant(sourceMessageID, 'Source message id should be set'); return JSON.stringify({ sourceMessageID, }); }, messageContentForClientDB(data: RawSidebarSourceMessageInfo): string { invariant( data.sourceMessage && data.sourceMessage.id, 'sourceMessage and sourceMessage.id should be defined', ); return JSON.stringify(data.sourceMessage); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawSidebarSourceMessageInfo { invariant( clientDBMessageInfo.content !== undefined && clientDBMessageInfo.content !== null, 'content must be defined for SidebarSource', ); const sourceMessage = JSON.parse(clientDBMessageInfo.content); const rawSidebarSourceMessageInfo: RawSidebarSourceMessageInfo = { type: messageTypes.SIDEBAR_SOURCE, id: clientDBMessageInfo.id, threadID: clientDBMessageInfo.thread, time: parseInt(clientDBMessageInfo.time), creatorID: clientDBMessageInfo.user, sourceMessage, }; return rawSidebarSourceMessageInfo; }, messageTitle() { invariant(false, 'Cannot call messageTitle on sidebarSourceMessageSpec'); }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawSidebarSourceMessageInfo { const { derivedMessages } = params; invariant(derivedMessages, 'Derived messages should be provided'); const content = JSON.parse(row.content); const sourceMessage = derivedMessages.get(content.sourceMessageID); if (!sourceMessage) { console.warn( `Message with id ${row.id} has a derived message ` + `${content.sourceMessageID} which is not present in the database`, ); } return { type: messageTypes.SIDEBAR_SOURCE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), sourceMessage, }; }, createMessageInfo( rawMessageInfo: RawSidebarSourceMessageInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ): ?SidebarSourceMessageInfo { if (!rawMessageInfo.sourceMessage) { return null; } const sourceMessage = params.createMessageInfoFromRaw( rawMessageInfo.sourceMessage, ); invariant( - sourceMessage && !isMessageSidebarSourceReactionEditOrPin(sourceMessage), + sourceMessage && !isInvalidSidebarSource(sourceMessage), 'Sidebars can not be created from SIDEBAR SOURCE, REACTION, EDIT OR PIN MESSAGE', ); return { type: messageTypes.SIDEBAR_SOURCE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, sourceMessage, }; }, rawMessageInfoFromMessageData( messageData: SidebarSourceMessageData, id: ?string, ): RawSidebarSourceMessageInfo { invariant(id, 'RawSidebarSourceMessageInfo needs id'); return { ...messageData, id }; }, shimUnsupportedMessageInfo( rawMessageInfo: RawSidebarSourceMessageInfo, ): RawSidebarSourceMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.sourceMessage) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'first message in thread', dontPrefixCreator: true, unsupportedMessageInfo: rawMessageInfo, }; }, unshimMessageInfo( unwrapped: RawSidebarSourceMessageInfo, ): RawSidebarSourceMessageInfo { return unwrapped; }, notificationTexts(): Promise { invariant( false, 'SIDEBAR_SOURCE notificationTexts should never be called directly!', ); }, notificationCollapseKey(rawMessageInfo: RawSidebarSourceMessageInfo): string { return joinResult(messageTypes.CREATE_SIDEBAR, rawMessageInfo.threadID); }, generatesNotifs: async () => pushTypes.NOTIF, startsThread: true, validator: rawSidebarSourceMessageInfoValidator, }); diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index ab5cb04f1..26d46ef9f 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1761 +1,1761 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import { getUserAvatarForThread } from '../shared/avatar-utils.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import { type RawThreadInfo, type ThreadInfo, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, type UserProfileThreadInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat( threadInfo: T, ): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: members.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, pinnedCount: 0, }; const userInfos = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( getAllThreadPermissions(null, serverThreadInfo.id), ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { return { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return rawThreadInfo; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers = threadInfo.members.filter( memberInfo => memberInfo.role, ); const memberEntities = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(threadInfo).filter(member => memberHasAdminPowers(member), ).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis.id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: ServerThreadInfo | RawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || - isInvalidSidebarSource(messageInfo.type) + isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; return !!defaultRole.permissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems = [...privateThreads, ...personalThreads, ...otherThreads]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useServerCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(searchText); setUsersSearchResults(usersResults); })(); }, [searchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall(threadInfo.id, [memberInfo.id]), { customKeyName }, ); } function switchMemberAdminRoleInThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, isCurrentlyAdmin: boolean, dispatchActionPromise: DispatchActionPromise, changeUserRoleServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise, ) { let newRole = null; for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( changeThreadMemberRolesActionTypes, changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => (username ? { id, username } : null)) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( threadInfo: RawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys(threadInfo.roles[roleID].permissions); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 6d20c00ab..56659941e 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,655 +1,644 @@ // @flow import invariant from 'invariant'; import t, { type TDict, type TEnums, type TInterface, type TUnion, } from 'tcomb'; import { type ClientDBMediaInfo } from './media-types.js'; import { type MessageType, messageTypes } from './message-types-enum.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from './messages/add-members.js'; import { type ChangeRoleMessageData, type ChangeRoleMessageInfo, type RawChangeRoleMessageInfo, rawChangeRoleMessageInfoValidator, } from './messages/change-role.js'; import { type ChangeSettingsMessageData, type ChangeSettingsMessageInfo, type RawChangeSettingsMessageInfo, rawChangeSettingsMessageInfoValidator, } from './messages/change-settings.js'; import { type CreateEntryMessageData, type CreateEntryMessageInfo, type RawCreateEntryMessageInfo, rawCreateEntryMessageInfoValidator, } from './messages/create-entry.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from './messages/create-sidebar.js'; import { type CreateSubthreadMessageData, type CreateSubthreadMessageInfo, type RawCreateSubthreadMessageInfo, rawCreateSubthreadMessageInfoValidator, } from './messages/create-subthread.js'; import { type CreateThreadMessageData, type CreateThreadMessageInfo, type RawCreateThreadMessageInfo, rawCreateThreadMessageInfoValidator, } from './messages/create-thread.js'; import { type DeleteEntryMessageData, type DeleteEntryMessageInfo, type RawDeleteEntryMessageInfo, rawDeleteEntryMessageInfoValidator, } from './messages/delete-entry.js'; import { type EditEntryMessageData, type EditEntryMessageInfo, type RawEditEntryMessageInfo, rawEditEntryMessageInfoValidator, } from './messages/edit-entry.js'; import { type EditMessageData, type EditMessageInfo, type RawEditMessageInfo, rawEditMessageInfoValidator, } from './messages/edit.js'; import { type ImagesMessageData, type ImagesMessageInfo, type RawImagesMessageInfo, rawImagesMessageInfoValidator, } from './messages/images.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from './messages/join-thread.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from './messages/leave-thread.js'; import { type MediaMessageData, type MediaMessageInfo, type MediaMessageServerDBContent, type RawMediaMessageInfo, rawMediaMessageInfoValidator, } from './messages/media.js'; import { type RawReactionMessageInfo, rawReactionMessageInfoValidator, type ReactionMessageData, type ReactionMessageInfo, } from './messages/reaction.js'; import { type RawRemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, type RemoveMembersMessageData, type RemoveMembersMessageInfo, } from './messages/remove-members.js'; import { type RawRestoreEntryMessageInfo, rawRestoreEntryMessageInfoValidator, type RestoreEntryMessageData, type RestoreEntryMessageInfo, } from './messages/restore-entry.js'; import { type RawTextMessageInfo, rawTextMessageInfoValidator, type TextMessageData, type TextMessageInfo, } from './messages/text.js'; import { type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, type TogglePinMessageData, type TogglePinMessageInfo, } from './messages/toggle-pin.js'; import { type RawUnsupportedMessageInfo, rawUnsupportedMessageInfoValidator, type UnsupportedMessageInfo, } from './messages/unsupported.js'; import { type RawUpdateRelationshipMessageInfo, rawUpdateRelationshipMessageInfoValidator, type UpdateRelationshipMessageData, type UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; import type { CallServerEndpointResultInfoInterface } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; import { tID, tNumber, tShape } from '../utils/validation-utils.js'; 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; } -export function isMessageSidebarSourceReactionEditOrPin( - message: RawMessageInfo | MessageInfo, -): boolean %checks { - return ( - message.type === messageTypes.SIDEBAR_SOURCE || - message.type === messageTypes.REACTION || - message.type === messageTypes.EDIT_MESSAGE || - message.type === messageTypes.TOGGLE_PIN - ); -} - 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 | TogglePinMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export const rawMultimediaMessageInfoValidator: TUnion = t.union([rawImagesMessageInfoValidator, rawMediaMessageInfoValidator]); export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; const rawComposableMessageInfoValidator = t.union([ rawTextMessageInfoValidator, rawMultimediaMessageInfoValidator, ]); export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawTogglePinMessageInfo; const rawRobotextMessageInfoValidator = t.union([ rawCreateThreadMessageInfoValidator, rawAddMembersMessageInfoValidator, rawCreateSubthreadMessageInfoValidator, rawChangeSettingsMessageInfoValidator, rawRemoveMembersMessageInfoValidator, rawChangeRoleMessageInfoValidator, rawLeaveThreadMessageInfoValidator, rawJoinThreadMessageInfoValidator, rawCreateEntryMessageInfoValidator, rawEditEntryMessageInfoValidator, rawDeleteEntryMessageInfoValidator, rawRestoreEntryMessageInfoValidator, rawUpdateRelationshipMessageInfoValidator, rawCreateSidebarMessageInfoValidator, rawUnsupportedMessageInfoValidator, rawTogglePinMessageInfoValidator, ]); export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export const rawSidebarSourceMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.SIDEBAR_SOURCE), threadID: tID, creatorID: t.String, time: t.Number, sourceMessage: t.maybe( t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, ]), ), id: tID, }); export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo | RawReactionMessageInfo | RawEditMessageInfo; export const rawMessageInfoValidator: TUnion = t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, rawSidebarSourceMessageInfoValidator, rawReactionMessageInfoValidator, rawEditMessageInfoValidator, ]); 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 | TogglePinMessageInfo; 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, }; const threadMessageInfoValidator: TInterface = tShape({ messageIDs: t.list(tID), startReached: t.Boolean, }); // 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, }; const localMessageInfoValidator: TInterface = tShape({ sendFailed: t.maybe(t.Boolean), }); export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; const messageStoreThreadsValidator: TDict = t.dict( tID, threadMessageInfoValidator, ); export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: MessageStoreThreads, +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: { +[keyserverID: string]: number }, }; export const messageStoreValidator: TInterface = tShape({ messages: t.dict(tID, rawMessageInfoValidator), threads: messageStoreThreadsValidator, local: t.dict(t.String, localMessageInfoValidator), currentAsOf: t.dict(t.String, t.Number), }); // 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 ClientDBThreadMessageInfo = { +id: string, +start_reached: string, }; 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 const messageTruncationStatusValidator: TEnums = t.enums.of( values(messageTruncationStatus), ); export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export const messageTruncationStatusesValidator: TDict = t.dict(tID, messageTruncationStatusValidator); 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 const messagesResponseValidator: TInterface = tShape({ rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, currentAsOf: t.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 const newMessagesPayloadValidator: TInterface = tShape({ messagesResult: messagesResponseValidator, }); export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; export type FetchPinnedMessagesRequest = { +threadID: string, }; export type FetchPinnedMessagesResult = { +pinnedMessages: $ReadOnlyArray, }; export type SearchMessagesRequest = { +query: string, +threadID: string, +cursor?: string, }; export type SearchMessagesResponse = { +messages: $ReadOnlyArray, +endReached: boolean, };