diff --git a/keyserver/src/creators/message-creator.js b/keyserver/src/creators/message-creator.js index 0155ab28e..627f08b58 100644 --- a/keyserver/src/creators/message-creator.js +++ b/keyserver/src/creators/message-creator.js @@ -1,690 +1,690 @@ // @flow import invariant from 'invariant'; import _pickBy from 'lodash/fp/pickBy.js'; import { permissionLookup } from 'lib/permissions/thread-permissions.js'; import { rawMessageInfoFromMessageData, shimUnsupportedRawMessageInfos, stripLocalIDs, } from 'lib/shared/message-utils.js'; import { pushTypes } from 'lib/shared/messages/message-spec.js'; import type { PushType } from 'lib/shared/messages/message-spec.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { messageDataLocalID, type MessageData, type RawMessageInfo, } from 'lib/types/message-types.js'; import { redisMessageTypes } from 'lib/types/redis-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { promiseAll } from 'lib/utils/promises.js'; import createIDs from './id-creator.js'; import type { UpdatesForCurrentSession } from './update-creator.js'; import { createUpdates } from './update-creator.js'; import { dbQuery, SQL, appendSQLArray, mergeOrConditions, } from '../database/database.js'; import { processMessagesForSearch } from '../database/search-utils.js'; import { fetchMessageInfoForLocalID, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchOtherSessionsForViewer } from '../fetchers/session-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { sendPushNotifs, sendRescindNotifs } from '../push/send.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; import { earliestFocusedTimeConsideredExpired } from '../shared/focused-times.js'; import { publisher } from '../socket/redis.js'; import { creationString } from '../utils/idempotent.js'; type UserThreadInfo = { +devices: Map< string, { +platform: string, +deviceToken: string, +codeVersion: ?string, }, >, +threadIDs: Set, +notFocusedThreadIDs: Set, +userNotMemberOfSubthreads: Set, +subthreadsCanSetToUnread: Set, }; type LatestMessagesPerUser = Map< string, $ReadOnlyMap< string, { +latestMessage: string, +latestReadMessage?: string, }, >, >; type LatestMessages = $ReadOnlyArray<{ +userID: string, +threadID: string, +latestMessage: string, +latestReadMessage: ?string, }>; // Does not do permission checks! (checkThreadPermission) async function createMessages( viewer: Viewer, messageDatas: $ReadOnlyArray, updatesForCurrentSession?: UpdatesForCurrentSession = 'return', ): Promise { if (messageDatas.length === 0) { return []; } const existingMessages = await Promise.all( messageDatas.map(messageData => fetchMessageInfoForLocalID(viewer, messageDataLocalID(messageData)), ), ); const existingMessageInfos: RawMessageInfo[] = []; const newMessageDatas: MessageData[] = []; for (let i = 0; i < messageDatas.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { existingMessageInfos.push(existingMessage); } else { newMessageDatas.push(messageDatas[i]); } } if (newMessageDatas.length === 0) { return shimUnsupportedRawMessageInfos( existingMessageInfos, viewer.platformDetails, ); } const ids = await createIDs('messages', newMessageDatas.length); const returnMessageInfos: RawMessageInfo[] = []; const subthreadPermissionsToCheck: Set = new Set(); const messageInsertRows = []; // Indices in threadsToMessageIndices point to newMessageInfos const newMessageInfos: RawMessageInfo[] = []; const threadsToMessageIndices: Map = new Map(); let nextNewMessageIndex = 0; for (let i = 0; i < messageDatas.length; i++) { const existingMessage = existingMessages[i]; if (existingMessage) { returnMessageInfos.push(existingMessage); continue; } const messageData = messageDatas[i]; const threadID = messageData.threadID; const creatorID = messageData.creatorID; let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } const newMessageIndex = nextNewMessageIndex++; messageIndices.push(newMessageIndex); const serverID = ids[newMessageIndex]; if (messageData.type === messageTypes.CREATE_SUB_THREAD) { subthreadPermissionsToCheck.add(messageData.childThreadID); } const content = messageSpecs[messageData.type].messageContentForServerDB?.(messageData); const creation = messageData.localID && viewer.hasSessionInfo ? creationString(viewer, messageData.localID) : null; let targetMessageID = null; if (messageData.targetMessageID) { targetMessageID = messageData.targetMessageID; } else if (messageData.sourceMessage) { targetMessageID = messageData.sourceMessage.id; } messageInsertRows.push([ serverID, threadID, creatorID, messageData.type, content, messageData.time, creation, targetMessageID, ]); const rawMessageInfo = rawMessageInfoFromMessageData(messageData, serverID); newMessageInfos.push(rawMessageInfo); // at newMessageIndex returnMessageInfos.push(rawMessageInfo); // at i } const messageInsertQuery = SQL` INSERT INTO messages(id, thread, user, type, content, time, creation, target_message) VALUES ${messageInsertRows} `; const messageInsertPromise = dbQuery(messageInsertQuery); const postMessageSendPromise = postMessageSend( viewer, threadsToMessageIndices, subthreadPermissionsToCheck, stripLocalIDs(newMessageInfos), newMessageDatas, updatesForCurrentSession, ); if (!viewer.isScriptViewer) { // If we're not being called from a script, then we avoid awaiting // postMessageSendPromise below so that we don't delay the response to the // user on external services. In that case, we use handleAsyncPromise to // make sure any exceptions are caught and logged. handleAsyncPromise(postMessageSendPromise); } await Promise.all([ messageInsertPromise, updateRepliesCount(threadsToMessageIndices, newMessageDatas), viewer.isScriptViewer ? postMessageSendPromise : undefined, ]); if (updatesForCurrentSession !== 'return') { return []; } return shimUnsupportedRawMessageInfos( returnMessageInfos, viewer.platformDetails, ); } async function updateRepliesCount( threadsToMessageIndices: Map, newMessageDatas: MessageData[], ) { const updatedThreads = []; const updateThreads = SQL` UPDATE threads SET replies_count = replies_count + (CASE `; const membershipConditions = []; for (const [threadID, messages] of threadsToMessageIndices.entries()) { const newRepliesIncrease = messages .map(i => newMessageDatas[i].type) .filter(type => messageSpecs[type].includedInRepliesCount).length; if (newRepliesIncrease === 0) { continue; } updateThreads.append(SQL` WHEN id = ${threadID} THEN ${newRepliesIncrease} `); updatedThreads.push(threadID); const senders = messages.map(i => newMessageDatas[i].creatorID); membershipConditions.push( SQL`thread = ${threadID} AND user IN (${senders})`, ); } updateThreads.append(SQL` ELSE 0 END) WHERE id IN (${updatedThreads}) AND source_message IS NOT NULL `); const updateMemberships = SQL` UPDATE memberships SET sender = 1 WHERE sender = 0 AND ( `; updateMemberships.append(mergeOrConditions(membershipConditions)); updateMemberships.append(SQL` ) `); if (updatedThreads.length > 0) { const [{ threadInfos: serverThreadInfos }] = await Promise.all([ fetchServerThreadInfos(SQL`t.id IN (${updatedThreads})`), dbQuery(updateThreads), dbQuery(updateMemberships), ]); const time = Date.now(); const updates = []; for (const threadID in serverThreadInfos) { for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } } await createUpdates(updates); } } // Handles: // (1) Sending push notifs // (2) Setting threads to unread and generating corresponding UpdateInfos // (3) Publishing to Redis so that active sockets pass on new messages // (4) Processing messages for search async function postMessageSend( viewer: Viewer, threadsToMessageIndices: Map, subthreadPermissionsToCheck: Set, messageInfos: RawMessageInfo[], messageDatas: MessageData[], updatesForCurrentSession: UpdatesForCurrentSession, ) { const processForSearch = processMessagesForSearch(messageInfos); let joinIndex = 0; let subthreadSelects = ''; const subthreadJoins = []; for (const subthread of subthreadPermissionsToCheck) { const index = joinIndex++; subthreadSelects += ` , stm${index}.permissions AS subthread${subthread}_permissions, stm${index}.role AS subthread${subthread}_role `; const join = SQL`LEFT JOIN memberships `; join.append(`stm${index} ON stm${index}.`); join.append(SQL`thread = ${subthread} AND `); join.append(`stm${index}.user = m.user`); subthreadJoins.push(join); } const time = earliestFocusedTimeConsideredExpired(); const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT m.user, m.thread, c.platform, c.device_token, c.versions, f.user AS focused_user `; query.append(subthreadSelects); query.append(SQL` FROM memberships m LEFT JOIN cookies c ON c.user = m.user AND c.device_token IS NOT NULL LEFT JOIN focused f ON f.user = m.user AND f.thread = m.thread AND f.time > ${time} `); appendSQLArray(query, subthreadJoins, SQL` `); query.append(SQL` WHERE (m.role > 0 OR f.user IS NOT NULL) AND JSON_EXTRACT(m.permissions, ${visibleExtractString}) IS TRUE AND m.thread IN (${[...threadsToMessageIndices.keys()]}) `); const perUserInfo = new Map(); const [result] = await dbQuery(query); for (const row of result) { const userID = row.user.toString(); const threadID = row.thread.toString(); const deviceToken = row.device_token; const focusedUser = !!row.focused_user; const { platform } = row; const versions = JSON.parse(row.versions); let thisUserInfo = perUserInfo.get(userID); if (!thisUserInfo) { thisUserInfo = { devices: new Map(), threadIDs: new Set(), notFocusedThreadIDs: new Set(), userNotMemberOfSubthreads: new Set(), subthreadsCanSetToUnread: new Set(), }; perUserInfo.set(userID, thisUserInfo); // Subthread info will be the same for each subthread, so we only parse // it once for (const subthread of subthreadPermissionsToCheck) { const isSubthreadMember = row[`subthread${subthread}_role`] > 0; const rawSubthreadPermissions = row[`subthread${subthread}_permissions`]; const subthreadPermissions = JSON.parse(rawSubthreadPermissions); const canSeeSubthread = permissionLookup( subthreadPermissions, threadPermissions.KNOW_OF, ); if (!canSeeSubthread) { continue; } thisUserInfo.subthreadsCanSetToUnread.add(subthread); // Only include the notification from the superthread if there is no // notification from the subthread if ( !isSubthreadMember || !permissionLookup(subthreadPermissions, threadPermissions.VISIBLE) ) { thisUserInfo.userNotMemberOfSubthreads.add(subthread); } } } if (deviceToken) { thisUserInfo.devices.set(deviceToken, { platform, deviceToken, codeVersion: versions ? versions.codeVersion : null, }); } thisUserInfo.threadIDs.add(threadID); if (!focusedUser) { thisUserInfo.notFocusedThreadIDs.add(threadID); } } const messageInfosPerUser = {}; const latestMessagesPerUser: LatestMessagesPerUser = new Map(); const userPushInfoPromises = {}; const userRescindInfoPromises = {}; for (const pair of perUserInfo) { const [userID, preUserPushInfo] = pair; const userMessageInfos = []; for (const threadID of preUserPushInfo.threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; userMessageInfos.push(messageInfo); } } if (userMessageInfos.length > 0) { messageInfosPerUser[userID] = userMessageInfos; } latestMessagesPerUser.set( userID, determineLatestMessagesPerThread( preUserPushInfo, userID, threadsToMessageIndices, messageInfos, ), ); const { userNotMemberOfSubthreads } = preUserPushInfo; const userDevices = [...preUserPushInfo.devices.values()]; if (userDevices.length === 0) { continue; } const generateNotifUserInfoPromise = async (pushType: PushType) => { const promises = []; for (const threadID of preUserPushInfo.notFocusedThreadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant( messageIndices, `indices should exist for thread ${threadID}`, ); promises.push( ...messageIndices.map(async messageIndex => { const messageInfo = messageInfos[messageIndex]; const { type } = messageInfo; if (messageInfo.creatorID === userID) { // We never send a user notifs about their own activity return undefined; } const { generatesNotifs } = messageSpecs[type]; const messageData = messageDatas[messageIndex]; const doesGenerateNotif = await generatesNotifs( messageInfo, messageData, { notifTargetUserID: userID, userNotMemberOfSubthreads, fetchMessageInfoByID: (messageID: string) => fetchMessageInfoByID(viewer, messageID), }, ); return doesGenerateNotif === pushType ? { messageInfo, messageData } : undefined; }), ); } const messagesToNotify = await Promise.all(promises); const filteredMessagesToNotify = messagesToNotify.filter(Boolean); if (filteredMessagesToNotify.length === 0) { return undefined; } return { devices: userDevices, messageInfos: filteredMessagesToNotify.map( ({ messageInfo }) => messageInfo, ), messageDatas: filteredMessagesToNotify.map( ({ messageData }) => messageData, ), }; }; const userPushInfoPromise = generateNotifUserInfoPromise(pushTypes.NOTIF); const userRescindInfoPromise = generateNotifUserInfoPromise( pushTypes.RESCIND, ); userPushInfoPromises[userID] = userPushInfoPromise; userRescindInfoPromises[userID] = userRescindInfoPromise; } const latestMessages = flattenLatestMessagesPerUser(latestMessagesPerUser); const [pushInfo, rescindInfo] = await Promise.all([ promiseAll(userPushInfoPromises), promiseAll(userRescindInfoPromises), createReadStatusUpdates(latestMessages), redisPublish(viewer, messageInfosPerUser, updatesForCurrentSession), updateLatestMessages(latestMessages), processForSearch, ]); await Promise.all([ sendPushNotifs(_pickBy(Boolean)(pushInfo)), sendRescindNotifs(_pickBy(Boolean)(rescindInfo)), ]); } async function redisPublish( viewer: Viewer, messageInfosPerUser: { [userID: string]: $ReadOnlyArray }, updatesForCurrentSession: UpdatesForCurrentSession, ) { const avoidBroadcastingToCurrentSession = viewer.hasSessionInfo && updatesForCurrentSession !== 'broadcast'; for (const userID in messageInfosPerUser) { if (userID === viewer.userID && avoidBroadcastingToCurrentSession) { continue; } const messageInfos = messageInfosPerUser[userID]; publisher.sendMessage( { userID }, { type: redisMessageTypes.NEW_MESSAGES, messages: messageInfos, }, ); } const viewerMessageInfos = messageInfosPerUser[viewer.userID]; if (!viewerMessageInfos || !avoidBroadcastingToCurrentSession) { return; } const sessionIDs = await fetchOtherSessionsForViewer(viewer); for (const sessionID of sessionIDs) { publisher.sendMessage( { userID: viewer.userID, sessionID }, { type: redisMessageTypes.NEW_MESSAGES, messages: viewerMessageInfos, }, ); } } function determineLatestMessagesPerThread( preUserPushInfo: UserThreadInfo, userID: string, threadsToMessageIndices: $ReadOnlyMap>, messageInfos: $ReadOnlyArray, ) { const { threadIDs, notFocusedThreadIDs, subthreadsCanSetToUnread } = preUserPushInfo; const latestMessagesPerThread = new Map(); for (const threadID of threadIDs) { const messageIndices = threadsToMessageIndices.get(threadID); invariant(messageIndices, `indices should exist for thread ${threadID}`); for (const messageIndex of messageIndices) { const messageInfo = messageInfos[messageIndex]; if ( messageInfo.type === messageTypes.CREATE_SUB_THREAD && !subthreadsCanSetToUnread.has(messageInfo.childThreadID) ) { continue; } const messageID = messageInfo.id; invariant( messageID, 'message ID should exist in determineLatestMessagesPerThread', ); if ( notFocusedThreadIDs.has(threadID) && messageInfo.creatorID !== userID ) { latestMessagesPerThread.set(threadID, { latestMessage: messageID, }); } else { latestMessagesPerThread.set(threadID, { latestMessage: messageID, latestReadMessage: messageID, }); } } } return latestMessagesPerThread; } function flattenLatestMessagesPerUser( latestMessagesPerUser: LatestMessagesPerUser, ): LatestMessages { const result = []; for (const [userID, latestMessagesPerThread] of latestMessagesPerUser) { for (const [threadID, latestMessages] of latestMessagesPerThread) { result.push({ userID, threadID, latestMessage: latestMessages.latestMessage, latestReadMessage: latestMessages.latestReadMessage, }); } } return result; } async function createReadStatusUpdates(latestMessages: LatestMessages) { const now = Date.now(); const readStatusUpdates = latestMessages .filter(message => !message.latestReadMessage) .map(({ userID, threadID }) => ({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID, time: now, threadID, unread: true, })); if (readStatusUpdates.length === 0) { return; } await createUpdates(readStatusUpdates); } function updateLatestMessages(latestMessages: LatestMessages) { if (latestMessages.length === 0) { return; } const query = SQL` UPDATE memberships SET `; const lastMessageExpression = SQL` last_message = GREATEST(last_message, CASE `; const lastReadMessageExpression = SQL` , last_read_message = GREATEST(last_read_message, CASE `; let shouldUpdateLastReadMessage = false; for (const { userID, threadID, latestMessage, latestReadMessage, } of latestMessages) { lastMessageExpression.append(SQL` WHEN user = ${userID} AND thread = ${threadID} THEN ${latestMessage} `); if (latestReadMessage) { shouldUpdateLastReadMessage = true; lastReadMessageExpression.append(SQL` WHEN user = ${userID} AND thread = ${threadID} THEN ${latestReadMessage} `); } } lastMessageExpression.append(SQL` ELSE last_message END) `); lastReadMessageExpression.append(SQL` ELSE last_read_message END) `); const conditions = latestMessages.map( ({ userID, threadID }) => SQL`(user = ${userID} AND thread = ${threadID})`, ); query.append(lastMessageExpression); if (shouldUpdateLastReadMessage) { query.append(lastReadMessageExpression); } query.append(SQL`WHERE `); query.append(mergeOrConditions(conditions)); dbQuery(query); } export default createMessages; diff --git a/keyserver/src/creators/update-creator.js b/keyserver/src/creators/update-creator.js index 3f0b3d632..e5d4df5e9 100644 --- a/keyserver/src/creators/update-creator.js +++ b/keyserver/src/creators/update-creator.js @@ -1,794 +1,794 @@ // @flow import invariant from 'invariant'; import { nonThreadCalendarFilters } from 'lib/selectors/calendar-filter-selectors.js'; import { keyForUpdateData, keyForUpdateInfo, rawUpdateInfoFromUpdateData, } from 'lib/shared/update-utils.js'; import { type RawEntryInfo, type FetchEntryInfosBase, type CalendarQuery, defaultCalendarQuery, } from 'lib/types/entry-types.js'; import { defaultNumberPerThread, type FetchMessageInfosResult, } from 'lib/types/message-types.js'; import { type UpdateTarget, redisMessageTypes, type NewUpdatesRedisMessage, } from 'lib/types/redis-types.js'; import type { RawThreadInfo } from 'lib/types/thread-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { type ServerUpdateInfo, type UpdateData, type RawUpdateInfo, type CreateUpdatesResult, - updateTypes, } from 'lib/types/update-types.js'; import type { UserInfos, LoggedInUserInfo, OldLoggedInUserInfo, } from 'lib/types/user-types.js'; import { promiseAll } from 'lib/utils/promises.js'; import createIDs from './id-creator.js'; import { dbQuery, SQL, mergeAndConditions } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { deleteUpdatesByConditions } from '../deleters/update-deleters.js'; import { fetchEntryInfos, fetchEntryInfosByID, } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers.js'; import { fetchKnownUserInfos, fetchCurrentUserInfo, } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { channelNameForUpdateTarget, publisher } from '../socket/redis.js'; export type UpdatesForCurrentSession = // This is the default if no Viewer is passed, or if an isSocket Viewer is // passed in. We will broadcast to all valid sessions via Redis and return // nothing to the caller, relying on the current session's Redis listener to // pick up the updates and deliver them asynchronously. | 'broadcast' // This is the default if a non-isSocket Viewer is passed in. We avoid // broadcasting the update to the current session, and instead return the // update to the caller, who will handle delivering it to the client. | 'return' // This means we ignore any updates destined for the current session. // Presumably the caller knows what they are doing and has a different way of // communicating the relevant information to the client. | 'ignore'; type DeleteCondition = { +userID: string, +target: ?string, +types: 'all_types' | $ReadOnlySet, }; export type ViewerInfo = | { viewer: Viewer, calendarQuery?: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, } | { viewer: Viewer, calendarQuery: ?CalendarQuery, updatesForCurrentSession?: UpdatesForCurrentSession, threadInfos: { +[id: string]: RawThreadInfo }, }; const defaultUpdateCreationResult = { viewerUpdates: [], userInfos: {} }; const sortFunction = ( a: UpdateData | ServerUpdateInfo, b: UpdateData | ServerUpdateInfo, ) => a.time - b.time; const deleteUpdatesBatchSize = 500; // Creates rows in the updates table based on the inputed updateDatas. Returns // UpdateInfos pertaining to the provided viewerInfo, as well as related // UserInfos. If no viewerInfo is provided, no UpdateInfos will be returned. And // the update row won't have an updater column, meaning no session will be // excluded from the update. async function createUpdates( updateDatas: $ReadOnlyArray, passedViewerInfo?: ?ViewerInfo, ): Promise { if (updateDatas.length === 0) { return defaultUpdateCreationResult; } // viewer.session will throw for a script Viewer let viewerInfo = passedViewerInfo; if ( viewerInfo && (viewerInfo.viewer.isScriptViewer || !viewerInfo.viewer.loggedIn) ) { viewerInfo = null; } const sortedUpdateDatas = [...updateDatas].sort(sortFunction); const filteredUpdateDatas: UpdateData[] = []; const keyedUpdateDatas: Map = new Map(); for (const updateData of sortedUpdateDatas) { const key = keyForUpdateData(updateData); if (!key) { filteredUpdateDatas.push(updateData); continue; } const conditionKey = `${updateData.userID}|${key}`; const deleteCondition = getDeleteCondition(updateData); invariant( deleteCondition, `updateData of type ${updateData.type} has conditionKey ` + `${conditionKey} but no deleteCondition`, ); const curUpdateDatas = keyedUpdateDatas.get(conditionKey); if (!curUpdateDatas) { keyedUpdateDatas.set(conditionKey, [updateData]); continue; } const filteredCurrent = curUpdateDatas.filter(curUpdateData => filterOnDeleteCondition(curUpdateData, deleteCondition), ); if (filteredCurrent.length === 0) { keyedUpdateDatas.set(conditionKey, [updateData]); continue; } const isNewUpdateDataFiltered = !filteredCurrent.every(curUpdateData => { const curDeleteCondition = getDeleteCondition(curUpdateData); invariant( curDeleteCondition, `updateData of type ${curUpdateData.type} is in keyedUpdateDatas ` + "but doesn't have a deleteCondition", ); return filterOnDeleteCondition(updateData, curDeleteCondition); }); if (!isNewUpdateDataFiltered) { filteredCurrent.push(updateData); } keyedUpdateDatas.set(conditionKey, filteredCurrent); } for (const keyUpdateDatas of keyedUpdateDatas.values()) { filteredUpdateDatas.push(...keyUpdateDatas); } const ids = await createIDs('updates', filteredUpdateDatas.length); let updatesForCurrentSession = viewerInfo && viewerInfo.updatesForCurrentSession; if (!updatesForCurrentSession && viewerInfo) { updatesForCurrentSession = viewerInfo.viewer.isSocket ? 'broadcast' : 'return'; } else if (!updatesForCurrentSession) { updatesForCurrentSession = 'broadcast'; } const dontBroadcastSession = updatesForCurrentSession !== 'broadcast' && viewerInfo ? viewerInfo.viewer.session : null; const publishInfos: Map = new Map(); const viewerRawUpdateInfos: RawUpdateInfo[] = []; const insertRows: (?(number | string))[][] = []; const earliestTime: Map = new Map(); for (let i = 0; i < filteredUpdateDatas.length; i++) { const updateData = filteredUpdateDatas[i]; let content; if (updateData.type === updateTypes.DELETE_ACCOUNT) { content = JSON.stringify({ deletedUserID: updateData.deletedUserID }); } else if (updateData.type === updateTypes.UPDATE_THREAD) { content = JSON.stringify({ threadID: updateData.threadID }); } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { const { threadID, unread } = updateData; content = JSON.stringify({ threadID, unread }); } else if ( updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { const { threadID } = updateData; content = JSON.stringify({ threadID }); } else if (updateData.type === updateTypes.BAD_DEVICE_TOKEN) { const { deviceToken } = updateData; content = JSON.stringify({ deviceToken }); } else if (updateData.type === updateTypes.UPDATE_ENTRY) { const { entryID } = updateData; content = JSON.stringify({ entryID }); } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { // user column contains all the info we need to construct the UpdateInfo content = null; } else if (updateData.type === updateTypes.UPDATE_USER) { const { updatedUserID } = updateData; content = JSON.stringify({ updatedUserID }); } else { invariant(false, `unrecognized updateType ${updateData.type}`); } const target = getTargetFromUpdateData(updateData); const rawUpdateInfo = rawUpdateInfoFromUpdateData(updateData, ids[i]); if (!target || !dontBroadcastSession || target !== dontBroadcastSession) { const updateTarget = target ? { userID: updateData.userID, sessionID: target } : { userID: updateData.userID }; const channelName = channelNameForUpdateTarget(updateTarget); let publishInfo = publishInfos.get(channelName); if (!publishInfo) { publishInfo = { updateTarget, rawUpdateInfos: [] }; publishInfos.set(channelName, publishInfo); } publishInfo.rawUpdateInfos.push(rawUpdateInfo); } if ( updatesForCurrentSession === 'return' && viewerInfo && updateData.userID === viewerInfo.viewer.id && (!target || target === viewerInfo.viewer.session) ) { viewerRawUpdateInfos.push(rawUpdateInfo); } if (viewerInfo && target && viewerInfo.viewer.session === target) { // In the case where this update is being created only for the current // session, there's no reason to insert a row into the updates table continue; } const key = keyForUpdateData(updateData); if (key) { const conditionKey = `${updateData.userID}|${key}`; const currentEarliestTime = earliestTime.get(conditionKey); if (!currentEarliestTime || updateData.time < currentEarliestTime) { earliestTime.set(conditionKey, updateData.time); } } const insertRow = [ ids[i], updateData.userID, updateData.type, key, content, updateData.time, dontBroadcastSession, target, ]; insertRows.push(insertRow); } type DeleteUpdatesConditions = { key: string, target?: string, types?: number[], time?: number, }; const usersByConditions: Map< string, { conditions: DeleteUpdatesConditions, users: Set, }, > = new Map(); for (const [conditionKey, keyUpdateDatas] of keyedUpdateDatas) { const deleteConditionByTarget: Map = new Map(); for (const updateData of keyUpdateDatas) { const deleteCondition = getDeleteCondition(updateData); invariant( deleteCondition, `updateData of type ${updateData.type} is in keyedUpdateDatas but ` + "doesn't have a deleteCondition", ); const { target, types } = deleteCondition; const existingDeleteCondition = deleteConditionByTarget.get(target); if (!existingDeleteCondition) { deleteConditionByTarget.set(target, deleteCondition); continue; } const existingTypes = existingDeleteCondition.types; if (existingTypes === 'all_types') { continue; } else if (types === 'all_types') { deleteConditionByTarget.set(target, deleteCondition); continue; } const mergedTypes = new Set([...types, ...existingTypes]); deleteConditionByTarget.set(target, { ...deleteCondition, types: mergedTypes, }); } for (const deleteCondition of deleteConditionByTarget.values()) { const { userID, target, types } = deleteCondition; const key = conditionKey.split('|')[1]; const conditions: DeleteUpdatesConditions = { key }; if (target) { conditions.target = target; } if (types !== 'all_types') { invariant(types.size > 0, 'deleteCondition had empty types set'); conditions.types = [...types]; } const earliestTimeForCondition = earliestTime.get(conditionKey); if (earliestTimeForCondition) { conditions.time = earliestTimeForCondition; } const conditionsKey = JSON.stringify(conditions); if (!usersByConditions.has(conditionsKey)) { usersByConditions.set(conditionsKey, { conditions, users: new Set(), }); } usersByConditions.get(conditionsKey)?.users.add(userID); } } const deleteSQLConditions: SQLStatementType[] = []; for (const { conditions, users } of usersByConditions.values()) { const sqlConditions = [ SQL`u.user IN (${[...users]})`, SQL`u.key = ${conditions.key}`, ]; if (conditions.target) { sqlConditions.push(SQL`u.target = ${conditions.target}`); } if (conditions.types) { sqlConditions.push(SQL`u.type IN (${conditions.types})`); } if (conditions.time) { sqlConditions.push(SQL`u.time < ${conditions.time}`); } deleteSQLConditions.push(mergeAndConditions(sqlConditions)); } const promises = {}; if (insertRows.length > 0) { const insertQuery = SQL` INSERT INTO updates(id, user, type, \`key\`, content, time, updater, target) `; insertQuery.append(SQL`VALUES ${insertRows}`); promises.insert = dbQuery(insertQuery); } if (publishInfos.size > 0) { promises.redis = redisPublish(publishInfos.values(), dontBroadcastSession); } if (deleteSQLConditions.length > 0) { promises.delete = (async () => { while (deleteSQLConditions.length > 0) { const batch = deleteSQLConditions.splice(0, deleteUpdatesBatchSize); await deleteUpdatesByConditions(batch); } })(); } if (viewerRawUpdateInfos.length > 0) { invariant(viewerInfo, 'should be set'); promises.updatesResult = fetchUpdateInfosWithRawUpdateInfos( viewerRawUpdateInfos, viewerInfo, ); } const { updatesResult } = await promiseAll(promises); if (!updatesResult) { return defaultUpdateCreationResult; } const { updateInfos, userInfos } = updatesResult; return { viewerUpdates: updateInfos, userInfos }; } export type FetchUpdatesResult = { +updateInfos: $ReadOnlyArray, +userInfos: UserInfos, }; async function fetchUpdateInfosWithRawUpdateInfos( rawUpdateInfos: $ReadOnlyArray, viewerInfo: ViewerInfo, ): Promise { const { viewer } = viewerInfo; const threadIDsNeedingFetch = new Set(); const entryIDsNeedingFetch = new Set(); let currentUserNeedsFetch = false; const threadIDsNeedingDetailedFetch = new Set(); // entries and messages for (const rawUpdateInfo of rawUpdateInfos) { if ( !viewerInfo.threadInfos && (rawUpdateInfo.type === updateTypes.UPDATE_THREAD || rawUpdateInfo.type === updateTypes.JOIN_THREAD) ) { threadIDsNeedingFetch.add(rawUpdateInfo.threadID); } if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) { threadIDsNeedingDetailedFetch.add(rawUpdateInfo.threadID); } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) { entryIDsNeedingFetch.add(rawUpdateInfo.entryID); } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) { currentUserNeedsFetch = true; } } const promises = {}; if (!viewerInfo.threadInfos && threadIDsNeedingFetch.size > 0) { promises.threadResult = fetchThreadInfos( viewer, SQL`t.id IN (${[...threadIDsNeedingFetch]})`, ); } let calendarQuery: ?CalendarQuery = viewerInfo.calendarQuery ? viewerInfo.calendarQuery : null; if (!calendarQuery && viewer.hasSessionInfo) { // This should only ever happen for "legacy" clients who call in without // providing this information. These clients wouldn't know how to deal with // the corresponding UpdateInfos anyways, so no reason to be worried. calendarQuery = viewer.calendarQuery; } else if (!calendarQuery) { calendarQuery = defaultCalendarQuery(viewer.platform, viewer.timeZone); } if (threadIDsNeedingDetailedFetch.size > 0) { const messageSelectionCriteria = { threadCursors: {} }; for (const threadID of threadIDsNeedingDetailedFetch) { messageSelectionCriteria.threadCursors[threadID] = false; } promises.messageInfosResult = fetchMessageInfos( viewer, messageSelectionCriteria, defaultNumberPerThread, ); const threadCalendarQuery = { ...calendarQuery, filters: [ ...nonThreadCalendarFilters(calendarQuery.filters), { type: 'threads', threadIDs: [...threadIDsNeedingDetailedFetch] }, ], }; promises.calendarResult = fetchEntryInfos(viewer, [threadCalendarQuery]); } if (entryIDsNeedingFetch.size > 0) { promises.entryInfosResult = fetchEntryInfosByID(viewer, [ ...entryIDsNeedingFetch, ]); } if (currentUserNeedsFetch) { promises.currentUserInfoResult = (async () => { const currentUserInfo = await fetchCurrentUserInfo(viewer); invariant(currentUserInfo.anonymous === undefined, 'should be logged in'); return currentUserInfo; })(); } const { threadResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfoResult, } = await promiseAll(promises); let threadInfosResult; if (viewerInfo.threadInfos) { const { threadInfos } = viewerInfo; threadInfosResult = { threadInfos }; } else if (threadResult) { threadInfosResult = threadResult; } else { threadInfosResult = { threadInfos: {} }; } return await updateInfosFromRawUpdateInfos(viewer, rawUpdateInfos, { threadInfosResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfoResult, }); } export type UpdateInfosRawData = { threadInfosResult: FetchThreadInfosResult, messageInfosResult: ?FetchMessageInfosResult, calendarResult: ?FetchEntryInfosBase, entryInfosResult: ?$ReadOnlyArray, currentUserInfoResult: ?OldLoggedInUserInfo | LoggedInUserInfo, }; async function updateInfosFromRawUpdateInfos( viewer: Viewer, rawUpdateInfos: $ReadOnlyArray, rawData: UpdateInfosRawData, ): Promise { const { threadInfosResult, messageInfosResult, calendarResult, entryInfosResult, currentUserInfoResult, } = rawData; const updateInfos = []; const userIDsToFetch = new Set(); for (const rawUpdateInfo of rawUpdateInfos) { if (rawUpdateInfo.type === updateTypes.DELETE_ACCOUNT) { updateInfos.push({ type: updateTypes.DELETE_ACCOUNT, id: rawUpdateInfo.id, time: rawUpdateInfo.time, deletedUserID: rawUpdateInfo.deletedUserID, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD) { const threadInfo = threadInfosResult.threadInfos[rawUpdateInfo.threadID]; if (!threadInfo) { console.warn( "failed to hydrate updateTypes.UPDATE_THREAD because we couldn't " + `fetch RawThreadInfo for ${rawUpdateInfo.threadID}`, ); continue; } updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS) { updateInfos.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadID: rawUpdateInfo.threadID, unread: rawUpdateInfo.unread, }); } else if (rawUpdateInfo.type === updateTypes.DELETE_THREAD) { updateInfos.push({ type: updateTypes.DELETE_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadID: rawUpdateInfo.threadID, }); } else if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) { const threadInfo = threadInfosResult.threadInfos[rawUpdateInfo.threadID]; if (!threadInfo) { console.warn( "failed to hydrate updateTypes.JOIN_THREAD because we couldn't " + `fetch RawThreadInfo for ${rawUpdateInfo.threadID}`, ); continue; } const rawEntryInfos = []; invariant(calendarResult, 'should be set'); for (const entryInfo of calendarResult.rawEntryInfos) { if (entryInfo.threadID === rawUpdateInfo.threadID) { rawEntryInfos.push(entryInfo); } } const rawMessageInfos = []; invariant(messageInfosResult, 'should be set'); for (const messageInfo of messageInfosResult.rawMessageInfos) { if (messageInfo.threadID === rawUpdateInfo.threadID) { rawMessageInfos.push(messageInfo); } } updateInfos.push({ type: updateTypes.JOIN_THREAD, id: rawUpdateInfo.id, time: rawUpdateInfo.time, threadInfo, rawMessageInfos, truncationStatus: messageInfosResult.truncationStatuses[rawUpdateInfo.threadID], rawEntryInfos, }); } else if (rawUpdateInfo.type === updateTypes.BAD_DEVICE_TOKEN) { updateInfos.push({ type: updateTypes.BAD_DEVICE_TOKEN, id: rawUpdateInfo.id, time: rawUpdateInfo.time, deviceToken: rawUpdateInfo.deviceToken, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) { invariant(entryInfosResult, 'should be set'); const entryInfo = entryInfosResult.find( candidate => candidate.id === rawUpdateInfo.entryID, ); if (!entryInfo) { console.warn( "failed to hydrate updateTypes.UPDATE_ENTRY because we couldn't " + `fetch RawEntryInfo for ${rawUpdateInfo.entryID}`, ); continue; } updateInfos.push({ type: updateTypes.UPDATE_ENTRY, id: rawUpdateInfo.id, time: rawUpdateInfo.time, entryInfo, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) { invariant(currentUserInfoResult, 'should be set'); updateInfos.push({ type: updateTypes.UPDATE_CURRENT_USER, id: rawUpdateInfo.id, time: rawUpdateInfo.time, currentUserInfo: currentUserInfoResult, }); } else if (rawUpdateInfo.type === updateTypes.UPDATE_USER) { updateInfos.push({ type: updateTypes.UPDATE_USER, id: rawUpdateInfo.id, time: rawUpdateInfo.time, updatedUserID: rawUpdateInfo.updatedUserID, }); userIDsToFetch.add(rawUpdateInfo.updatedUserID); } else { invariant(false, `unrecognized updateType ${rawUpdateInfo.type}`); } } let userInfos = {}; if (userIDsToFetch.size > 0) { userInfos = await fetchKnownUserInfos(viewer, [...userIDsToFetch]); } updateInfos.sort(sortFunction); // Now we'll attempt to merge UpdateInfos so that we only have one per key const updateForKey: Map = new Map(); const mergedUpdates: ServerUpdateInfo[] = []; for (const updateInfo of updateInfos) { const key = keyForUpdateInfo(updateInfo); if (!key) { mergedUpdates.push(updateInfo); continue; } else if ( updateInfo.type === updateTypes.DELETE_THREAD || updateInfo.type === updateTypes.JOIN_THREAD || updateInfo.type === updateTypes.DELETE_ACCOUNT ) { updateForKey.set(key, updateInfo); continue; } const currentUpdateInfo = updateForKey.get(key); if (!currentUpdateInfo) { updateForKey.set(key, updateInfo); } else if ( updateInfo.type === updateTypes.UPDATE_THREAD && currentUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS ) { // UPDATE_THREAD trumps UPDATE_THREAD_READ_STATUS // Note that we keep the oldest UPDATE_THREAD updateForKey.set(key, updateInfo); } else if ( updateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS && currentUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS ) { // If we only have UPDATE_THREAD_READ_STATUS, keep the most recent updateForKey.set(key, updateInfo); } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { updateForKey.set(key, updateInfo); } else if (updateInfo.type === updateTypes.UPDATE_CURRENT_USER) { updateForKey.set(key, updateInfo); } } for (const [, updateInfo] of updateForKey) { mergedUpdates.push(updateInfo); } mergedUpdates.sort(sortFunction); return { updateInfos: mergedUpdates, userInfos }; } type PublishInfo = { updateTarget: UpdateTarget, rawUpdateInfos: RawUpdateInfo[], }; async function redisPublish( publishInfos: Iterator, dontBroadcastSession: ?string, ): Promise { for (const publishInfo of publishInfos) { const { updateTarget, rawUpdateInfos } = publishInfo; const redisMessage: NewUpdatesRedisMessage = { type: redisMessageTypes.NEW_UPDATES, updates: rawUpdateInfos, }; if (!updateTarget.sessionID && dontBroadcastSession) { redisMessage.ignoreSession = dontBroadcastSession; } publisher.sendMessage(updateTarget, redisMessage); } } function getTargetFromUpdateData(updateData: UpdateData): ?string { if (updateData.targetSession) { return updateData.targetSession; } else if (updateData.targetCookie) { return updateData.targetCookie; } else { return null; } } function getDeleteCondition(updateData: UpdateData): ?DeleteCondition { let types; if (updateData.type === updateTypes.DELETE_ACCOUNT) { types = new Set([updateTypes.DELETE_ACCOUNT, updateTypes.UPDATE_USER]); } else if (updateData.type === updateTypes.UPDATE_THREAD) { types = new Set([ updateTypes.UPDATE_THREAD, updateTypes.UPDATE_THREAD_READ_STATUS, ]); } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { types = new Set([updateTypes.UPDATE_THREAD_READ_STATUS]); } else if ( updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { types = 'all_types'; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { types = 'all_types'; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { types = new Set([updateTypes.UPDATE_CURRENT_USER]); } else if (updateData.type === updateTypes.UPDATE_USER) { types = new Set([updateTypes.UPDATE_USER]); } else { return null; } const target = getTargetFromUpdateData(updateData); const { userID } = updateData; return { userID, target, types }; } function filterOnDeleteCondition( updateData: UpdateData, deleteCondition: DeleteCondition, ): boolean { invariant( updateData.userID === deleteCondition.userID, `updateData of type ${updateData.type} being compared to wrong userID`, ); if (deleteCondition.target) { const target = getTargetFromUpdateData(updateData); if (target !== deleteCondition.target) { return true; } } if (deleteCondition.types === 'all_types') { return false; } return !deleteCondition.types.has(updateData.type); } export { createUpdates, fetchUpdateInfosWithRawUpdateInfos }; diff --git a/keyserver/src/deleters/account-deleters.js b/keyserver/src/deleters/account-deleters.js index ca649b6d7..ebde860f8 100644 --- a/keyserver/src/deleters/account-deleters.js +++ b/keyserver/src/deleters/account-deleters.js @@ -1,146 +1,146 @@ // @flow import bcrypt from 'twin-bcrypt'; import type { LogOutResponse, DeleteAccountRequest, } from 'lib/types/account-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { createNewAnonymousCookie } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; async function deleteAccount( viewer: Viewer, request?: DeleteAccountRequest, ): Promise { if (!viewer.loggedIn || (!request && !viewer.isScriptViewer)) { throw new ServerError('not_logged_in'); } if (request) { const hashQuery = SQL`SELECT hash FROM users WHERE id = ${viewer.userID}`; const [result] = await dbQuery(hashQuery); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; const requestPasswordConsistentWithDB = !!row.hash === !!request.password; const shouldValidatePassword = !!row.hash; if ( !requestPasswordConsistentWithDB || (shouldValidatePassword && !bcrypt.compareSync(request.password, row.hash)) ) { throw new ServerError('invalid_credentials'); } } const deletedUserID = viewer.userID; await rescindPushNotifs(SQL`n.user = ${deletedUserID}`, SQL`NULL`); const knownUserInfos = await fetchKnownUserInfos(viewer); const usersToUpdate: $ReadOnlyArray = values(knownUserInfos).filter( (user: UserInfo): boolean => user.id !== deletedUserID, ); // TODO: if this results in any orphaned orgs, convert them to chats const deletionQuery = SQL` START TRANSACTION; DELETE FROM users WHERE id = ${deletedUserID}; DELETE FROM ids WHERE id = ${deletedUserID}; DELETE c, i FROM cookies c LEFT JOIN ids i ON i.id = c.id WHERE c.user = ${deletedUserID}; DELETE FROM memberships WHERE user = ${deletedUserID}; DELETE FROM focused WHERE user = ${deletedUserID}; DELETE n, i FROM notifications n LEFT JOIN ids i ON i.id = n.id WHERE n.user = ${deletedUserID}; DELETE u, i FROM updates u LEFT JOIN ids i ON i.id = u.id WHERE u.user = ${deletedUserID}; DELETE s, i FROM sessions s LEFT JOIN ids i ON i.id = s.id WHERE s.user = ${deletedUserID}; DELETE r, i FROM reports r LEFT JOIN ids i ON i.id = r.id WHERE r.user = ${deletedUserID}; DELETE u, i FROM uploads u LEFT JOIN ids i on i.id = u.id WHERE u.container = ${deletedUserID}; DELETE FROM relationships_undirected WHERE user1 = ${deletedUserID}; DELETE FROM relationships_undirected WHERE user2 = ${deletedUserID}; DELETE FROM relationships_directed WHERE user1 = ${deletedUserID}; DELETE FROM relationships_directed WHERE user2 = ${deletedUserID}; COMMIT; `; const promises = {}; promises.deletion = dbQuery(deletionQuery, { multipleStatements: true }); if (request) { promises.anonymousViewerData = createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }); } const { anonymousViewerData } = await promiseAll(promises); if (anonymousViewerData) { viewer.setNewCookie(anonymousViewerData); } const deletionUpdatesPromise = createAccountDeletionUpdates( usersToUpdate, deletedUserID, ); if (request) { handleAsyncPromise(deletionUpdatesPromise); } else { await deletionUpdatesPromise; } if (request) { return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } return null; } async function createAccountDeletionUpdates( knownUserInfos: $ReadOnlyArray, deletedUserID: string, ): Promise { const time = Date.now(); const updateDatas = []; for (const userInfo of knownUserInfos) { const { id: userID } = userInfo; updateDatas.push({ type: updateTypes.DELETE_ACCOUNT, userID, time, deletedUserID, }); } await createUpdates(updateDatas); } export { deleteAccount }; diff --git a/keyserver/src/deleters/thread-deleters.js b/keyserver/src/deleters/thread-deleters.js index 6e9e328c5..53b89847b 100644 --- a/keyserver/src/deleters/thread-deleters.js +++ b/keyserver/src/deleters/thread-deleters.js @@ -1,156 +1,156 @@ // @flow import { permissionLookup } from 'lib/permissions/thread-permissions.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadDeletionRequest, type LeaveThreadResult, } from 'lib/types/thread-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchThreadInfos, fetchServerThreadInfos, } from '../fetchers/thread-fetchers.js'; import { fetchThreadPermissionsBlob } from '../fetchers/thread-permission-fetchers.js'; import { fetchUpdateInfoForThreadDeletion } from '../fetchers/update-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import type { Viewer } from '../session/viewer.js'; async function deleteThread( viewer: Viewer, threadDeletionRequest: ThreadDeletionRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { threadID } = threadDeletionRequest; const [permissionsBlob, { threadInfos: serverThreadInfos }] = await Promise.all([ fetchThreadPermissionsBlob(viewer, threadID), fetchServerThreadInfos(SQL`t.id = ${threadID}`), ]); if (!permissionsBlob) { // This should only occur if the first request goes through but the client // never receives the response const [{ updateInfos }, fetchThreadInfoResult] = await Promise.all([ fetchUpdateInfoForThreadDeletion(viewer, threadID), hasMinCodeVersion(viewer.platformDetails, 62) ? undefined : fetchThreadInfos(viewer), ]); if (fetchThreadInfoResult) { const { threadInfos } = fetchThreadInfoResult; return { threadInfos, updatesResult: { newUpdates: updateInfos } }; } return { updatesResult: { newUpdates: updateInfos } }; } const hasPermission = permissionLookup( permissionsBlob, threadPermissions.DELETE_THREAD, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } await rescindPushNotifs( SQL`n.thread = ${threadID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ); // TODO: if org, delete all descendant threads as well. make sure to warn user // TODO: handle descendant thread permission update correctly. // thread-permission-updaters should be used for descendant threads. const query = SQL` DELETE t, ic, d, id, e, ie, re, ire, mm, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids ic ON ic.id = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN memberships mm ON mm.thread = t.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON (up.container = ms.id OR up.container = t.id) LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE t.id = ${threadID} `; const serverThreadInfo = serverThreadInfos[threadID]; const time = Date.now(); const updateDatas = []; for (const memberInfo of serverThreadInfo.members) { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID: memberInfo.id, time, threadID, }); } const [{ viewerUpdates }] = await Promise.all([ createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'return' }), dbQuery(query), ]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } async function deleteInaccessibleThreads(): Promise { // A thread is considered "inaccessible" if it has no membership rows. Note // that membership rows exist whenever a user can see a thread, even if they // are not technically a member (in which case role=0). For now, we're also // excluding threads with children, since to properly delete those we would // need to update their parent_thread_id, and possibly change their type. await dbQuery(SQL` DELETE t, i, m2, d, id, e, ie, re, ire, r, ir, ms, im, up, iu, f, n, ino FROM threads t LEFT JOIN ids i ON i.id = t.id LEFT JOIN memberships m1 ON m1.thread = t.id AND m1.role > -1 LEFT JOIN threads c ON c.parent_thread_id = t.id LEFT JOIN memberships m2 ON m2.thread = t.id LEFT JOIN days d ON d.thread = t.id LEFT JOIN ids id ON id.id = d.id LEFT JOIN entries e ON e.day = d.id LEFT JOIN ids ie ON ie.id = e.id LEFT JOIN revisions re ON re.entry = e.id LEFT JOIN ids ire ON ire.id = re.id LEFT JOIN roles r ON r.thread = t.id LEFT JOIN ids ir ON ir.id = r.id LEFT JOIN messages ms ON ms.thread = t.id LEFT JOIN ids im ON im.id = ms.id LEFT JOIN uploads up ON (up.container = ms.id OR up.container = t.id) LEFT JOIN ids iu ON iu.id = up.id LEFT JOIN focused f ON f.thread = t.id LEFT JOIN notifications n ON n.thread = t.id LEFT JOIN ids ino ON ino.id = n.id WHERE m1.thread IS NULL AND c.id IS NULL `); } export { deleteThread, deleteInaccessibleThreads }; diff --git a/keyserver/src/fetchers/update-fetchers.js b/keyserver/src/fetchers/update-fetchers.js index bc4e57a9a..85d5c678a 100644 --- a/keyserver/src/fetchers/update-fetchers.js +++ b/keyserver/src/fetchers/update-fetchers.js @@ -1,175 +1,172 @@ // @flow import invariant from 'invariant'; import type { CalendarQuery } from 'lib/types/entry-types.js'; -import { - type RawUpdateInfo, - updateTypes, - assertUpdateType, -} from 'lib/types/update-types.js'; +import { updateTypes, assertUpdateType } from 'lib/types/update-types-enum.js'; +import { type RawUpdateInfo } from 'lib/types/update-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { type ViewerInfo, type FetchUpdatesResult, fetchUpdateInfosWithRawUpdateInfos, } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import type { Viewer } from '../session/viewer.js'; const defaultUpdateFetchResult = { updateInfos: [], userInfos: {} }; async function fetchUpdateInfosWithQuery( viewerInfo: ViewerInfo, query: SQLStatementType, ): Promise { if (!viewerInfo.viewer.loggedIn) { throw new ServerError('not_logged_in'); } if (viewerInfo.viewer.isScriptViewer) { return defaultUpdateFetchResult; } const [result] = await dbQuery(query); const rawUpdateInfos = []; for (const row of result) { rawUpdateInfos.push(rawUpdateInfoFromRow(row)); } return await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, viewerInfo); } function fetchUpdateInfos( viewer: Viewer, currentAsOf: number, calendarQuery: CalendarQuery, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND time > ${currentAsOf} AND (updater IS NULL OR updater != ${viewer.session}) AND (target IS NULL OR target = ${viewer.session}) ORDER BY time ASC `; return fetchUpdateInfosWithQuery({ viewer, calendarQuery }, query); } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function rawUpdateInfoFromRow(row: Object): RawUpdateInfo { const type = assertUpdateType(row.type); if (type === updateTypes.DELETE_ACCOUNT) { const content = JSON.parse(row.content); return { type: updateTypes.DELETE_ACCOUNT, id: row.id.toString(), time: row.time, deletedUserID: content.deletedUserID, }; } else if (type === updateTypes.UPDATE_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.UPDATE_THREAD_READ_STATUS) { const { threadID, unread } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: row.id.toString(), time: row.time, threadID, unread, }; } else if (type === updateTypes.DELETE_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.DELETE_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.JOIN_THREAD) { const { threadID } = JSON.parse(row.content); return { type: updateTypes.JOIN_THREAD, id: row.id.toString(), time: row.time, threadID, }; } else if (type === updateTypes.BAD_DEVICE_TOKEN) { const { deviceToken } = JSON.parse(row.content); return { type: updateTypes.BAD_DEVICE_TOKEN, id: row.id.toString(), time: row.time, deviceToken, }; } else if (type === updateTypes.UPDATE_ENTRY) { const { entryID } = JSON.parse(row.content); return { type: updateTypes.UPDATE_ENTRY, id: row.id.toString(), time: row.time, entryID, }; } else if (type === updateTypes.UPDATE_CURRENT_USER) { return { type: updateTypes.UPDATE_CURRENT_USER, id: row.id.toString(), time: row.time, }; } else if (type === updateTypes.UPDATE_USER) { const content = JSON.parse(row.content); return { type: updateTypes.UPDATE_USER, id: row.id.toString(), time: row.time, updatedUserID: content.updatedUserID, }; } invariant(false, `unrecognized updateType ${type}`); } const entryIDExtractString = '$.entryID'; function fetchUpdateInfoForEntryUpdate( viewer: Viewer, entryID: string, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND type = ${updateTypes.UPDATE_ENTRY} AND JSON_EXTRACT(content, ${entryIDExtractString}) = ${entryID} ORDER BY time DESC LIMIT 1 `; return fetchUpdateInfosWithQuery({ viewer }, query); } const threadIDExtractString = '$.threadID'; function fetchUpdateInfoForThreadDeletion( viewer: Viewer, threadID: string, ): Promise { const query = SQL` SELECT id, type, content, time FROM updates WHERE user = ${viewer.id} AND type = ${updateTypes.DELETE_THREAD} AND JSON_EXTRACT(content, ${threadIDExtractString}) = ${threadID} ORDER BY time DESC LIMIT 1 `; return fetchUpdateInfosWithQuery({ viewer }, query); } export { fetchUpdateInfos, fetchUpdateInfoForEntryUpdate, fetchUpdateInfoForThreadDeletion, }; diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js index 82dec8325..16c7215e0 100644 --- a/keyserver/src/push/send.js +++ b/keyserver/src/push/send.js @@ -1,1144 +1,1144 @@ // @flow import apn from '@parse/node-apn'; import type { ResponseFailure } from '@parse/node-apn'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import _flow from 'lodash/fp/flow.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _pickBy from 'lodash/fp/pickBy.js'; import uuidv4 from 'uuid/v4.js'; import { oldValidUsernameRegex } from 'lib/shared/account-utils.js'; import { isMentioned } from 'lib/shared/mention-utils.js'; import { createMessageInfo, sortMessageInfoList, shimUnsupportedRawMessageInfos, } from 'lib/shared/message-utils.js'; import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { notifTextsForMessageInfo } from 'lib/shared/notif-utils.js'; import { rawThreadInfoFromServerThreadInfo, threadInfoFromRawThreadInfo, } from 'lib/shared/thread-utils.js'; import type { Platform, PlatformDetails } from 'lib/types/device-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type MessageData, } from 'lib/types/message-types.js'; import type { WebNotification, WNSNotification, ResolvedNotifTexts, } from 'lib/types/notif-types.js'; import type { ServerThreadInfo } from 'lib/types/thread-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { promiseAll } from 'lib/utils/promises.js'; import { getAPNsNotificationTopic } from './providers.js'; import { rescindPushNotifs } from './rescind.js'; import { apnPush, fcmPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, wnsMaxNotificationPayloadByteSize, webPush, wnsPush, type WebPushError, type WNSPushError, } from './utils.js'; import createIDs from '../creators/id-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import type { CollapsableNotifInfo } from '../fetchers/message-fetchers.js'; import { fetchCollapsableNotifs } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { getENSNames } from '../utils/ens-cache.js'; type Device = { +platform: Platform, +deviceToken: string, +codeVersion: ?number, }; type PushUserInfo = { +devices: Device[], // messageInfos and messageDatas have the same key +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], }; type Delivery = PushDelivery | { collapsedInto: string }; type NotificationRow = { +dbID: string, +userID: string, +threadID?: ?string, +messageID?: ?string, +collapseKey?: ?string, +deliveries: Delivery[], }; export type PushInfo = { [userID: string]: PushUserInfo }; async function sendPushNotifs(pushInfo: PushInfo) { if (Object.keys(pushInfo).length === 0) { return; } const [ unreadCounts, { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }, dbIDs, ] = await Promise.all([ getUnreadCounts(Object.keys(pushInfo)), fetchInfos(pushInfo), createDBIDs(pushInfo), ]); const deliveryPromises = []; const notifications: Map = new Map(); for (const userID in usersToCollapsableNotifInfo) { const threadInfos = _flow( _mapValues((serverThreadInfo: ServerThreadInfo) => { const rawThreadInfo = rawThreadInfoFromServerThreadInfo( serverThreadInfo, userID, ); if (!rawThreadInfo) { return null; } return threadInfoFromRawThreadInfo(rawThreadInfo, userID, userInfos); }), _pickBy(threadInfo => threadInfo), )(serverThreadInfos); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const newRawMessageInfo of notifInfo.newMessageInfos) { const newMessageInfo = hydrateMessageInfo(newRawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(newRawMessageInfo); } } if (newMessageInfos.length === 0) { continue; } const existingMessageInfos = notifInfo.existingMessageInfos .map(hydrateMessageInfo) .filter(Boolean); const allMessageInfos = sortMessageInfoList([ ...newMessageInfos, ...existingMessageInfos, ]); const [firstNewMessageInfo, ...remainingNewMessageInfos] = newMessageInfos; const { threadID } = firstNewMessageInfo; const threadInfo = threadInfos[threadID]; const updateBadge = threadInfo.currentUser.subscription.home; const displayBanner = threadInfo.currentUser.subscription.pushNotifs; const username = userInfos[userID] && userInfos[userID].username; const userWasMentioned = username && threadInfo.currentUser.role && oldValidUsernameRegex.test(username) && newMessageInfos.some(newMessageInfo => { const unwrappedMessageInfo = newMessageInfo.type === messageTypes.SIDEBAR_SOURCE ? newMessageInfo.sourceMessage : newMessageInfo; return ( unwrappedMessageInfo.type === messageTypes.TEXT && isMentioned(username, unwrappedMessageInfo.text) ); }); if (!updateBadge && !displayBanner && !userWasMentioned) { continue; } const badgeOnly = !displayBanner && !userWasMentioned; const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( allMessageInfos, threadInfo, notifTargetUserInfo, getENSNames, ); if (!notifTexts) { continue; } const dbID = dbIDs.shift(); invariant(dbID, 'should have sufficient DB IDs'); const byPlatform = getDevicesByPlatform(pushInfo[userID].devices); const firstMessageID = firstNewMessageInfo.id; invariant(firstMessageID, 'RawMessageInfo.id should be set on server'); const notificationInfo = { source: 'new_message', dbID, userID, threadID, messageID: firstMessageID, collapseKey: notifInfo.collapseKey, }; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [codeVersion, deviceTokens] of iosVersionsToTokens) { const platformDetails = { platform: 'ios', codeVersion }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const deliveryPromise = (async () => { const notification = await prepareAPNsNotification({ notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount: unreadCounts[userID], platformDetails, }); return await sendAPNsNotification( 'ios', notification, [...deviceTokens], { ...notificationInfo, codeVersion, }, ); })(); deliveryPromises.push(deliveryPromise); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [codeVersion, deviceTokens] of androidVersionsToTokens) { const platformDetails = { platform: 'android', codeVersion }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const deliveryPromise = (async () => { const notification = await prepareAndroidNotification({ notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount: unreadCounts[userID], platformDetails, dbID, }); return await sendAndroidNotification( notification, [...deviceTokens], { ...notificationInfo, codeVersion, }, ); })(); deliveryPromises.push(deliveryPromise); } } const webVersionsToTokens = byPlatform.get('web'); if (webVersionsToTokens) { for (const [codeVersion, deviceTokens] of webVersionsToTokens) { const deliveryPromise = (async () => { const notification = await prepareWebNotification({ notifTexts, threadID: threadInfo.id, unreadCount: unreadCounts[userID], }); return await sendWebNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }); })(); deliveryPromises.push(deliveryPromise); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [codeVersion, deviceTokens] of macosVersionsToTokens) { const platformDetails = { platform: 'macos', codeVersion }; const shimmedNewRawMessageInfos = shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ); const deliveryPromise = (async () => { const notification = await prepareAPNsNotification({ notifTexts, newRawMessageInfos: shimmedNewRawMessageInfos, threadID: threadInfo.id, collapseKey: notifInfo.collapseKey, badgeOnly, unreadCount: unreadCounts[userID], platformDetails, }); return await sendAPNsNotification( 'macos', notification, [...deviceTokens], { ...notificationInfo, codeVersion, }, ); })(); deliveryPromises.push(deliveryPromise); } } const windowsVersionsToTokens = byPlatform.get('windows'); if (windowsVersionsToTokens) { for (const [codeVersion, deviceTokens] of windowsVersionsToTokens) { const deliveryPromise = (async () => { const notification = await prepareWNSNotification({ notifTexts, threadID: threadInfo.id, unreadCount: unreadCounts[userID], }); return await sendWNSNotification(notification, [...deviceTokens], { ...notificationInfo, codeVersion, }); })(); deliveryPromises.push(deliveryPromise); } } for (const newMessageInfo of remainingNewMessageInfos) { const newDBID = dbIDs.shift(); invariant(newDBID, 'should have sufficient DB IDs'); const messageID = newMessageInfo.id; invariant(messageID, 'RawMessageInfo.id should be set on server'); notifications.set(newDBID, { dbID: newDBID, userID, threadID: newMessageInfo.threadID, messageID, collapseKey: notifInfo.collapseKey, deliveries: [{ collapsedInto: dbID }], }); } } } const cleanUpPromises = []; if (dbIDs.length > 0) { const query = SQL`DELETE FROM ids WHERE id IN (${dbIDs})`; cleanUpPromises.push(dbQuery(query)); } const [deliveryResults] = await Promise.all([ Promise.all(deliveryPromises), Promise.all(cleanUpPromises), ]); await saveNotifResults(deliveryResults, notifications, true); } async function sendRescindNotifs(rescindInfo: PushInfo) { if (Object.keys(rescindInfo).length === 0) { return; } const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(rescindInfo); const promises = []; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const existingMessageInfo of notifInfo.existingMessageInfos) { const rescindCondition = SQL` n.user = ${userID} AND n.thread = ${existingMessageInfo.threadID} AND n.message = ${existingMessageInfo.id} `; promises.push(rescindPushNotifs(rescindCondition)); } } } await Promise.all(promises); } // The results in deliveryResults will be combined with the rows // in rowsToSave and then written to the notifications table async function saveNotifResults( deliveryResults: $ReadOnlyArray, inputRowsToSave: Map, rescindable: boolean, ) { const rowsToSave = new Map(inputRowsToSave); const allInvalidTokens = []; for (const deliveryResult of deliveryResults) { const { info, delivery, invalidTokens } = deliveryResult; const { dbID, userID } = info; const curNotifRow = rowsToSave.get(dbID); if (curNotifRow) { curNotifRow.deliveries.push(delivery); } else { // Ternary expressions for Flow const threadID = info.threadID ? info.threadID : null; const messageID = info.messageID ? info.messageID : null; const collapseKey = info.collapseKey ? info.collapseKey : null; rowsToSave.set(dbID, { dbID, userID, threadID, messageID, collapseKey, deliveries: [delivery], }); } if (invalidTokens) { allInvalidTokens.push({ userID, tokens: invalidTokens, }); } } const notificationRows = []; for (const notification of rowsToSave.values()) { notificationRows.push([ notification.dbID, notification.userID, notification.threadID, notification.messageID, notification.collapseKey, JSON.stringify(notification.deliveries), Number(!rescindable), ]); } const dbPromises = []; if (allInvalidTokens.length > 0) { dbPromises.push(removeInvalidTokens(allInvalidTokens)); } if (notificationRows.length > 0) { const query = SQL` INSERT INTO notifications (id, user, thread, message, collapse_key, delivery, rescinded) VALUES ${notificationRows} `; dbPromises.push(dbQuery(query)); } if (dbPromises.length > 0) { await Promise.all(dbPromises); } } async function fetchInfos(pushInfo: PushInfo) { const usersToCollapsableNotifInfo = await fetchCollapsableNotifs(pushInfo); const threadIDs = new Set(); const threadWithChangedNamesToMessages = new Map(); const addThreadIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } if ( rawMessageInfo.type === messageTypes.CHANGE_SETTINGS && rawMessageInfo.field === 'name' ) { const messages = threadWithChangedNamesToMessages.get(threadID); if (messages) { messages.push(rawMessageInfo.id); } else { threadWithChangedNamesToMessages.set(threadID, [rawMessageInfo.id]); } } }; for (const userID in usersToCollapsableNotifInfo) { for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addThreadIDsFromMessageInfos(rawMessageInfo); } } } const promises = {}; // These threadInfos won't have currentUser set promises.threadResult = fetchServerThreadInfos( SQL`t.id IN (${[...threadIDs]})`, ); if (threadWithChangedNamesToMessages.size > 0) { const typesThatAffectName = [ messageTypes.CHANGE_SETTINGS, messageTypes.CREATE_THREAD, ]; const oldNameQuery = SQL` SELECT IF( JSON_TYPE(JSON_EXTRACT(m.content, "$.name")) = 'NULL', "", JSON_UNQUOTE(JSON_EXTRACT(m.content, "$.name")) ) AS name, m.thread FROM ( SELECT MAX(id) AS id FROM messages WHERE type IN (${typesThatAffectName}) AND JSON_EXTRACT(content, "$.name") IS NOT NULL AND`; const threadClauses = []; for (const [threadID, messages] of threadWithChangedNamesToMessages) { threadClauses.push( SQL`(thread = ${threadID} AND id NOT IN (${messages}))`, ); } oldNameQuery.append(mergeOrConditions(threadClauses)); oldNameQuery.append(SQL` GROUP BY thread ) x LEFT JOIN messages m ON m.id = x.id `); promises.oldNames = dbQuery(oldNameQuery); } const { threadResult, oldNames } = await promiseAll(promises); const serverThreadInfos = threadResult.threadInfos; if (oldNames) { const [result] = oldNames; for (const row of result) { const threadID = row.thread.toString(); serverThreadInfos[threadID].name = row.name; } } const userInfos = await fetchNotifUserInfos( serverThreadInfos, usersToCollapsableNotifInfo, ); return { usersToCollapsableNotifInfo, serverThreadInfos, userInfos }; } async function fetchNotifUserInfos( serverThreadInfos: { +[threadID: string]: ServerThreadInfo }, usersToCollapsableNotifInfo: { +[userID: string]: CollapsableNotifInfo[] }, ) { const missingUserIDs = new Set(); for (const threadID in serverThreadInfos) { const serverThreadInfo = serverThreadInfos[threadID]; for (const member of serverThreadInfo.members) { missingUserIDs.add(member.id); } } const addUserIDsFromMessageInfos = (rawMessageInfo: RawMessageInfo) => { missingUserIDs.add(rawMessageInfo.creatorID); const userIDs = messageSpecs[rawMessageInfo.type].userIDs?.(rawMessageInfo) ?? []; for (const userID of userIDs) { missingUserIDs.add(userID); } }; for (const userID in usersToCollapsableNotifInfo) { missingUserIDs.add(userID); for (const notifInfo of usersToCollapsableNotifInfo[userID]) { for (const rawMessageInfo of notifInfo.existingMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } for (const rawMessageInfo of notifInfo.newMessageInfos) { addUserIDsFromMessageInfos(rawMessageInfo); } } } return await fetchUserInfos([...missingUserIDs]); } async function createDBIDs(pushInfo: PushInfo): Promise { let numIDsNeeded = 0; for (const userID in pushInfo) { numIDsNeeded += pushInfo[userID].messageInfos.length; } return await createIDs('notifications', numIDsNeeded); } function getDevicesByPlatform( devices: Device[], ): Map>> { const byPlatform = new Map(); for (const device of devices) { let innerMap = byPlatform.get(device.platform); if (!innerMap) { innerMap = new Map(); byPlatform.set(device.platform, innerMap); } const codeVersion: number = device.codeVersion !== null && device.codeVersion !== undefined ? device.codeVersion : -1; let innerMostSet = innerMap.get(codeVersion); if (!innerMostSet) { innerMostSet = new Set(); innerMap.set(codeVersion, innerMostSet); } innerMostSet.add(device.deviceToken); } return byPlatform; } type APNsNotifInputData = { +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: RawMessageInfo[], +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount: number, +platformDetails: PlatformDetails, }; async function prepareAPNsNotification( inputData: APNsNotifInputData, ): Promise { const { notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, } = inputData; const uniqueID = uuidv4(); const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic(platformDetails); const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { notification.body = merged; notification.sound = 'default'; } notification.payload = { ...notification.payload, ...rest, }; notification.badge = unreadCount; notification.threadId = threadID; notification.id = uniqueID; notification.pushType = 'alert'; notification.payload.id = uniqueID; notification.payload.threadID = threadID; if (platformDetails.codeVersion && platformDetails.codeVersion > 198) { notification.mutableContent = true; } if (collapseKey) { notification.collapseId = collapseKey; } const messageInfos = JSON.stringify(newRawMessageInfos); // We make a copy before checking notification's length, because calling // length compiles the notification and makes it immutable. Further // changes to its properties won't be reflected in the final plaintext // data that is sent. const copyWithMessageInfos = _cloneDeep(notification); copyWithMessageInfos.payload = { ...copyWithMessageInfos.payload, messageInfos, }; if (copyWithMessageInfos.length() <= apnMaxNotificationPayloadByteSize) { notification.payload.messageInfos = messageInfos; return notification; } const notificationCopy = _cloneDeep(notification); if (notificationCopy.length() > apnMaxNotificationPayloadByteSize) { console.warn( `${platformDetails.platform} notification ${uniqueID} ` + `exceeds size limit, even with messageInfos omitted`, ); } return notification; } type AndroidNotifInputData = { ...APNsNotifInputData, +dbID: string, }; async function prepareAndroidNotification( inputData: AndroidNotifInputData, ): Promise { const { notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails: { codeVersion }, dbID, } = inputData; const notifID = collapseKey ? collapseKey : dbID; const { merged, ...rest } = notifTexts; const notification = { data: { badge: unreadCount.toString(), ...rest, threadID, }, }; // The reason we only include `badgeOnly` for newer clients is because older // clients don't know how to parse it. The reason we only include `id` for // newer clients is that if the older clients see that field, they assume // the notif has a full payload, and then crash when trying to parse it. // By skipping `id` we allow old clients to still handle in-app notifs and // badge updating. if (!badgeOnly || (codeVersion && codeVersion >= 69)) { notification.data = { ...notification.data, id: notifID, badgeOnly: badgeOnly ? '1' : '0', }; } const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; if ( Buffer.byteLength(JSON.stringify(copyWithMessageInfos)) <= fcmMaxNotificationPayloadByteSize ) { return copyWithMessageInfos; } if ( Buffer.byteLength(JSON.stringify(notification)) > fcmMaxNotificationPayloadByteSize ) { console.warn( `Android notification ${notifID} exceeds size limit, even with messageInfos omitted`, ); } return notification; } type WebNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +unreadCount: number, }; async function prepareWebNotification( inputData: WebNotifInputData, ): Promise { const { notifTexts, threadID, unreadCount } = inputData; const id = uuidv4(); const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, id, threadID, }; return notification; } type WNSNotifInputData = { +notifTexts: ResolvedNotifTexts, +threadID: string, +unreadCount: number, }; async function prepareWNSNotification( inputData: WNSNotifInputData, ): Promise { const { notifTexts, threadID, unreadCount } = inputData; const { merged, ...rest } = notifTexts; const notification = { ...rest, unreadCount, threadID, }; if ( Buffer.byteLength(JSON.stringify(notification)) > wnsMaxNotificationPayloadByteSize ) { console.warn('WNS notification exceeds size limit'); } return notification; } type NotificationInfo = | { +source: 'new_message', +dbID: string, +userID: string, +threadID: string, +messageID: string, +collapseKey: ?string, +codeVersion: number, } | { +source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', +dbID: string, +userID: string, +codeVersion: number, }; type APNsDelivery = { source: $PropertyType, deviceType: 'ios' | 'macos', iosID: string, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, }; type APNsResult = { info: NotificationInfo, delivery: APNsDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAPNsNotification( platform: 'ios' | 'macos', notification: apn.Notification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion } = notificationInfo; const response = await apnPush({ notification, deviceTokens, platformDetails: { platform, codeVersion }, }); const delivery: APNsDelivery = { source, deviceType: platform, iosID: notification.id, deviceTokens, codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: APNsResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type PushResult = AndroidResult | APNsResult | WebResult | WNSResult; type PushDelivery = AndroidDelivery | APNsDelivery | WebDelivery | WNSDelivery; type AndroidDelivery = { source: $PropertyType, deviceType: 'android', androidIDs: $ReadOnlyArray, deviceTokens: $ReadOnlyArray, codeVersion: number, errors?: $ReadOnlyArray, }; type AndroidResult = { info: NotificationInfo, delivery: AndroidDelivery, invalidTokens?: $ReadOnlyArray, }; async function sendAndroidNotification( notification: Object, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const collapseKey = notificationInfo.collapseKey ? notificationInfo.collapseKey : null; // for Flow... const { source, codeVersion } = notificationInfo; const response = await fcmPush({ notification, deviceTokens, collapseKey, codeVersion, }); const androidIDs = response.fcmIDs ? response.fcmIDs : []; const delivery: AndroidDelivery = { source, deviceType: 'android', androidIDs, deviceTokens, codeVersion, }; if (response.errors) { delivery.errors = response.errors; } const result: AndroidResult = { info: notificationInfo, delivery, }; if (response.invalidTokens) { result.invalidTokens = response.invalidTokens; } return result; } type WebDelivery = { +source: $PropertyType, +deviceType: 'web', +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +errors?: $ReadOnlyArray, }; type WebResult = { +info: NotificationInfo, +delivery: WebDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWebNotification( notification: WebNotification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion } = notificationInfo; const response = await webPush({ notification, deviceTokens, }); const delivery: WebDelivery = { source, deviceType: 'web', deviceTokens, codeVersion, errors: response.errors, }; const result: WebResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type WNSDelivery = { +source: $PropertyType, +deviceType: 'windows', +wnsIDs: $ReadOnlyArray, +deviceTokens: $ReadOnlyArray, +codeVersion?: number, +errors?: $ReadOnlyArray, }; type WNSResult = { +info: NotificationInfo, +delivery: WNSDelivery, +invalidTokens?: $ReadOnlyArray, }; async function sendWNSNotification( notification: WNSNotification, deviceTokens: $ReadOnlyArray, notificationInfo: NotificationInfo, ): Promise { const { source, codeVersion } = notificationInfo; const response = await wnsPush({ notification, deviceTokens, }); const wnsIDs = response.wnsIDs ?? []; const delivery: WNSDelivery = { source, deviceType: 'windows', wnsIDs, deviceTokens, codeVersion, errors: response.errors, }; const result: WNSResult = { info: notificationInfo, delivery, invalidTokens: response.invalidTokens, }; return result; } type InvalidToken = { +userID: string, +tokens: $ReadOnlyArray, }; async function removeInvalidTokens( invalidTokens: $ReadOnlyArray, ): Promise { const sqlTuples = invalidTokens.map( invalidTokenUser => SQL`( user = ${invalidTokenUser.userID} AND device_token IN (${invalidTokenUser.tokens}) )`, ); const sqlCondition = mergeOrConditions(sqlTuples); const selectQuery = SQL` SELECT id, user, device_token FROM cookies WHERE `; selectQuery.append(sqlCondition); const [result] = await dbQuery(selectQuery); const userCookiePairsToInvalidDeviceTokens = new Map(); for (const row of result) { const userCookiePair = `${row.user}|${row.id}`; const existing = userCookiePairsToInvalidDeviceTokens.get(userCookiePair); if (existing) { existing.add(row.device_token); } else { userCookiePairsToInvalidDeviceTokens.set( userCookiePair, new Set([row.device_token]), ); } } const time = Date.now(); const promises = []; for (const entry of userCookiePairsToInvalidDeviceTokens) { const [userCookiePair, deviceTokens] = entry; const [userID, cookieID] = userCookiePair.split('|'); const updateDatas = [...deviceTokens].map(deviceToken => ({ type: updateTypes.BAD_DEVICE_TOKEN, userID, time, deviceToken, targetCookie: cookieID, })); promises.push(createUpdates(updateDatas)); } const updateQuery = SQL` UPDATE cookies SET device_token = NULL WHERE `; updateQuery.append(sqlCondition); promises.push(dbQuery(updateQuery)); await Promise.all(promises); } async function updateBadgeCount( viewer: Viewer, source: 'mark_as_unread' | 'mark_as_read' | 'activity_update', ) { const { userID } = viewer; const deviceTokenQuery = SQL` SELECT platform, device_token, versions FROM cookies WHERE user = ${userID} AND device_token IS NOT NULL `; if (viewer.data.cookieID) { deviceTokenQuery.append(SQL`AND id != ${viewer.cookieID} `); } const [unreadCounts, [deviceTokenResult], [dbID]] = await Promise.all([ getUnreadCounts([userID]), dbQuery(deviceTokenQuery), createIDs('notifications', 1), ]); const unreadCount = unreadCounts[userID]; const devices = deviceTokenResult.map(row => ({ platform: row.platform, deviceToken: row.device_token, codeVersion: JSON.parse(row.versions)?.codeVersion, })); const byPlatform = getDevicesByPlatform(devices); const deliveryPromises = []; const iosVersionsToTokens = byPlatform.get('ios'); if (iosVersionsToTokens) { for (const [codeVersion, deviceTokens] of iosVersionsToTokens) { const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'ios', codeVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; deliveryPromises.push( sendAPNsNotification('ios', notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const androidVersionsToTokens = byPlatform.get('android'); if (androidVersionsToTokens) { for (const [codeVersion, deviceTokens] of androidVersionsToTokens) { const notificationData = codeVersion < 69 ? { badge: unreadCount.toString() } : { badge: unreadCount.toString(), badgeOnly: '1' }; const notification = { data: notificationData }; deliveryPromises.push( sendAndroidNotification(notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const macosVersionsToTokens = byPlatform.get('macos'); if (macosVersionsToTokens) { for (const [codeVersion, deviceTokens] of macosVersionsToTokens) { const notification = new apn.Notification(); notification.topic = getAPNsNotificationTopic({ platform: 'macos', codeVersion, }); notification.badge = unreadCount; notification.pushType = 'alert'; deliveryPromises.push( sendAPNsNotification('macos', notification, [...deviceTokens], { source, dbID, userID, codeVersion, }), ); } } const deliveryResults = await Promise.all(deliveryPromises); await saveNotifResults(deliveryResults, new Map(), false); } export { sendPushNotifs, sendRescindNotifs, updateBadgeCount }; diff --git a/keyserver/src/scripts/merge-users.js b/keyserver/src/scripts/merge-users.js index 88e8d4202..6b47f2372 100644 --- a/keyserver/src/scripts/merge-users.js +++ b/keyserver/src/scripts/merge-users.js @@ -1,194 +1,195 @@ // @flow import type { Shape } from 'lib/types/core.js'; import type { ServerThreadInfo } from 'lib/types/thread-types.js'; -import { type UpdateData, updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; +import { type UpdateData } from 'lib/types/update-types.js'; import { endScript } from './utils.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { deleteAccount } from '../deleters/account-deleters.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { createScriptViewer } from '../session/scripts.js'; import { changeRole, commitMembershipChangeset, } from '../updaters/thread-permission-updaters.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; async function main() { try { await mergeUsers('7147', '15972', { username: true, password: true }); endScript(); } catch (e) { endScript(); console.warn(e); } } type ReplaceUserInfo = Shape<{ +username: boolean, +password: boolean, }>; async function mergeUsers( fromUserID: string, toUserID: string, replaceUserInfo?: ReplaceUserInfo, ) { let updateUserRowQuery = null; let updateDatas = []; if (replaceUserInfo) { const replaceUserResult = await replaceUser( fromUserID, toUserID, replaceUserInfo, ); ({ sql: updateUserRowQuery, updateDatas } = replaceUserResult); } const usersGettingUpdate = new Set(); const usersNeedingUpdate = new Set(); const needUserInfoUpdate = replaceUserInfo && replaceUserInfo.username; const setGettingUpdate = (threadInfo: ServerThreadInfo) => { if (!needUserInfoUpdate) { return; } for (const { id } of threadInfo.members) { usersGettingUpdate.add(id); usersNeedingUpdate.delete(id); } }; const setNeedingUpdate = (threadInfo: ServerThreadInfo) => { if (!needUserInfoUpdate) { return; } for (const { id } of threadInfo.members) { if (!usersGettingUpdate.has(id)) { usersNeedingUpdate.add(id); } } }; const newThreadRolePairs = []; const { threadInfos } = await fetchServerThreadInfos(); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const fromUserExistingMember = threadInfo.members.find( memberInfo => memberInfo.id === fromUserID, ); if (!fromUserExistingMember) { setNeedingUpdate(threadInfo); continue; } const { role } = fromUserExistingMember; if (!role) { // Only transfer explicit memberships setNeedingUpdate(threadInfo); continue; } const toUserExistingMember = threadInfo.members.find( memberInfo => memberInfo.id === toUserID, ); if (!toUserExistingMember || !toUserExistingMember.role) { setGettingUpdate(threadInfo); newThreadRolePairs.push([threadID, role]); } else { setNeedingUpdate(threadInfo); } } const fromViewer = createScriptViewer(fromUserID); await deleteAccount(fromViewer); if (updateUserRowQuery) { await dbQuery(updateUserRowQuery); } const time = Date.now(); for (const userID of usersNeedingUpdate) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID, time, updatedUserID: toUserID, }); } await createUpdates(updateDatas); const changesets = await Promise.all( newThreadRolePairs.map(([threadID, role]) => changeRole(threadID, [toUserID], role), ), ); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); for (const currentChangeset of changesets) { const { membershipRows: currentMembershipRows, relationshipChangeset: currentRelationshipChangeset, } = currentChangeset; membershipRows.push(...currentMembershipRows); relationshipChangeset.addAll(currentRelationshipChangeset); } if (membershipRows.length > 0 || relationshipChangeset.getRowCount() > 0) { const toViewer = createScriptViewer(toUserID); const changeset = { membershipRows, relationshipChangeset }; await commitMembershipChangeset(toViewer, changeset); } } type ReplaceUserResult = { sql: ?SQLStatementType, updateDatas: UpdateData[], }; async function replaceUser( fromUserID: string, toUserID: string, replaceUserInfo: ReplaceUserInfo, ): Promise { if (Object.keys(replaceUserInfo).length === 0) { return { sql: null, updateDatas: [], }; } const fromUserQuery = SQL` SELECT username, hash FROM users WHERE id = ${fromUserID} `; const [fromUserResult] = await dbQuery(fromUserQuery); const [firstResult] = fromUserResult; if (!firstResult) { throw new Error(`couldn't fetch fromUserID ${fromUserID}`); } const changedFields = {}; if (replaceUserInfo.username) { changedFields.username = firstResult.username; } if (replaceUserInfo.password) { changedFields.hash = firstResult.hash; } const updateUserRowQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${toUserID} `; const updateDatas = []; if (replaceUserInfo.username) { updateDatas.push({ type: updateTypes.UPDATE_CURRENT_USER, userID: toUserID, time: Date.now(), }); } return { sql: updateUserRowQuery, updateDatas, }; } main(); diff --git a/keyserver/src/scripts/rename-user.js b/keyserver/src/scripts/rename-user.js index 5c52c740a..4cb00be07 100644 --- a/keyserver/src/scripts/rename-user.js +++ b/keyserver/src/scripts/rename-user.js @@ -1,40 +1,40 @@ // @flow -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { main } from './utils.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import { createScriptViewer } from '../session/scripts.js'; const userID = '5'; const newUsername = 'commbot'; async function renameUser() { const [adjacentUsers] = await Promise.all([ fetchKnownUserInfos(createScriptViewer(userID)), dbQuery( SQL`UPDATE users SET username = ${newUsername} WHERE id = ${userID}`, ), ]); const updateDatas = []; const time = Date.now(); updateDatas.push({ type: updateTypes.UPDATE_CURRENT_USER, userID, time, }); for (const adjacentUserID in adjacentUsers) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: adjacentUserID, time, updatedUserID: userID, }); } await createUpdates(updateDatas); } main([renameUser]); diff --git a/keyserver/src/scripts/setup-sidebars.js b/keyserver/src/scripts/setup-sidebars.js index 900b34b2f..32439fbbf 100644 --- a/keyserver/src/scripts/setup-sidebars.js +++ b/keyserver/src/scripts/setup-sidebars.js @@ -1,125 +1,125 @@ // @flow import { messageSpecs } from 'lib/shared/messages/message-specs.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { main } from './utils.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, mergeOrConditions, SQL } from '../database/database.js'; async function addRepliesCountColumn() { const update = SQL` ALTER TABLE threads ADD replies_count INT UNSIGNED NOT NULL DEFAULT 0 `; try { await dbQuery(update); } catch (e) { console.log(e, 'replies-count column already exists'); } } async function addSenderColumn() { const update = SQL` ALTER TABLE memberships ADD sender TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 `; try { await dbQuery(update); } catch (e) { console.log(e, 'sender column already exists'); } } async function computeRepliesCount() { const includedMessageTypes = Object.keys(messageTypes) .map(key => messageTypes[key]) .filter(type => messageSpecs[type].includedInRepliesCount); const sidebarMembersQuery = SQL` SELECT t.id AS threadID, m.user AS userID FROM threads t INNER JOIN memberships m ON t.id = m.thread WHERE t.source_message IS NOT NULL AND m.role >= 0 `; const readCountUpdate = SQL` UPDATE threads t INNER JOIN ( SELECT thread AS threadID, COUNT(*) AS count FROM messages WHERE type IN (${includedMessageTypes}) GROUP BY thread ) c ON c.threadID = t.id SET t.replies_count = c.count WHERE t.source_message IS NOT NULL `; const [[sidebarMembers]] = await Promise.all([ dbQuery(sidebarMembersQuery), dbQuery(readCountUpdate), ]); const time = Date.now(); const updates = sidebarMembers.map(({ threadID, userID }) => ({ userID, time, threadID, type: updateTypes.UPDATE_THREAD, })); await createUpdates(updates); } export async function determineSenderStatus() { const includedMessageTypes = Object.keys(messageTypes) .map(key => messageTypes[key]) .filter(type => messageSpecs[type].includedInRepliesCount); const sendersQuery = SQL` SELECT DISTINCT m.thread AS threadID, m.user AS userID FROM messages m WHERE m.type IN (${includedMessageTypes}) `; const [senders] = await dbQuery(sendersQuery); const conditions = senders.map( ({ threadID, userID }) => SQL`thread = ${threadID} AND user = ${userID}`, ); const setSenders = SQL` UPDATE memberships m SET m.sender = 1 WHERE `; setSenders.append(mergeOrConditions(conditions)); const updatedThreads = new Set(senders.map(({ threadID }) => threadID)); const affectedMembersQuery = SQL` SELECT thread AS threadID, user AS userID FROM memberships WHERE thread IN (${[...updatedThreads]}) AND role >= 0 `; const [[affectedMembers]] = await Promise.all([ dbQuery(affectedMembersQuery), dbQuery(setSenders), ]); const time = Date.now(); const updates = affectedMembers.map(({ threadID, userID }) => ({ userID, time, threadID, type: updateTypes.UPDATE_THREAD, })); await createUpdates(updates); } main([ addRepliesCountColumn, addSenderColumn, computeRepliesCount, determineSenderStatus, ]); diff --git a/keyserver/src/updaters/account-updaters.js b/keyserver/src/updaters/account-updaters.js index 31e0bfc4e..223f60f08 100644 --- a/keyserver/src/updaters/account-updaters.js +++ b/keyserver/src/updaters/account-updaters.js @@ -1,254 +1,254 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, LogInResponse, } from 'lib/types/account-types.js'; import type { ClientAvatar, UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from 'lib/types/avatar-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import type { CreateUpdatesResult, UpdateData, } from 'lib/types/update-types.js'; import type { PasswordUpdate, UserInfo, UserInfos, } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { getUploadURL } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function accountUpdater( viewer: Viewer, update: PasswordUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newPassword = update.updatedFields.password; if (!newPassword) { // If it's an old client it may have given us an email, // but we don't store those anymore return; } const verifyQuery = SQL` SELECT username, hash FROM users WHERE id = ${viewer.userID} `; const [verifyResult] = await dbQuery(verifyQuery); if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } const changedFields = { hash: bcrypt.hashSync(newPassword) }; const saveQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `; await dbQuery(saveQuery); const updateDatas = [ { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time: Date.now(), }, ]; await createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'broadcast', }); } // eslint-disable-next-line no-unused-vars async function checkAndSendVerificationEmail(viewer: Viewer): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } async function checkAndSendPasswordResetEmail( // eslint-disable-next-line no-unused-vars request: ResetPasswordRequest, ): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } /* eslint-disable no-unused-vars */ async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { /* eslint-enable no-unused-vars */ // We have no way to handle this request anymore throw new ServerError('deprecated'); } async function updateUserSettings( viewer: Viewer, request: UpdateUserSettingsRequest, ) { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const createOrUpdateSettingsQuery = SQL` INSERT INTO settings (user, name, data) VALUES ${[[viewer.id, request.name, request.data]]} ON DUPLICATE KEY UPDATE data = VALUE(data) `; await dbQuery(createOrUpdateSettingsQuery); } async function updateUserAvatar( viewer: Viewer, request: UpdateUserAvatarRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newAvatarValue = request.type === 'remove' ? null : JSON.stringify(request); const mediaID = request.type === 'image' ? request.uploadID : null; const query = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE uploader = ${viewer.userID} AND container = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${viewer.userID} WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE users SET avatar = ${newAvatarValue} WHERE id = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container = ${viewer.userID} AND thread IS NULL ) ); COMMIT; SELECT id AS upload_id, secret AS upload_secret FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container = ${viewer.userID}; `; const [resultSet] = await dbQuery(query, { multipleStatements: true }); const selectResult = resultSet.pop(); const knownUserInfos: UserInfos = await fetchKnownUserInfos(viewer); const updates: CreateUpdatesResult = await createUserAvatarUpdates( viewer, knownUserInfos, ); if (hasMinCodeVersion(viewer.platformDetails, 215)) { const updateUserAvatarResponse: UpdateUserAvatarResponse = { updates, }; return updateUserAvatarResponse; } if (request.type === 'remove') { return null; } else if (request.type !== 'image') { return request; } else { const [{ upload_id, upload_secret }] = selectResult; const uploadID = upload_id.toString(); invariant( uploadID === request.uploadID, 'uploadID of upload should match uploadID of UpdateUserAvatarRequest', ); return { type: 'image', uri: getUploadURL(uploadID, upload_secret), }; } } async function createUserAvatarUpdates( viewer: Viewer, knownUserInfos: UserInfos, ): Promise { const time = Date.now(); const userUpdates: $ReadOnlyArray = values(knownUserInfos).map( (user: UserInfo): UpdateData => ({ type: updateTypes.UPDATE_USER, userID: user.id, time, updatedUserID: viewer.userID, }), ); const currentUserUpdate: UpdateData = { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time, }; return await createUpdates([...userUpdates, currentUserUpdate], { viewer, updatesForCurrentSession: 'return', }); } export { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updateUserSettings, updatePassword, updateUserAvatar, }; diff --git a/keyserver/src/updaters/activity-updaters.js b/keyserver/src/updaters/activity-updaters.js index 06567a37d..c93ff559b 100644 --- a/keyserver/src/updaters/activity-updaters.js +++ b/keyserver/src/updaters/activity-updaters.js @@ -1,513 +1,513 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import _max from 'lodash/fp/max.js'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import type { UpdateActivityResult, UpdateActivityRequest, SetThreadUnreadStatusRequest, SetThreadUnreadStatusResult, } from 'lib/types/activity-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import type { SQLStatementType } from '../database/types.js'; import { deleteActivityForViewerSession } from '../deleters/activity-deleters.js'; import { checkThread, getValidThreads, } from '../fetchers/thread-permission-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import { updateBadgeCount } from '../push/send.js'; import type { Viewer } from '../session/viewer.js'; import { earliestFocusedTimeConsideredExpired } from '../shared/focused-times.js'; type PartialThreadStatus = { +focusActive: boolean, +threadID: string, +newLastReadMessage: ?number, }; type ThreadStatus = | { +focusActive: true, +threadID: string, +newLastReadMessage: number, +curLastReadMessage: number, +rescindCondition: SQLStatementType, } | { +focusActive: false, +threadID: string, +newLastReadMessage: ?number, +curLastReadMessage: number, +rescindCondition: ?SQLStatementType, +newerMessageFromOtherAuthor: boolean, }; async function activityUpdater( viewer: Viewer, request: UpdateActivityRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const focusUpdatesByThreadID = new Map(); for (const activityUpdate of request.updates) { const threadID = activityUpdate.threadID; const updatesForThreadID = focusUpdatesByThreadID.get(threadID) ?? []; if (!focusUpdatesByThreadID.has(threadID)) { focusUpdatesByThreadID.set(threadID, updatesForThreadID); } updatesForThreadID.push(activityUpdate); } const unverifiedThreadIDs: $ReadOnlySet = new Set( request.updates.map(update => update.threadID), ); const verifiedThreadsData = await getValidThreads( viewer, [...unverifiedThreadIDs], [ { check: 'permission', permission: threadPermissions.VISIBLE, }, ], ); if (verifiedThreadsData.length === 0) { return { unfocusedToUnread: [] }; } const memberThreadIDs = new Set(); const verifiedThreadIDs = []; for (const threadData of verifiedThreadsData) { if (threadData.role > 0) { memberThreadIDs.add(threadData.threadID); } verifiedThreadIDs.push(threadData.threadID); } const partialThreadStatuses: PartialThreadStatus[] = []; for (const threadID of verifiedThreadIDs) { const focusUpdates = focusUpdatesByThreadID.get(threadID); invariant(focusUpdates, `no focusUpdate for thread ID ${threadID}`); const focusActive = !focusUpdates.some(update => !update.focus); const newLastReadMessage = _max( focusUpdates .filter( update => update.latestMessage && !update.latestMessage.startsWith(localIDPrefix), ) .map(update => parseInt(update.latestMessage)), ); partialThreadStatuses.push({ threadID, focusActive, newLastReadMessage, }); } // We update the focused rows before we check for new messages so we can // guarantee that any messages that may have set the thread to unread before // we set it to focused are caught and overriden await updateFocusedRows(viewer, partialThreadStatuses); if (memberThreadIDs.size === 0) { return { unfocusedToUnread: [] }; } const memberPartialThreadStatuses = partialThreadStatuses.filter( partialStatus => memberThreadIDs.has(partialStatus.threadID), ); const unfocusedLatestMessages = new Map(); for (const partialThreadStatus of memberPartialThreadStatuses) { const { threadID, focusActive, newLastReadMessage } = partialThreadStatus; if (!focusActive) { unfocusedLatestMessages.set(threadID, newLastReadMessage ?? 0); } } const [unfocusedThreadsWithNewerMessages, lastMessageInfos] = await Promise.all([ checkForNewerMessages(viewer, unfocusedLatestMessages), fetchLastMessageInfo(viewer, [...memberThreadIDs]), ]); const threadStatuses: ThreadStatus[] = []; for (const partialThreadStatus of memberPartialThreadStatuses) { const { threadID, focusActive, newLastReadMessage } = partialThreadStatus; const lastMessageInfo = lastMessageInfos.get(threadID); invariant( lastMessageInfo !== undefined, `no lastMessageInfo for thread ID ${threadID}`, ); const { lastMessage, lastReadMessage: curLastReadMessage } = lastMessageInfo; if (focusActive) { threadStatuses.push({ focusActive: true, threadID, newLastReadMessage: newLastReadMessage ? Math.max(lastMessage, newLastReadMessage) : lastMessage, curLastReadMessage, rescindCondition: SQL`n.thread = ${threadID}`, }); } else { threadStatuses.push({ focusActive: false, threadID, newLastReadMessage, curLastReadMessage, rescindCondition: newLastReadMessage ? SQL`(n.thread = ${threadID} AND n.message <= ${newLastReadMessage})` : null, newerMessageFromOtherAuthor: unfocusedThreadsWithNewerMessages.has(threadID), }); } } // The following block determines whether to enqueue updates for a given // (user, thread) pair and whether to propagate badge count notifs to all of // that user's devices const setUnread: Array<{ +threadID: string, +unread: boolean }> = []; for (const threadStatus of threadStatuses) { const { threadID, curLastReadMessage } = threadStatus; if (!threadStatus.focusActive) { const { newLastReadMessage, newerMessageFromOtherAuthor } = threadStatus; if (newerMessageFromOtherAuthor) { setUnread.push({ threadID, unread: true }); } else if (!newLastReadMessage) { // This is a rare edge case. It should only be possible for threads that // have zero messages on both the client and server, which shouldn't // happen. In this case we'll set the thread to read, just in case... console.warn(`thread ID ${threadID} appears to have no messages`); setUnread.push({ threadID, unread: false }); } else if (newLastReadMessage > curLastReadMessage) { setUnread.push({ threadID, unread: false }); } } else { const { newLastReadMessage } = threadStatus; if (newLastReadMessage > curLastReadMessage) { setUnread.push({ threadID, unread: false }); } } } const time = Date.now(); const updateDatas = setUnread.map(({ threadID, unread }) => ({ type: updateTypes.UPDATE_THREAD_READ_STATUS, userID: viewer.userID, time, threadID, unread, })); const latestMessages = new Map(); for (const threadStatus of threadStatuses) { const { threadID, newLastReadMessage, curLastReadMessage } = threadStatus; if (newLastReadMessage && newLastReadMessage > curLastReadMessage) { latestMessages.set(threadID, newLastReadMessage); } } await Promise.all([ updateLastReadMessage(viewer, latestMessages), createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'ignore' }), ]); // We do this afterwards so the badge count is correct const rescindConditions = threadStatuses .map(({ rescindCondition }) => rescindCondition) .filter(Boolean); let rescindCondition; if (rescindConditions.length > 0) { rescindCondition = SQL`n.user = ${viewer.userID} AND `; rescindCondition.append(mergeOrConditions(rescindConditions)); } await rescindAndUpdateBadgeCounts( viewer, rescindCondition, updateDatas.length > 0 ? 'activity_update' : null, ); return { unfocusedToUnread: [...unfocusedThreadsWithNewerMessages] }; } async function updateFocusedRows( viewer: Viewer, partialThreadStatuses: $ReadOnlyArray, ): Promise { const threadIDs = partialThreadStatuses .filter(threadStatus => threadStatus.focusActive) .map(({ threadID }) => threadID); const time = Date.now(); if (threadIDs.length > 0) { const focusedInsertRows = threadIDs.map(threadID => [ viewer.userID, viewer.session, threadID, time, ]); const query = SQL` INSERT INTO focused (user, session, thread, time) VALUES ${focusedInsertRows} ON DUPLICATE KEY UPDATE time = VALUE(time) `; await dbQuery(query); } if (viewer.hasSessionInfo) { await deleteActivityForViewerSession(viewer, time); } } // To protect against a possible race condition, we reset the thread to unread // if the latest message ID on the client at the time that focus was dropped // is no longer the latest message ID. // Returns the set of unfocused threads that should be set to unread on // the client because a new message arrived since they were unfocused. async function checkForNewerMessages( viewer: Viewer, latestMessages: Map, ): Promise> { if (latestMessages.size === 0 || !viewer.loggedIn) { return new Set(); } const unfocusedThreadIDs = [...latestMessages.keys()]; const focusedElsewhereThreadIDs = await checkThreadsFocused( viewer, unfocusedThreadIDs, ); const unreadCandidates = _difference(unfocusedThreadIDs)( focusedElsewhereThreadIDs, ); if (unreadCandidates.length === 0) { return new Set(); } const knowOfExtractString = `$.${threadPermissions.KNOW_OF}.value`; const query = SQL` SELECT m.thread, MAX(m.id) AS latest_message FROM messages m LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewer.userID} WHERE m.thread IN (${unreadCandidates}) AND m.user != ${viewer.userID} AND ( m.type != ${messageTypes.CREATE_SUB_THREAD} OR JSON_EXTRACT(stm.permissions, ${knowOfExtractString}) IS TRUE ) GROUP BY m.thread `; const [result] = await dbQuery(query); const threadsWithNewerMessages = new Set(); for (const row of result) { const threadID = row.thread.toString(); const serverLatestMessage = row.latest_message; const clientLatestMessage = latestMessages.get(threadID); if (clientLatestMessage < serverLatestMessage) { threadsWithNewerMessages.add(threadID); } } return threadsWithNewerMessages; } async function checkThreadsFocused( viewer: Viewer, threadIDs: $ReadOnlyArray, ): Promise { const time = earliestFocusedTimeConsideredExpired(); const query = SQL` SELECT thread FROM focused WHERE time > ${time} AND user = ${viewer.userID} AND thread IN (${threadIDs}) GROUP BY thread `; const [result] = await dbQuery(query); const focusedThreadIDs = []; for (const row of result) { focusedThreadIDs.push(row.thread.toString()); } return focusedThreadIDs; } async function updateLastReadMessage( viewer: Viewer, lastReadMessages: $ReadOnlyMap, ) { if (lastReadMessages.size === 0) { return; } const query = SQL` UPDATE memberships SET last_read_message = GREATEST(last_read_message, CASE `; lastReadMessages.forEach((lastMessage, threadID) => { query.append(SQL` WHEN thread = ${threadID} THEN ${lastMessage} `); }); query.append(SQL` ELSE last_read_message END) WHERE thread IN (${[...lastReadMessages.keys()]}) AND user = ${viewer.userID} `); await dbQuery(query); } async function setThreadUnreadStatus( viewer: Viewer, request: SetThreadUnreadStatusRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const isMemberAndCanViewThread = await checkThread(viewer, request.threadID, [ { check: 'is_member', }, { check: 'permission', permission: threadPermissions.VISIBLE, }, ]); if (!isMemberAndCanViewThread) { throw new ServerError('invalid_parameters'); } const resetThreadToUnread = await shouldResetThreadToUnread(viewer, request); if (!resetThreadToUnread) { const lastReadMessage = request.unread ? SQL`0` : SQL`GREATEST(m.last_read_message, ${request.latestMessage ?? 0})`; const update = SQL` UPDATE memberships m SET m.last_read_message = `; update.append(lastReadMessage); update.append(SQL` WHERE m.thread = ${request.threadID} AND m.user = ${viewer.userID} `); const queryPromise = dbQuery(update); const time = Date.now(); const updatesPromise = createUpdates( [ { type: updateTypes.UPDATE_THREAD_READ_STATUS, userID: viewer.userID, time: time, threadID: request.threadID, unread: request.unread, }, ], { viewer, updatesForCurrentSession: 'ignore' }, ); await Promise.all([updatesPromise, queryPromise]); } let rescindCondition; if (!request.unread) { rescindCondition = SQL` n.user = ${viewer.userID} AND n.thread = ${request.threadID} AND n.message <= ${request.latestMessage} `; } await rescindAndUpdateBadgeCounts( viewer, rescindCondition, request.unread ? 'mark_as_unread' : 'mark_as_read', ); return { resetToUnread: resetThreadToUnread, }; } async function rescindAndUpdateBadgeCounts( viewer: Viewer, rescindCondition: ?SQLStatementType, badgeCountUpdateSource: ?( | 'activity_update' | 'mark_as_unread' | 'mark_as_read' ), ) { const notificationPromises = []; if (rescindCondition) { notificationPromises.push(rescindPushNotifs(rescindCondition)); } if (badgeCountUpdateSource) { notificationPromises.push(updateBadgeCount(viewer, badgeCountUpdateSource)); } await Promise.all(notificationPromises); } async function shouldResetThreadToUnread( viewer: Viewer, request: SetThreadUnreadStatusRequest, ): Promise { if (request.unread) { return false; } const threadsWithNewerMessages = await checkForNewerMessages( viewer, new Map([[request.threadID, parseInt(request.latestMessage) || 0]]), ); return threadsWithNewerMessages.has(request.threadID); } type LastMessageInfo = { +lastMessage: number, +lastReadMessage: number, }; async function fetchLastMessageInfo( viewer: Viewer, threadIDs: $ReadOnlyArray, ) { const query = SQL` SELECT thread, last_message, last_read_message FROM memberships WHERE user = ${viewer.userID} AND thread IN (${threadIDs}) `; const [result] = await dbQuery(query); const lastMessages = new Map(); for (const row of result) { const threadID = row.thread.toString(); const lastMessage = row.last_message; const lastReadMessage = row.last_read_message; lastMessages.set(threadID, { lastMessage, lastReadMessage }); } return lastMessages; } export { activityUpdater, setThreadUnreadStatus }; diff --git a/keyserver/src/updaters/entry-updaters.js b/keyserver/src/updaters/entry-updaters.js index 42f06e41f..746b4a53f 100644 --- a/keyserver/src/updaters/entry-updaters.js +++ b/keyserver/src/updaters/entry-updaters.js @@ -1,289 +1,287 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import { rawEntryInfoWithinCalendarQuery, calendarQueryDifference, } from 'lib/shared/entry-utils.js'; import { type SaveEntryRequest, type SaveEntryResponse, type RawEntryInfo, type CalendarQuery, defaultCalendarQuery, } from 'lib/types/entry-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import { - updateTypes, - type ServerCreateUpdatesResponse, -} from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; +import { type ServerCreateUpdatesResponse } from 'lib/types/update-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import type { SessionUpdate } from './session-updaters.js'; import createIDs from '../creators/id-creator.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfo, checkThreadPermissionForEntry, } from '../fetchers/entry-fetchers.js'; import { fetchActiveSessionsForThread } from '../fetchers/session-fetchers.js'; import type { Viewer } from '../session/viewer.js'; const defaultUpdateCreationResponse = { viewerUpdates: [], userInfos: [] }; async function updateEntry( viewer: Viewer, request: SaveEntryRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const lastRevisionQuery = SQL` SELECT r.id, r.author, r.text, r.session, r.last_update, r.deleted, e.text AS entryText FROM revisions r LEFT JOIN entries e ON r.entry = e.id WHERE r.entry = ${request.entryID} ORDER BY r.last_update DESC LIMIT 1 `; const [hasPermission, entryInfo, [lastRevisionResult]] = await Promise.all([ checkThreadPermissionForEntry( viewer, request.entryID, threadPermissions.EDIT_ENTRIES, ), fetchEntryInfo(viewer, request.entryID), dbQuery(lastRevisionQuery), ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (!entryInfo) { throw new ServerError('invalid_parameters'); } if (entryInfo.deleted) { throw new ServerError('entry_deleted'); } if (lastRevisionResult.length === 0) { throw new ServerError('unknown_error'); } const lastRevisionRow = lastRevisionResult[0]; if ( lastRevisionRow.deleted || lastRevisionRow.text !== lastRevisionRow.entryText ) { throw new ServerError('database_corruption'); } const viewerID = viewer.userID; const dbPromises = []; let insertNewRevision = false; let shouldUpdateEntry = false; if ( viewerID === lastRevisionRow.author && viewer.session === lastRevisionRow.session ) { if (lastRevisionRow.last_update >= request.timestamp) { // Updates got sent out of order and as a result an update newer than us // has already been committed, so there's nothing to do return { entryID: request.entryID, newMessageInfos: [], updatesResult: defaultUpdateCreationResponse, }; } shouldUpdateEntry = true; if (lastRevisionRow.last_update + 120000 > request.timestamp) { dbPromises.push( dbQuery(SQL` UPDATE revisions SET last_update = ${request.timestamp}, text = ${request.text} WHERE id = ${lastRevisionRow.id} `), ); } else { insertNewRevision = true; } } else if ( viewer.session !== lastRevisionRow.session && request.prevText !== lastRevisionRow.text ) { throw new ServerError('concurrent_modification', { db: lastRevisionRow.text, ui: request.prevText, }); } else if (lastRevisionRow.last_update >= request.timestamp) { throw new ServerError('old_timestamp', { oldTime: lastRevisionRow.last_update, newTime: request.timestamp, }); } else { shouldUpdateEntry = true; insertNewRevision = true; } if (shouldUpdateEntry) { dbPromises.push( dbQuery(SQL` UPDATE entries SET last_update = ${request.timestamp}, text = ${request.text} WHERE id = ${request.entryID} `), ); } if (insertNewRevision) { const [revisionID] = await createIDs('revisions', 1); const revisionRow = [ revisionID, request.entryID, viewerID, request.text, request.timestamp, viewer.session, request.timestamp, 0, ]; dbPromises.push( dbQuery(SQL` INSERT INTO revisions(id, entry, author, text, creation_time, session, last_update, deleted) VALUES ${[revisionRow]} `), ); } const updatedEntryInfo = { ...entryInfo, text: request.text, }; const [newMessageInfos, updatesResult] = await Promise.all([ createMessages(viewer, [ { type: messageTypes.EDIT_ENTRY, threadID: entryInfo.threadID, creatorID: viewerID, time: Date.now(), entryID: request.entryID, date: dateString(entryInfo.year, entryInfo.month, entryInfo.day), text: request.text, }, ]), createUpdateDatasForChangedEntryInfo( viewer, entryInfo, updatedEntryInfo, request.calendarQuery, ), Promise.all(dbPromises), ]); return { entryID: request.entryID, newMessageInfos, updatesResult }; } async function createUpdateDatasForChangedEntryInfo( viewer: Viewer, oldEntryInfo: ?RawEntryInfo, newEntryInfo: RawEntryInfo, inputCalendarQuery: ?CalendarQuery, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const entryID = newEntryInfo.id; invariant(entryID, 'should be set'); // If we ever make it possible to move entries from one thread to another, // we should update this code to look at oldEntryInfo.threadID as well const fetchedFilters = await fetchActiveSessionsForThread( newEntryInfo.threadID, ); let calendarQuery; if (inputCalendarQuery) { calendarQuery = inputCalendarQuery; } else if (viewer.hasSessionInfo) { // This should only ever happen for "legacy" clients who call in without // providing this information. These clients wouldn't know how to deal with // the corresponding UpdateInfos anyways, so no reason to be worried. calendarQuery = viewer.calendarQuery; } else { calendarQuery = defaultCalendarQuery(viewer.platform, viewer.timeZone); } let replaced = null; const { userID } = viewer; const filters = fetchedFilters.map(filter => filter.session === viewer.session && filter.userID === userID ? (replaced = { ...filter, calendarQuery }) : filter, ); if (!replaced) { const { session } = viewer; filters.push({ userID, session, calendarQuery }); } const time = Date.now(); const updateDatas = filters .filter( filter => rawEntryInfoWithinCalendarQuery(newEntryInfo, filter.calendarQuery) || (oldEntryInfo && rawEntryInfoWithinCalendarQuery(oldEntryInfo, filter.calendarQuery)), ) .map(filter => ({ type: updateTypes.UPDATE_ENTRY, userID: filter.userID, time, entryID, targetSession: filter.session, })); const { userInfos, ...updatesResult } = await createUpdates(updateDatas, { viewer, calendarQuery, updatesForCurrentSession: 'return', }); return { ...updatesResult, userInfos: values(userInfos), }; } type CalendarQueryComparisonResult = { +difference: $ReadOnlyArray, +oldCalendarQuery: CalendarQuery, +sessionUpdate: SessionUpdate, }; function compareNewCalendarQuery( viewer: Viewer, newCalendarQuery: CalendarQuery, ): CalendarQueryComparisonResult { if (!viewer.hasSessionInfo) { throw new ServerError('unknown_error'); } const oldCalendarQuery = viewer.calendarQuery; const difference = calendarQueryDifference( oldCalendarQuery, newCalendarQuery, ); const sessionUpdate = _isEqual(oldCalendarQuery)(newCalendarQuery) ? {} : { query: newCalendarQuery }; return { difference, oldCalendarQuery, sessionUpdate: Object.freeze({ ...sessionUpdate }), }; } export { updateEntry, createUpdateDatasForChangedEntryInfo, compareNewCalendarQuery, }; diff --git a/keyserver/src/updaters/relationship-updaters.js b/keyserver/src/updaters/relationship-updaters.js index 91b5a8e23..459b58c25 100644 --- a/keyserver/src/updaters/relationship-updaters.js +++ b/keyserver/src/updaters/relationship-updaters.js @@ -1,368 +1,369 @@ // @flow import invariant from 'invariant'; import { sortIDs } from 'lib/shared/relationship-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RelationshipRequest, type RelationshipErrors, type UndirectedRelationshipRow, relationshipActions, undirectedStatus, directedStatus, } from 'lib/types/relationship-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import { updateTypes, type UpdateData } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; +import { type UpdateData } from 'lib/types/update-types.js'; import { cartesianProduct } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL, mergeOrConditions } from '../database/database.js'; import { fetchFriendRequestRelationshipOperations } from '../fetchers/relationship-fetchers.js'; import { fetchUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function updateRelationships( viewer: Viewer, request: RelationshipRequest, ): Promise { const { action } = request; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const uniqueUserIDs = [...new Set(request.userIDs)]; const users = await fetchUserInfos(uniqueUserIDs); let errors = {}; const userIDs: string[] = []; for (const userID of uniqueUserIDs) { if (userID === viewer.userID || !users[userID].username) { const acc = errors.invalid_user || []; errors.invalid_user = [...acc, userID]; } else { userIDs.push(userID); } } if (!userIDs.length) { return Object.freeze({ ...errors }); } const updateIDs = []; if (action === relationshipActions.FRIEND) { // We have to create personal threads before setting the relationship // status. By doing that we make sure that failed thread creation is // reported to the caller and can be repeated - there should be only // one PERSONAL thread per a pair of users and we can safely call it // repeatedly. const threadIDPerUser = await createPersonalThreads(viewer, request); const { userRelationshipOperations, errors: friendRequestErrors } = await fetchFriendRequestRelationshipOperations(viewer, userIDs); errors = { ...errors, ...friendRequestErrors }; const undirectedInsertRows = []; const directedInsertRows = []; const directedDeleteIDs = []; const messageDatas = []; const now = Date.now(); for (const userID in userRelationshipOperations) { const operations = userRelationshipOperations[userID]; const ids = sortIDs(viewer.userID, userID); if (operations.length) { updateIDs.push(userID); } for (const operation of operations) { if (operation === 'delete_directed') { directedDeleteIDs.push(userID); } else if (operation === 'friend') { const [user1, user2] = ids; const status = undirectedStatus.FRIEND; undirectedInsertRows.push({ user1, user2, status }); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_accepted', }); } else if (operation === 'pending_friend') { const status = directedStatus.PENDING_FRIEND; directedInsertRows.push([viewer.userID, userID, status]); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_sent', }); } else if (operation === 'know_of') { const [user1, user2] = ids; const status = undirectedStatus.KNOW_OF; undirectedInsertRows.push({ user1, user2, status }); } else { invariant(false, `unexpected relationship operation ${operation}`); } } } const promises = [updateUndirectedRelationships(undirectedInsertRows)]; if (directedInsertRows.length) { const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedInsertRows} ON DUPLICATE KEY UPDATE status = VALUE(status) `; promises.push(dbQuery(directedInsertQuery)); } if (directedDeleteIDs.length) { const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE (user1 = ${viewer.userID} AND user2 IN (${directedDeleteIDs})) OR (status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${directedDeleteIDs}) AND user2 = ${viewer.userID}) `; promises.push(dbQuery(directedDeleteQuery)); } if (messageDatas.length > 0) { promises.push(createMessages(viewer, messageDatas, 'broadcast')); } await Promise.all(promises); } else if (action === relationshipActions.UNFRIEND) { updateIDs.push(...userIDs); const updateRows = userIDs.map(userID => { const [user1, user2] = sortIDs(viewer.userID, userID); return { user1, user2, status: undirectedStatus.KNOW_OF }; }); const deleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND (user1 = ${viewer.userID} AND user2 IN (${userIDs}) OR user1 IN (${userIDs}) AND user2 = ${viewer.userID}) `; await Promise.all([ updateUndirectedRelationships(updateRows, false), dbQuery(deleteQuery), ]); } else if (action === relationshipActions.BLOCK) { updateIDs.push(...userIDs); const directedRows = []; const undirectedRows = []; for (const userID of userIDs) { directedRows.push([viewer.userID, userID, directedStatus.BLOCKED]); const [user1, user2] = sortIDs(viewer.userID, userID); undirectedRows.push({ user1, user2, status: undirectedStatus.KNOW_OF }); } const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedRows} ON DUPLICATE KEY UPDATE status = VALUE(status) `; const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${userIDs}) AND user2 = ${viewer.userID} `; await Promise.all([ dbQuery(directedInsertQuery), dbQuery(directedDeleteQuery), updateUndirectedRelationships(undirectedRows, false), ]); } else if (action === relationshipActions.UNBLOCK) { updateIDs.push(...userIDs); const query = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.BLOCKED} AND user1 = ${viewer.userID} AND user2 IN (${userIDs}) `; await dbQuery(query); } else { invariant(false, `action ${action} is invalid or not supported currently`); } await createUpdates( updateDatasForUserPairs(cartesianProduct([viewer.userID], updateIDs)), ); return Object.freeze({ ...errors }); } function updateDatasForUserPairs( userPairs: $ReadOnlyArray<[string, string]>, ): UpdateData[] { const time = Date.now(); const updateDatas = []; for (const [user1, user2] of userPairs) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user1, time, updatedUserID: user2, }); updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user2, time, updatedUserID: user1, }); } return updateDatas; } async function updateUndirectedRelationships( changeset: UndirectedRelationshipRow[], greatest: boolean = true, ) { if (!changeset.length) { return; } const rows = changeset.map(row => [row.user1, row.user2, row.status]); const query = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${rows} `; if (greatest) { query.append( SQL`ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUE(status))`, ); } else { query.append(SQL`ON DUPLICATE KEY UPDATE status = VALUE(status)`); } await dbQuery(query); } async function updateChangedUndirectedRelationships( changeset: UndirectedRelationshipRow[], ): Promise { if (changeset.length === 0) { return []; } const user2ByUser1: Map> = new Map(); for (const { user1, user2 } of changeset) { if (!user2ByUser1.has(user1)) { user2ByUser1.set(user1, new Set()); } user2ByUser1.get(user1)?.add(user2); } const selectQuery = SQL` SELECT user1, user2, status FROM relationships_undirected WHERE `; const conditions = []; for (const [user1, users] of user2ByUser1) { conditions.push(SQL`(user1 = ${user1} AND user2 IN (${[...users]}))`); } selectQuery.append(mergeOrConditions(conditions)); const [result] = await dbQuery(selectQuery); const existingStatuses = new Map(); for (const row of result) { existingStatuses.set(`${row.user1}|${row.user2}`, row.status); } const insertRows = []; for (const row of changeset) { const existingStatus = existingStatuses.get(`${row.user1}|${row.user2}`); if (!existingStatus || existingStatus < row.status) { insertRows.push([row.user1, row.user2, row.status]); } } if (insertRows.length === 0) { return []; } const insertQuery = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${insertRows} ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUE(status)) `; await dbQuery(insertQuery); return updateDatasForUserPairs( insertRows.map(([user1, user2]) => [user1, user2]), ); } async function createPersonalThreads( viewer: Viewer, request: RelationshipRequest, ) { invariant( request.action === relationshipActions.FRIEND, 'We should only create a PERSONAL threads when sending a FRIEND request, ' + `but we tried to do that for ${request.action}`, ); const threadIDPerUser = {}; const personalThreadsQuery = SQL` SELECT t.id AS threadID, m2.user AS user2 FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user IN (${request.userIDs}) WHERE t.type = ${threadTypes.PERSONAL} AND m1.role > 0 AND m2.role > 0 `; const [personalThreadsResult] = await dbQuery(personalThreadsQuery); for (const row of personalThreadsResult) { const user2 = row.user2.toString(); threadIDPerUser[user2] = row.threadID.toString(); } const threadCreationPromises = {}; for (const userID of request.userIDs) { if (threadIDPerUser[userID]) { continue; } threadCreationPromises[userID] = createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [userID], }, { forceAddMembers: true, updatesForCurrentSession: 'broadcast' }, ); } const personalThreadPerUser = await promiseAll(threadCreationPromises); for (const userID in personalThreadPerUser) { const newThread = personalThreadPerUser[userID]; threadIDPerUser[userID] = newThread.newThreadID ?? newThread.newThreadInfo.id; } return threadIDPerUser; } export { updateRelationships, updateDatasForUserPairs, updateUndirectedRelationships, updateChangedUndirectedRelationships, }; diff --git a/keyserver/src/updaters/thread-permission-updaters.js b/keyserver/src/updaters/thread-permission-updaters.js index 38468f821..3f2cc1854 100644 --- a/keyserver/src/updaters/thread-permission-updaters.js +++ b/keyserver/src/updaters/thread-permission-updaters.js @@ -1,1291 +1,1291 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import bots from 'lib/facts/bots.js'; import genesis from 'lib/facts/genesis.js'; import { makePermissionsBlob, makePermissionsForChildrenBlob, getRoleForPermissions, } from 'lib/permissions/thread-permissions.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { ThreadPermissionsBlob, ThreadRolePermissionsBlob, } from 'lib/types/thread-permission-types.js'; import { type ThreadType, assertThreadType, } from 'lib/types/thread-types-enum.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { - updateTypes, type ServerUpdateInfo, type CreateUpdatesResult, } from 'lib/types/update-types.js'; import { pushAll } from 'lib/utils/array.js'; import { ServerError } from 'lib/utils/errors.js'; import { updateChangedUndirectedRelationships } from './relationship-updaters.js'; import { createUpdates, type UpdatesForCurrentSession, } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchServerThreadInfos, rawThreadInfosFromServerThreadInfos, type FetchThreadInfosResult, } from '../fetchers/thread-fetchers.js'; import { rescindPushNotifs } from '../push/rescind.js'; import { createScriptViewer } from '../session/scripts.js'; import type { Viewer } from '../session/viewer.js'; import { updateRoles } from '../updaters/role-updaters.js'; import DepthQueue from '../utils/depth-queue.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; export type MembershipRowToSave = { +operation: 'save', +intent: 'join' | 'leave' | 'none', +userID: string, +threadID: string, +userNeedsFullThreadDetails: boolean, +permissions: ?ThreadPermissionsBlob, +permissionsForChildren: ?ThreadPermissionsBlob, // null role represents by "0" +role: string, +oldRole: string, +unread?: boolean, }; type MembershipRowToDelete = { +operation: 'delete', +intent: 'join' | 'leave' | 'none', +userID: string, +threadID: string, +oldRole: string, }; type MembershipRow = MembershipRowToSave | MembershipRowToDelete; type Changeset = { +membershipRows: MembershipRow[], +relationshipChangeset: RelationshipChangeset, }; // 0 role means to remove the user from the thread // null role means to set the user to the default role // string role means to set the user to the role with that ID // -1 role means to set the user as a "ghost" (former member) type ChangeRoleOptions = { +setNewMembersToUnread?: boolean, }; type ChangeRoleMemberInfo = { permissionsFromParent?: ?ThreadPermissionsBlob, memberOfContainingThread?: boolean, }; async function changeRole( threadID: string, userIDs: $ReadOnlyArray, role: string | -1 | 0 | null, options?: ChangeRoleOptions, ): Promise { const intent = role === -1 || role === 0 ? 'leave' : 'join'; const setNewMembersToUnread = options?.setNewMembersToUnread && intent === 'join'; if (userIDs.length === 0) { return { membershipRows: [], relationshipChangeset: new RelationshipChangeset(), }; } const membershipQuery = SQL` SELECT user, role, permissions, permissions_for_children FROM memberships WHERE thread = ${threadID} `; const parentMembershipQuery = SQL` SELECT pm.user, pm.permissions_for_children AS permissions_from_parent FROM threads t INNER JOIN memberships pm ON pm.thread = t.parent_thread_id WHERE t.id = ${threadID} AND (pm.user IN (${userIDs}) OR t.parent_thread_id != ${genesis.id}) `; const containingMembershipQuery = SQL` SELECT cm.user, cm.role AS containing_role FROM threads t INNER JOIN memberships cm ON cm.thread = t.containing_thread_id WHERE t.id = ${threadID} AND cm.user IN (${userIDs}) `; const [ [membershipResults], [parentMembershipResults], containingMembershipResults, roleThreadResult, ] = await Promise.all([ dbQuery(membershipQuery), dbQuery(parentMembershipQuery), (async () => { if (intent === 'leave') { // Membership in the container only needs to be checked for members return []; } const [result] = await dbQuery(containingMembershipQuery); return result; })(), changeRoleThreadQuery(threadID, role), ]); const { roleColumnValue: intendedRole, threadType, parentThreadID, hasContainingThreadID, rolePermissions: intendedRolePermissions, depth, } = roleThreadResult; const existingMembershipInfo = new Map(); for (const row of membershipResults) { const userID = row.user.toString(); existingMembershipInfo.set(userID, { oldRole: row.role.toString(), oldPermissions: JSON.parse(row.permissions), oldPermissionsForChildren: JSON.parse(row.permissions_for_children), }); } const ancestorMembershipInfo: Map = new Map(); for (const row of parentMembershipResults) { const userID = row.user.toString(); if (!userIDs.includes(userID)) { continue; } ancestorMembershipInfo.set(userID, { permissionsFromParent: JSON.parse(row.permissions_from_parent), }); } for (const row of containingMembershipResults) { const userID = row.user.toString(); const ancestorMembership = ancestorMembershipInfo.get(userID); const memberOfContainingThread = row.containing_role > 0; if (ancestorMembership) { ancestorMembership.memberOfContainingThread = memberOfContainingThread; } else { ancestorMembershipInfo.set(userID, { memberOfContainingThread, }); } } const relationshipChangeset = new RelationshipChangeset(); const existingMemberIDs = [...existingMembershipInfo.keys()]; if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const parentMemberIDs = parentMembershipResults.map(row => row.user.toString(), ); if (parentThreadID && parentThreadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(parentMemberIDs); } const membershipRows = []; const toUpdateDescendants = new Map(); for (const userID of userIDs) { const existingMembership = existingMembershipInfo.get(userID); const oldRole = existingMembership?.oldRole ?? '-1'; const oldPermissions = existingMembership?.oldPermissions ?? null; const oldPermissionsForChildren = existingMembership?.oldPermissionsForChildren ?? null; if (existingMembership && oldRole === intendedRole) { // If the old role is the same as the new one, we have nothing to update continue; } else if (Number(oldRole) > 0 && role === null) { // In the case where we're just trying to add somebody to a thread, if // they already have a role with a nonzero role then we don't need to do // anything continue; } let permissionsFromParent = null; let memberOfContainingThread = false; const ancestorMembership = ancestorMembershipInfo.get(userID); if (ancestorMembership) { permissionsFromParent = ancestorMembership.permissionsFromParent; memberOfContainingThread = ancestorMembership.memberOfContainingThread; } if (!hasContainingThreadID) { memberOfContainingThread = true; } const rolePermissions = memberOfContainingThread ? intendedRolePermissions : null; const targetRole = memberOfContainingThread ? intendedRole : '-1'; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userBecameMember = Number(oldRole) <= 0 && Number(newRole) > 0; const userLostMembership = Number(oldRole) > 0 && Number(newRole) <= 0; if ( (intent === 'join' && Number(newRole) <= 0) || (intent === 'leave' && Number(newRole) > 0) ) { throw new ServerError('invalid_parameters'); } else if (intendedRole !== newRole) { console.warn( `changeRole called for role=${intendedRole}, but ended up setting ` + `role=${newRole} for userID ${userID} and threadID ${threadID}, ` + 'probably because KNOW_OF permission was unexpectedly present or ' + 'missing', ); } if ( existingMembership && _isEqual(permissions)(oldPermissions) && oldRole === newRole ) { // This thread and all of its descendants need no updates for this user, // since the corresponding memberships row is unchanged by this operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent, userID, threadID, userNeedsFullThreadDetails: userBecameMember, permissions, permissionsForChildren, role: newRole, oldRole, unread: userBecameMember && setNewMembersToUnread, }); } else { membershipRows.push({ operation: 'delete', intent, userID, threadID, oldRole, }); } if (permissions && !existingMembership && threadID !== genesis.id) { relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if ( userLostMembership || !_isEqual(permissionsForChildren)(oldPermissionsForChildren) ) { toUpdateDescendants.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions({ threadID, depth, changesByUser: toUpdateDescendants, }); pushAll(membershipRows, descendantMembershipRows); relationshipChangeset.addAll(descendantRelationshipChangeset); } return { membershipRows, relationshipChangeset }; } type RoleThreadResult = { +roleColumnValue: string, +depth: number, +threadType: ThreadType, +parentThreadID: ?string, +hasContainingThreadID: boolean, +rolePermissions: ?ThreadRolePermissionsBlob, }; async function changeRoleThreadQuery( threadID: string, role: string | -1 | 0 | null, ): Promise { if (role === 0 || role === -1) { const query = SQL` SELECT type, depth, parent_thread_id, containing_thread_id FROM threads WHERE id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: role.toString(), depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: null, }; } else if (role !== null) { const query = SQL` SELECT t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions FROM threads t INNER JOIN roles r ON r.thread = t.id AND r.id = ${role} WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: role, depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: JSON.parse(row.permissions), }; } else { const query = SQL` SELECT t.type, t.depth, t.parent_thread_id, t.containing_thread_id, t.default_role, r.permissions FROM threads t INNER JOIN roles r ON r.thread = t.id AND r.id = t.default_role WHERE t.id = ${threadID} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('internal_error'); } const row = result[0]; return { roleColumnValue: row.default_role.toString(), depth: row.depth, threadType: assertThreadType(row.type), parentThreadID: row.parent_thread_id ? row.parent_thread_id.toString() : null, hasContainingThreadID: row.containing_thread_id !== null, rolePermissions: JSON.parse(row.permissions), }; } } type ChangedAncestor = { +threadID: string, +depth: number, +changesByUser: Map, }; type AncestorChanges = { +userIsMember: boolean, +permissionsForChildren: ?ThreadPermissionsBlob, }; async function updateDescendantPermissions( initialChangedAncestor: ChangedAncestor, ): Promise { const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); const initialDescendants = await fetchDescendantsForUpdate([ initialChangedAncestor, ]); const depthQueue = new DepthQueue( getDescendantDepth, getDescendantKey, mergeDescendants, ); depthQueue.addInfos(initialDescendants); let descendants; while ((descendants = depthQueue.getNextDepth())) { const descendantsAsAncestors = []; for (const descendant of descendants) { const { threadID, threadType, depth, users } = descendant; const existingMembers = [...users.entries()]; const existingMemberIDs = existingMembers .filter(([, { curRole }]) => curRole) .map(([userID]) => userID); if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const usersForNextLayer = new Map(); for (const [userID, user] of users) { const { curRolePermissions, curPermissionsFromParent, curMemberOfContainingThread, nextMemberOfContainingThread, nextPermissionsFromParent, potentiallyNeedsUpdate, } = user; const existingMembership = !!user.curRole; const curRole = user.curRole ?? '-1'; const curPermissions = user.curPermissions ?? null; const curPermissionsForChildren = user.curPermissionsForChildren ?? null; if (!potentiallyNeedsUpdate) { continue; } const permissionsFromParent = nextPermissionsFromParent === undefined ? curPermissionsFromParent : nextPermissionsFromParent; const memberOfContainingThread = nextMemberOfContainingThread === undefined ? curMemberOfContainingThread : nextMemberOfContainingThread; const targetRole = memberOfContainingThread ? curRole : '-1'; const rolePermissions = memberOfContainingThread ? curRolePermissions : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userLostMembership = Number(curRole) > 0 && Number(newRole) <= 0; if (_isEqual(permissions)(curPermissions) && curRole === newRole) { // This thread and all of its descendants need no updates for this // user, since the corresponding memberships row is unchanged by this // operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent: 'none', userID, threadID, userNeedsFullThreadDetails: false, permissions, permissionsForChildren, role: newRole, oldRole: curRole, }); } else { membershipRows.push({ operation: 'delete', intent: 'none', userID, threadID, oldRole: curRole, }); } if (permissions && !existingMembership && threadID !== genesis.id) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We expect that whoever called us already // generated memberships row for the new members, will will lead // saveMemberships to generate relationships rows between those new // users. relationshipChangeset.setRelationshipsNeeded( userID, existingMemberIDs, ); } if ( userLostMembership || !_isEqual(permissionsForChildren)(curPermissionsForChildren) ) { usersForNextLayer.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (usersForNextLayer.size > 0) { descendantsAsAncestors.push({ threadID, depth, changesByUser: usersForNextLayer, }); } } const nextDescendants = await fetchDescendantsForUpdate( descendantsAsAncestors, ); depthQueue.addInfos(nextDescendants); } return { membershipRows, relationshipChangeset }; } type DescendantUserInfo = $Shape<{ curRole?: string, curRolePermissions?: ?ThreadRolePermissionsBlob, curPermissions?: ?ThreadPermissionsBlob, curPermissionsForChildren?: ?ThreadPermissionsBlob, curPermissionsFromParent?: ?ThreadPermissionsBlob, curMemberOfContainingThread?: boolean, nextPermissionsFromParent?: ?ThreadPermissionsBlob, nextMemberOfContainingThread?: boolean, potentiallyNeedsUpdate?: boolean, }>; type DescendantInfo = { +threadID: string, +parentThreadID: string, +containingThreadID: string, +threadType: ThreadType, +depth: number, +users: Map, }; const fetchDescendantsBatchSize = 10; async function fetchDescendantsForUpdate( ancestors: $ReadOnlyArray, ): Promise { const threadIDs = ancestors.map(ancestor => ancestor.threadID); const rows = []; while (threadIDs.length > 0) { const batch = threadIDs.splice(0, fetchDescendantsBatchSize); const query = SQL` SELECT t.id, m.user, t.type, t.depth, t.parent_thread_id, t.containing_thread_id, r.permissions AS role_permissions, m.permissions, m.permissions_for_children, m.role, pm.permissions_for_children AS permissions_from_parent, cm.role AS containing_role FROM threads t INNER JOIN memberships m ON m.thread = t.id LEFT JOIN memberships pm ON pm.thread = t.parent_thread_id AND pm.user = m.user LEFT JOIN memberships cm ON cm.thread = t.containing_thread_id AND cm.user = m.user LEFT JOIN roles r ON r.id = m.role WHERE t.parent_thread_id IN (${batch}) OR t.containing_thread_id IN (${batch}) `; const [results] = await dbQuery(query); pushAll(rows, results); } const descendantThreadInfos: Map = new Map(); for (const row of rows) { const descendantThreadID = row.id.toString(); if (!descendantThreadInfos.has(descendantThreadID)) { descendantThreadInfos.set(descendantThreadID, { threadID: descendantThreadID, parentThreadID: row.parent_thread_id.toString(), containingThreadID: row.containing_thread_id.toString(), threadType: assertThreadType(row.type), depth: row.depth, users: new Map(), }); } const descendantThreadInfo = descendantThreadInfos.get(descendantThreadID); invariant( descendantThreadInfo, `value should exist for key ${descendantThreadID}`, ); const userID = row.user.toString(); descendantThreadInfo.users.set(userID, { curRole: row.role.toString(), curRolePermissions: JSON.parse(row.role_permissions), curPermissions: JSON.parse(row.permissions), curPermissionsForChildren: JSON.parse(row.permissions_for_children), curPermissionsFromParent: JSON.parse(row.permissions_from_parent), curMemberOfContainingThread: row.containing_role > 0, }); } for (const ancestor of ancestors) { const { threadID, changesByUser } = ancestor; for (const [userID, changes] of changesByUser) { for (const descendantThreadInfo of descendantThreadInfos.values()) { const { users, parentThreadID, containingThreadID } = descendantThreadInfo; if (threadID !== parentThreadID && threadID !== containingThreadID) { continue; } let user = users.get(userID); if (!user) { user = {}; users.set(userID, user); } if (threadID === parentThreadID) { user.nextPermissionsFromParent = changes.permissionsForChildren; user.potentiallyNeedsUpdate = true; } if (threadID === containingThreadID) { user.nextMemberOfContainingThread = changes.userIsMember; if (!user.nextMemberOfContainingThread) { user.potentiallyNeedsUpdate = true; } } } } } return [...descendantThreadInfos.values()]; } function getDescendantDepth(descendant: DescendantInfo): number { return descendant.depth; } function getDescendantKey(descendant: DescendantInfo): string { return descendant.threadID; } function mergeDescendants( a: DescendantInfo, b: DescendantInfo, ): DescendantInfo { const { users: usersA, ...restA } = a; const { users: usersB, ...restB } = b; if (!_isEqual(restA)(restB)) { console.warn( `inconsistent descendantInfos ${JSON.stringify(restA)}, ` + JSON.stringify(restB), ); throw new ServerError('internal_error'); } const newUsers = new Map(usersA); for (const [userID, userFromB] of usersB) { const userFromA = newUsers.get(userID); if (!userFromA) { newUsers.set(userID, userFromB); } else { newUsers.set(userID, { ...userFromA, ...userFromB }); } } return { ...a, users: newUsers }; } type RecalculatePermissionsMemberInfo = { role?: ?string, permissions?: ?ThreadPermissionsBlob, permissionsForChildren?: ?ThreadPermissionsBlob, rolePermissions?: ?ThreadRolePermissionsBlob, memberOfContainingThread?: boolean, permissionsFromParent?: ?ThreadPermissionsBlob, }; async function recalculateThreadPermissions( threadID: string, ): Promise { const threadQuery = SQL` SELECT type, depth, parent_thread_id, containing_thread_id FROM threads WHERE id = ${threadID} `; const membershipQuery = SQL` SELECT m.user, m.role, m.permissions, m.permissions_for_children, r.permissions AS role_permissions, cm.role AS containing_role FROM threads t INNER JOIN memberships m ON m.thread = t.id LEFT JOIN roles r ON r.id = m.role LEFT JOIN memberships cm ON cm.user = m.user AND cm.thread = t.containing_thread_id WHERE t.id = ${threadID} `; const parentMembershipQuery = SQL` SELECT pm.user, pm.permissions_for_children AS permissions_from_parent FROM threads t INNER JOIN memberships pm ON pm.thread = t.parent_thread_id WHERE t.id = ${threadID} `; const [[threadResults], [membershipResults], [parentMembershipResults]] = await Promise.all([ dbQuery(threadQuery), dbQuery(membershipQuery), dbQuery(parentMembershipQuery), ]); if (threadResults.length !== 1) { throw new ServerError('internal_error'); } const [threadResult] = threadResults; const threadType = assertThreadType(threadResult.type); const depth = threadResult.depth; const hasContainingThreadID = threadResult.containing_thread_id !== null; const parentThreadID = threadResult.parent_thread_id?.toString(); const membershipInfo: Map = new Map(); for (const row of membershipResults) { const userID = row.user.toString(); membershipInfo.set(userID, { role: row.role.toString(), permissions: JSON.parse(row.permissions), permissionsForChildren: JSON.parse(row.permissions_for_children), rolePermissions: JSON.parse(row.role_permissions), memberOfContainingThread: !!( row.containing_role && row.containing_role > 0 ), }); } for (const row of parentMembershipResults) { const userID = row.user.toString(); const permissionsFromParent = JSON.parse(row.permissions_from_parent); const membership = membershipInfo.get(userID); if (membership) { membership.permissionsFromParent = permissionsFromParent; } else { membershipInfo.set(userID, { permissionsFromParent: permissionsFromParent, }); } } const relationshipChangeset = new RelationshipChangeset(); const existingMemberIDs = membershipResults.map(row => row.user.toString()); if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(existingMemberIDs); } const parentMemberIDs = parentMembershipResults.map(row => row.user.toString(), ); if (parentThreadID && parentThreadID !== genesis.id) { relationshipChangeset.setAllRelationshipsExist(parentMemberIDs); } const membershipRows = []; const toUpdateDescendants = new Map(); for (const [userID, membership] of membershipInfo) { const { rolePermissions: intendedRolePermissions, permissionsFromParent } = membership; const oldPermissions = membership?.permissions ?? null; const oldPermissionsForChildren = membership?.permissionsForChildren ?? null; const existingMembership = membership.role !== undefined; const oldRole = membership.role ?? '-1'; const memberOfContainingThread = hasContainingThreadID ? !!membership.memberOfContainingThread : true; const targetRole = memberOfContainingThread ? oldRole : '-1'; const rolePermissions = memberOfContainingThread ? intendedRolePermissions : null; const permissions = makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ); const permissionsForChildren = makePermissionsForChildrenBlob(permissions); const newRole = getRoleForPermissions(targetRole, permissions); const userLostMembership = Number(oldRole) > 0 && Number(newRole) <= 0; if (_isEqual(permissions)(oldPermissions) && oldRole === newRole) { // This thread and all of its descendants need no updates for this user, // since the corresponding memberships row is unchanged by this operation continue; } if (permissions) { membershipRows.push({ operation: 'save', intent: 'none', userID, threadID, userNeedsFullThreadDetails: false, permissions, permissionsForChildren, role: newRole, oldRole, }); } else { membershipRows.push({ operation: 'delete', intent: 'none', userID, threadID, oldRole, }); } if (permissions && !existingMembership && threadID !== genesis.id) { // If there was no membership row before, and we are creating one, // we'll need to make sure the new member has a relationship row with // each existing member. We handle guaranteeing that new members have // relationship rows with each other in saveMemberships. relationshipChangeset.setRelationshipsNeeded(userID, existingMemberIDs); } if ( userLostMembership || !_isEqual(permissionsForChildren)(oldPermissionsForChildren) ) { toUpdateDescendants.set(userID, { userIsMember: Number(newRole) > 0, permissionsForChildren, }); } } if (toUpdateDescendants.size > 0) { const { membershipRows: descendantMembershipRows, relationshipChangeset: descendantRelationshipChangeset, } = await updateDescendantPermissions({ threadID, depth, changesByUser: toUpdateDescendants, }); pushAll(membershipRows, descendantMembershipRows); relationshipChangeset.addAll(descendantRelationshipChangeset); } return { membershipRows, relationshipChangeset }; } const defaultSubscriptionString = JSON.stringify({ home: false, pushNotifs: false, }); const joinSubscriptionString = JSON.stringify({ home: true, pushNotifs: true }); const membershipInsertBatchSize = 50; async function saveMemberships(toSave: $ReadOnlyArray) { if (toSave.length === 0) { return; } const time = Date.now(); const insertRows = []; for (const rowToSave of toSave) { insertRows.push([ rowToSave.userID, rowToSave.threadID, rowToSave.role, time, rowToSave.intent === 'join' ? joinSubscriptionString : defaultSubscriptionString, rowToSave.permissions ? JSON.stringify(rowToSave.permissions) : null, rowToSave.permissionsForChildren ? JSON.stringify(rowToSave.permissionsForChildren) : null, rowToSave.unread ? 1 : 0, 0, ]); } // Logic below will only update an existing membership row's `subscription` // column if the user is either joining or leaving the thread. That means // there's no way to use this function to update a user's subscription without // also making them join or leave the thread. The reason we do this is because // we need to specify a value for `subscription` here, as it's a non-null // column and this is an INSERT, but we don't want to require people to have // to know the current `subscription` when they're just using this function to // update the permissions of an existing membership row. while (insertRows.length > 0) { const batch = insertRows.splice(0, membershipInsertBatchSize); const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${batch} ON DUPLICATE KEY UPDATE subscription = IF( (role <= 0 AND VALUE(role) > 0) OR (role > 0 AND VALUE(role) <= 0), VALUE(subscription), subscription ), role = VALUE(role), permissions = VALUE(permissions), permissions_for_children = VALUE(permissions_for_children) `; await dbQuery(query); } } async function deleteMemberships( toDelete: $ReadOnlyArray, ) { if (toDelete.length === 0) { return; } const time = Date.now(); const insertRows = toDelete.map(rowToDelete => [ rowToDelete.userID, rowToDelete.threadID, -1, time, defaultSubscriptionString, null, null, 0, 0, ]); while (insertRows.length > 0) { const batch = insertRows.splice(0, membershipInsertBatchSize); const query = SQL` INSERT INTO memberships (user, thread, role, creation_time, subscription, permissions, permissions_for_children, last_message, last_read_message) VALUES ${batch} ON DUPLICATE KEY UPDATE role = -1, permissions = NULL, permissions_for_children = NULL, subscription = ${defaultSubscriptionString}, last_message = 0, last_read_message = 0 `; await dbQuery(query); } } const emptyCommitMembershipChangesetConfig = Object.freeze({}); // Specify non-empty changedThreadIDs to force updates to be generated for those // threads, presumably for reasons not covered in the changeset. calendarQuery // only needs to be specified if a JOIN_THREAD update will be generated for the // viewer, in which case it's necessary for knowing the set of entries to fetch. type ChangesetCommitResult = { ...FetchThreadInfosResult, ...CreateUpdatesResult, }; async function commitMembershipChangeset( viewer: Viewer, changeset: Changeset, { changedThreadIDs = new Set(), calendarQuery, updatesForCurrentSession = 'return', }: { +changedThreadIDs?: Set, +calendarQuery?: ?CalendarQuery, +updatesForCurrentSession?: UpdatesForCurrentSession, } = emptyCommitMembershipChangesetConfig, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { membershipRows, relationshipChangeset } = changeset; const membershipRowMap = new Map(); for (const row of membershipRows) { const { userID, threadID } = row; changedThreadIDs.add(threadID); const pairString = `${userID}|${threadID}`; const existing = membershipRowMap.get(pairString); invariant( !existing || existing.intent === 'none' || row.intent === 'none', `multiple intents provided for ${pairString}`, ); if (!existing || existing.intent === 'none') { membershipRowMap.set(pairString, row); } } const toSave = [], toDelete = [], toRescindPushNotifs = []; for (const row of membershipRowMap.values()) { if ( row.operation === 'delete' || (row.operation === 'save' && Number(row.role) <= 0) ) { const { userID, threadID } = row; toRescindPushNotifs.push({ userID, threadID }); } if (row.operation === 'delete') { toDelete.push(row); } else { toSave.push(row); } } const threadsToSavedUsers = new Map(); for (const row of membershipRowMap.values()) { const { userID, threadID } = row; let savedUsers = threadsToSavedUsers.get(threadID); if (!savedUsers) { savedUsers = []; threadsToSavedUsers.set(threadID, savedUsers); } savedUsers.push(userID); } for (const [threadID, savedUsers] of threadsToSavedUsers) { if (threadID !== genesis.id) { relationshipChangeset.setAllRelationshipsNeeded(savedUsers); } } const relationshipRows = relationshipChangeset.getRows(); const [updateDatas] = await Promise.all([ updateChangedUndirectedRelationships(relationshipRows), saveMemberships(toSave), deleteMemberships(toDelete), rescindPushNotifsForMemberDeletion(toRescindPushNotifs), ]); // We fetch all threads here because old clients still expect the full list of // threads on most thread operations. Once verifyClientSupported gates on // codeVersion 62, we can add a WHERE clause on changedThreadIDs here const serverThreadInfoFetchResult = await fetchServerThreadInfos(); const { threadInfos: serverThreadInfos } = serverThreadInfoFetchResult; const time = Date.now(); for (const changedThreadID of changedThreadIDs) { const serverThreadInfo = serverThreadInfos[changedThreadID]; for (const memberInfo of serverThreadInfo.members) { const pairString = `${memberInfo.id}|${serverThreadInfo.id}`; const membershipRow = membershipRowMap.get(pairString); if (membershipRow) { continue; } updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: memberInfo.id, time, threadID: changedThreadID, }); } } for (const row of membershipRowMap.values()) { const { userID, threadID } = row; if (row.operation === 'delete' || row.role === '-1') { if (row.oldRole !== '-1') { updateDatas.push({ type: updateTypes.DELETE_THREAD, userID, time, threadID, }); } } else if (row.userNeedsFullThreadDetails) { updateDatas.push({ type: updateTypes.JOIN_THREAD, userID, time, threadID, }); } else { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID, time, threadID, }); } } const threadInfoFetchResult = rawThreadInfosFromServerThreadInfos( viewer, serverThreadInfoFetchResult, ); const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, ...threadInfoFetchResult, updatesForCurrentSession, }); return { ...threadInfoFetchResult, userInfos, viewerUpdates, }; } const emptyGetChangesetCommitResultConfig = Object.freeze({}); // When the user tries to create a new thread, it's possible for the client to // fail the creation even if a row gets added to the threads table. This may // occur due to a timeout (on either the client or server side), or due to some // error in the server code following the INSERT operation. Handling the error // scenario is more challenging since it would require detecting which set of // operations failed so we could retry them. As a result, this code is geared at // only handling the timeout scenario. async function getChangesetCommitResultForExistingThread( viewer: Viewer, threadID: string, otherUpdates: $ReadOnlyArray, { calendarQuery, updatesForCurrentSession = 'return', }: { +calendarQuery?: ?CalendarQuery, +updatesForCurrentSession?: UpdatesForCurrentSession, } = emptyGetChangesetCommitResultConfig, ): Promise { for (const update of otherUpdates) { if ( update.type === updateTypes.JOIN_THREAD && update.threadInfo.id === threadID ) { // If the JOIN_THREAD is already there we can expect // the appropriate UPDATE_USERs to be covered as well return { viewerUpdates: otherUpdates, userInfos: {} }; } } const time = Date.now(); const updateDatas = [ { type: updateTypes.JOIN_THREAD, userID: viewer.userID, time, threadID, targetSession: viewer.session, }, ]; // To figure out what UserInfos might be missing, we consider the worst case: // the same client previously attempted to create a thread with a non-friend // they found via search results, but the request timed out. In this scenario // the viewer might never have received the UPDATE_USER that would add that // UserInfo to their UserStore, but the server assumed the client had gotten // it because createUpdates was called with UpdatesForCurrentSession=return. // For completeness here we query for the full list of memberships rows in the // thread. We can't use fetchServerThreadInfos because it skips role=-1 rows const membershipsQuery = SQL` SELECT user FROM memberships WHERE thread = ${threadID} AND user != ${viewer.userID} `; const [results] = await dbQuery(membershipsQuery); for (const row of results) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: viewer.userID, time, updatedUserID: row.user.toString(), targetSession: viewer.session, }); } const { viewerUpdates, userInfos } = await createUpdates(updateDatas, { viewer, calendarQuery, updatesForCurrentSession, }); return { viewerUpdates: [...otherUpdates, ...viewerUpdates], userInfos }; } const rescindPushNotifsBatchSize = 3; async function rescindPushNotifsForMemberDeletion( toRescindPushNotifs: $ReadOnlyArray<{ +userID: string, +threadID: string }>, ): Promise { const queue = [...toRescindPushNotifs]; while (queue.length > 0) { const batch = queue.splice(0, rescindPushNotifsBatchSize); await Promise.all( batch.map(({ userID, threadID }) => rescindPushNotifs( SQL`n.thread = ${threadID} AND n.user = ${userID}`, SQL`IF(m.thread = ${threadID}, NULL, m.thread)`, ), ), ); } } async function recalculateAllThreadPermissions() { const getAllThreads = SQL`SELECT id FROM threads`; const [result] = await dbQuery(getAllThreads); // We handle each thread one-by-one to avoid a situation where a permission // calculation for a child thread, done during a call to // recalculateThreadPermissions for the parent thread, can be incorrectly // overriden by a call to recalculateThreadPermissions for the child thread. // If the changeset resulting from the parent call isn't committed before the // calculation is done for the child, the calculation done for the child can // be incorrect. const viewer = createScriptViewer(bots.commbot.userID); for (const row of result) { const threadID = row.id.toString(); const changeset = await recalculateThreadPermissions(threadID); await commitMembershipChangeset(viewer, changeset); } } async function updateRolesAndPermissionsForAllThreads() { const batchSize = 10; const fetchThreads = SQL`SELECT id, type, depth FROM threads`; const [result] = await dbQuery(fetchThreads); const allThreads = result.map(row => { return { id: row.id.toString(), type: assertThreadType(row.type), depth: row.depth, }; }); const viewer = createScriptViewer(bots.commbot.userID); const maxDepth = Math.max(...allThreads.map(row => row.depth)); for (let depth = 0; depth <= maxDepth; depth++) { const threads = allThreads.filter(row => row.depth === depth); console.log(`recalculating permissions for threads with depth ${depth}`); while (threads.length > 0) { const batch = threads.splice(0, batchSize); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); await Promise.all( batch.map(async thread => { console.log(`updating roles for ${thread.id}`); await updateRoles(viewer, thread.id, thread.type); console.log(`recalculating permissions for ${thread.id}`); const { membershipRows: threadMembershipRows, relationshipChangeset: threadRelationshipChangeset, } = await recalculateThreadPermissions(thread.id); membershipRows.push(...threadMembershipRows); relationshipChangeset.addAll(threadRelationshipChangeset); }), ); console.log(`committing batch ${JSON.stringify(batch)}`); await commitMembershipChangeset(viewer, { membershipRows, relationshipChangeset, }); } } } export { changeRole, recalculateThreadPermissions, getChangesetCommitResultForExistingThread, saveMemberships, commitMembershipChangeset, recalculateAllThreadPermissions, updateRolesAndPermissionsForAllThreads, }; diff --git a/keyserver/src/updaters/thread-updaters.js b/keyserver/src/updaters/thread-updaters.js index 3ec80c1b4..4a138a1ff 100644 --- a/keyserver/src/updaters/thread-updaters.js +++ b/keyserver/src/updaters/thread-updaters.js @@ -1,1029 +1,1029 @@ // @flow import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors.js'; import { getPinnedContentFromMessage } from 'lib/shared/message-utils.js'; import { threadHasAdminRole, roleIsAdminRole, viewerIsMember, getThreadTypeParentRequirement, validChatNameRegex, } from 'lib/shared/thread-utils.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { Shape } from 'lib/types/core.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type ServerThreadJoinRequest, type ThreadJoinResult, type ToggleMessagePinRequest, type ToggleMessagePinResult, } from 'lib/types/thread-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { firstLine } from 'lib/utils/string-utils.js'; import { reportLinkUsage } from './link-updaters.js'; import { updateRoles } from './role-updaters.js'; import { changeRole, recalculateThreadPermissions, commitMembershipChangeset, } from './thread-permission-updaters.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { checkIfInviteLinkIsValid } from '../fetchers/link-fetchers.js'; import { fetchMessageInfos, fetchMessageInfoByID, } from '../fetchers/message-fetchers.js'; import { fetchThreadInfos, fetchServerThreadInfos, determineThreadAncestry, } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission, viewerIsMember as fetchViewerIsMember, checkThread, validateCandidateMembers, } from '../fetchers/thread-permission-fetchers.js'; import { verifyUserIDs, verifyUserOrCookieIDs, } from '../fetchers/user-fetchers.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; import RelationshipChangeset from '../utils/relationship-changeset.js'; async function updateRole( viewer: Viewer, request: RoleChangeRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT user, role FROM memberships WHERE user IN (${memberIDs}) AND thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonMemberUser = false; let numResults = 0; for (const row of result) { if (row.role <= 0) { nonMemberUser = true; break; } numResults++; } if (nonMemberUser || numResults < memberIDs.length) { throw new ServerError('invalid_parameters'); } const changeset = await changeRole(request.threadID, memberIDs, request.role); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.CHANGE_ROLE, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), userIDs: memberIDs, newRole: request.role, }; const newMessageInfos = await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function removeMembers( viewer: Viewer, request: RemoveMembersRequest, ): Promise { const viewerID = viewer.userID; if (request.memberIDs.includes(viewerID)) { throw new ServerError('invalid_parameters'); } const [memberIDs, hasPermission] = await Promise.all([ verifyUserOrCookieIDs(request.memberIDs), checkThreadPermission( viewer, request.threadID, threadPermissions.REMOVE_MEMBERS, ), ]); if (memberIDs.length === 0) { throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT m.user, m.role, t.default_role FROM memberships m LEFT JOIN threads t ON t.id = m.thread WHERE m.user IN (${memberIDs}) AND m.thread = ${request.threadID} `; const [result] = await dbQuery(query); let nonDefaultRoleUser = false; const actualMemberIDs = []; for (const row of result) { if (row.role <= 0) { continue; } actualMemberIDs.push(row.user.toString()); if (row.role !== row.default_role) { nonDefaultRoleUser = true; } } if (nonDefaultRoleUser) { const hasChangeRolePermission = await checkThreadPermission( viewer, request.threadID, threadPermissions.CHANGE_ROLE, ); if (!hasChangeRolePermission) { throw new ServerError('invalid_credentials'); } } const changeset = await changeRole(request.threadID, actualMemberIDs, 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const newMessageInfos = await (async () => { if (actualMemberIDs.length === 0) { return []; } const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID: request.threadID, creatorID: viewerID, time: Date.now(), removedUserIDs: actualMemberIDs, }; return await createMessages(viewer, [messageData]); })(); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function leaveThread( viewer: Viewer, request: LeaveThreadRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const [fetchThreadResult, hasPermission] = await Promise.all([ fetchThreadInfos(viewer, SQL`t.id = ${request.threadID}`), checkThreadPermission( viewer, request.threadID, threadPermissions.LEAVE_THREAD, ), ]); const threadInfo = fetchThreadResult.threadInfos[request.threadID]; if (!viewerIsMember(threadInfo)) { if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: [] }, }; } const { threadInfos } = await fetchThreadInfos(viewer); return { threadInfos, updatesResult: { newUpdates: [], }, }; } if (!hasPermission) { throw new ServerError('invalid_parameters'); } const viewerID = viewer.userID; if (threadHasAdminRole(threadInfo)) { let otherUsersExist = false; let otherAdminsExist = false; for (const member of threadInfo.members) { const role = member.role; if (!role || member.id === viewerID) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo.roles[role])) { otherAdminsExist = true; break; } } if (otherUsersExist && !otherAdminsExist) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewerID], 0); const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, ); const messageData = { type: messageTypes.LEAVE_THREAD, threadID: request.threadID, creatorID: viewerID, time: Date.now(), }; await createMessages(viewer, [messageData]); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates } }; } return { threadInfos, updatesResult: { newUpdates: viewerUpdates, }, }; } type UpdateThreadOptions = Shape<{ +forceAddMembers: boolean, +forceUpdateRoot: boolean, +silenceMessages: boolean, +ignorePermissions: boolean, }>; async function updateThread( viewer: Viewer, request: UpdateThreadRequest, options?: UpdateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const forceUpdateRoot = options?.forceUpdateRoot ?? false; const silenceMessages = options?.silenceMessages ?? false; const ignorePermissions = (options?.ignorePermissions && viewer.isScriptViewer) ?? false; const validationPromises = {}; const changedFields = {}; const sqlUpdate = {}; const untrimmedName = request.changes.name; if (untrimmedName !== undefined && untrimmedName !== null) { const name = firstLine(untrimmedName); if (name.search(validChatNameRegex) === -1) { throw new ServerError('invalid_chat_name'); } changedFields.name = name; sqlUpdate.name = name ?? null; } const { description } = request.changes; if (description !== undefined && description !== null) { changedFields.description = description; sqlUpdate.description = description ?? null; } if (request.changes.color) { const color = request.changes.color.toLowerCase(); changedFields.color = color; sqlUpdate.color = color; } const { parentThreadID } = request.changes; if (parentThreadID !== undefined) { // TODO some sort of message when this changes sqlUpdate.parent_thread_id = parentThreadID; } const { avatar } = request.changes; if (avatar) { changedFields.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; sqlUpdate.avatar = avatar.type !== 'remove' ? JSON.stringify(avatar) : null; } const threadType = request.changes.type; if (threadType !== null && threadType !== undefined) { changedFields.type = threadType; sqlUpdate.type = threadType; } if ( !ignorePermissions && threadType !== null && threadType !== undefined && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && threadType !== threadTypes.COMMUNITY_SECRET_SUBTHREAD ) { throw new ServerError('invalid_parameters'); } const newMemberIDs = request.changes.newMemberIDs && request.changes.newMemberIDs.length > 0 ? [...new Set(request.changes.newMemberIDs)] : null; if ( Object.keys(sqlUpdate).length === 0 && !newMemberIDs && !forceUpdateRoot ) { throw new ServerError('invalid_parameters'); } validationPromises.serverThreadInfos = fetchServerThreadInfos( SQL`t.id = ${request.threadID}`, ); validationPromises.hasNecessaryPermissions = (async () => { if (ignorePermissions) { return; } const checks = []; if (sqlUpdate.name !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_NAME, }); } if (sqlUpdate.description !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_DESCRIPTION, }); } if (sqlUpdate.color !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_COLOR, }); } if (sqlUpdate.avatar !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_THREAD_AVATAR, }); } if (parentThreadID !== undefined || sqlUpdate.type !== undefined) { checks.push({ check: 'permission', permission: threadPermissions.EDIT_PERMISSIONS, }); } if (newMemberIDs) { checks.push({ check: 'permission', permission: threadPermissions.ADD_MEMBERS, }); } const hasNecessaryPermissions = await checkThread( viewer, request.threadID, checks, ); if (!hasNecessaryPermissions) { throw new ServerError('invalid_credentials'); } })(); const { serverThreadInfos } = await promiseAll(validationPromises); const serverThreadInfo = serverThreadInfos.threadInfos[request.threadID]; if (!serverThreadInfo) { throw new ServerError('internal_error'); } // Threads with source message should be visible to everyone, but we can't // guarantee it for COMMUNITY_SECRET_SUBTHREAD threads so we forbid it for // now. In the future, if we want to support this, we would need to unlink the // source message. if ( threadType !== null && threadType !== undefined && threadType !== threadTypes.SIDEBAR && threadType !== threadTypes.COMMUNITY_OPEN_SUBTHREAD && serverThreadInfo.sourceMessageID ) { throw new ServerError('invalid_parameters'); } // You can't change the parent thread of a current or former SIDEBAR if (parentThreadID !== undefined && serverThreadInfo.sourceMessageID) { throw new ServerError('invalid_parameters'); } const oldThreadType = serverThreadInfo.type; const oldParentThreadID = serverThreadInfo.parentThreadID; const oldContainingThreadID = serverThreadInfo.containingThreadID; const oldCommunity = serverThreadInfo.community; const oldDepth = serverThreadInfo.depth; const nextThreadType = threadType !== null && threadType !== undefined ? threadType : oldThreadType; let nextParentThreadID = parentThreadID !== undefined ? parentThreadID : oldParentThreadID; // Does the new thread type preclude a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'disabled' && nextParentThreadID !== null ) { nextParentThreadID = null; sqlUpdate.parent_thread_id = null; } // Does the new thread type require a parent? if ( threadType !== undefined && threadType !== null && getThreadTypeParentRequirement(threadType) === 'required' && nextParentThreadID === null ) { throw new ServerError('no_parent_thread_specified'); } const determineThreadAncestryPromise = determineThreadAncestry( nextParentThreadID, nextThreadType, ); const confirmParentPermissionPromise = (async () => { if (ignorePermissions || !nextParentThreadID) { return; } if ( nextParentThreadID === oldParentThreadID && (nextThreadType === threadTypes.SIDEBAR) === (oldThreadType === threadTypes.SIDEBAR) ) { return; } const hasParentPermission = await checkThreadPermission( viewer, nextParentThreadID, nextThreadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBCHANNELS, ); if (!hasParentPermission) { throw new ServerError('invalid_parameters'); } })(); const rolesNeedUpdate = forceUpdateRoot || nextThreadType !== oldThreadType; const validateNewMembersPromise = (async () => { if (!newMemberIDs || ignorePermissions) { return; } const defaultRolePermissionsPromise = (async () => { let rolePermissions; if (!rolesNeedUpdate) { const rolePermissionsQuery = SQL` SELECT r.permissions FROM threads t LEFT JOIN roles r ON r.id = t.default_role WHERE t.id = ${request.threadID} `; const [result] = await dbQuery(rolePermissionsQuery); if (result.length > 0) { rolePermissions = JSON.parse(result[0].permissions); } } if (!rolePermissions) { rolePermissions = getRolePermissionBlobs(nextThreadType).Members; } return rolePermissions; })(); const [defaultRolePermissions, nextThreadAncestry] = await Promise.all([ defaultRolePermissionsPromise, determineThreadAncestryPromise, ]); const { newMemberIDs: validatedIDs } = await validateCandidateMembers( viewer, { newMemberIDs }, { threadType: nextThreadType, parentThreadID: nextParentThreadID, containingThreadID: nextThreadAncestry.containingThreadID, defaultRolePermissions, }, { requireRelationship: !forceAddMembers }, ); if ( validatedIDs && Number(validatedIDs?.length) < Number(newMemberIDs?.length) ) { throw new ServerError('invalid_credentials'); } })(); const { nextThreadAncestry } = await promiseAll({ nextThreadAncestry: determineThreadAncestryPromise, confirmParentPermissionPromise, validateNewMembersPromise, }); if (nextThreadAncestry.containingThreadID !== oldContainingThreadID) { sqlUpdate.containing_thread_id = nextThreadAncestry.containingThreadID; } if (nextThreadAncestry.community !== oldCommunity) { if (!ignorePermissions) { throw new ServerError('invalid_parameters'); } sqlUpdate.community = nextThreadAncestry.community; } if (nextThreadAncestry.depth !== oldDepth) { sqlUpdate.depth = nextThreadAncestry.depth; } const updateQueryPromise = (async () => { if (Object.keys(sqlUpdate).length === 0) { return; } const { avatar: avatarUpdate, ...nonAvatarUpdates } = sqlUpdate; const updatePromises = []; if (Object.keys(nonAvatarUpdates).length > 0) { const nonAvatarUpdateQuery = SQL` UPDATE threads SET ${nonAvatarUpdates} WHERE id = ${request.threadID} `; updatePromises.push(dbQuery(nonAvatarUpdateQuery)); } if (avatarUpdate !== undefined) { const avatarUploadID = avatar && avatar.type === 'image' ? avatar.uploadID : null; const avatarUpdateQuery = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE container = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${request.threadID} WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE threads SET avatar = ${avatarUpdate} WHERE id = ${request.threadID} AND ( ${avatarUploadID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${avatarUploadID} AND ${avatarUploadID} IS NOT NULL AND uploader = ${viewer.userID} AND container = ${request.threadID} AND thread IS NULL ) ); COMMIT; `; updatePromises.push( dbQuery(avatarUpdateQuery, { multipleStatements: true }), ); } await Promise.all(updatePromises); })(); const updateRolesPromise = (async () => { if (rolesNeedUpdate) { await updateRoles(viewer, request.threadID, nextThreadType); } })(); const intermediatePromises = {}; intermediatePromises.updateQuery = updateQueryPromise; intermediatePromises.updateRoles = updateRolesPromise; if (newMemberIDs) { intermediatePromises.addMembersChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await changeRole(request.threadID, newMemberIDs, null, { setNewMembersToUnread: true, }); })(); } const threadRootChanged = rolesNeedUpdate || nextParentThreadID !== oldParentThreadID; if (threadRootChanged) { intermediatePromises.recalculatePermissionsChangeset = (async () => { await Promise.all([updateQueryPromise, updateRolesPromise]); return await recalculateThreadPermissions(request.threadID); })(); } const { addMembersChangeset, recalculatePermissionsChangeset } = await promiseAll(intermediatePromises); const membershipRows = []; const relationshipChangeset = new RelationshipChangeset(); if (recalculatePermissionsChangeset) { const { membershipRows: recalculateMembershipRows, relationshipChangeset: recalculateRelationshipChangeset, } = recalculatePermissionsChangeset; membershipRows.push(...recalculateMembershipRows); relationshipChangeset.addAll(recalculateRelationshipChangeset); } let addedMemberIDs; if (addMembersChangeset) { const { membershipRows: addMembersMembershipRows, relationshipChangeset: addMembersRelationshipChangeset, } = addMembersChangeset; addedMemberIDs = addMembersMembershipRows .filter( row => row.operation === 'save' && row.threadID === request.threadID && Number(row.role) > 0, ) .map(row => row.userID); membershipRows.push(...addMembersMembershipRows); relationshipChangeset.addAll(addMembersRelationshipChangeset); } const changeset = { membershipRows, relationshipChangeset }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { // This forces an update for this thread, // regardless of whether any membership rows are changed changedThreadIDs: Object.keys(sqlUpdate).length > 0 ? new Set([request.threadID]) : new Set(), }, ); let newMessageInfos = []; if (!silenceMessages) { const time = Date.now(); const messageDatas = []; for (const fieldName in changedFields) { const newValue = changedFields[fieldName]; messageDatas.push({ type: messageTypes.CHANGE_SETTINGS, threadID: request.threadID, creatorID: viewer.userID, time, field: fieldName, value: newValue, }); } if (addedMemberIDs && addedMemberIDs.length > 0) { messageDatas.push({ type: messageTypes.ADD_MEMBERS, threadID: request.threadID, creatorID: viewer.userID, time, addedUserIDs: addedMemberIDs, }); } newMessageInfos = await createMessages(viewer, messageDatas); } if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { updatesResult: { newUpdates: viewerUpdates }, newMessageInfos }; } return { threadInfo: threadInfos[request.threadID], threadInfos, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } async function joinThread( viewer: Viewer, request: ServerThreadJoinRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const permissionCheck = request.inviteLinkSecret ? checkIfInviteLinkIsValid(request.inviteLinkSecret, request.threadID) : checkThreadPermission( viewer, request.threadID, threadPermissions.JOIN_THREAD, ); const [isMember, hasPermission] = await Promise.all([ fetchViewerIsMember(viewer, request.threadID), permissionCheck, ]); if (!hasPermission) { throw new ServerError('invalid_parameters'); } // TODO: determine code version const hasCodeVersionBelow87 = !hasMinCodeVersion(viewer.platformDetails, 87); const hasCodeVersionBelow62 = !hasMinCodeVersion(viewer.platformDetails, 62); const { calendarQuery } = request; if (isMember) { const response: ThreadJoinResult = { rawMessageInfos: [], truncationStatuses: {}, userInfos: {}, updatesResult: { newUpdates: [], }, }; if (calendarQuery && hasCodeVersionBelow87) { response.rawEntryInfos = []; } if (hasCodeVersionBelow62) { response.threadInfos = {}; } return response; } if (calendarQuery) { const threadFilterIDs = filteredThreadIDs(calendarQuery.filters); if ( !threadFilterIDs || threadFilterIDs.size !== 1 || threadFilterIDs.values().next().value !== request.threadID ) { throw new ServerError('invalid_parameters'); } } const changeset = await changeRole(request.threadID, [viewer.userID], null); const membershipResult = await commitMembershipChangeset(viewer, changeset, { calendarQuery, }); if (request.inviteLinkSecret) { handleAsyncPromise(reportLinkUsage(request.inviteLinkSecret)); } const messageData = { type: messageTypes.JOIN_THREAD, threadID: request.threadID, creatorID: viewer.userID, time: Date.now(), }; const newMessages = await createMessages(viewer, [messageData]); const messageSelectionCriteria = { threadCursors: { [request.threadID]: false }, }; if (!hasCodeVersionBelow87) { return { rawMessageInfos: newMessages, truncationStatuses: {}, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; } const [fetchMessagesResult, fetchEntriesResult] = await Promise.all([ fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, ]); const rawEntryInfos = fetchEntriesResult && fetchEntriesResult.rawEntryInfos; const response: ThreadJoinResult = { rawMessageInfos: fetchMessagesResult.rawMessageInfos, truncationStatuses: fetchMessagesResult.truncationStatuses, userInfos: membershipResult.userInfos, updatesResult: { newUpdates: membershipResult.viewerUpdates, }, }; if (hasCodeVersionBelow62) { response.threadInfos = membershipResult.threadInfos; } if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } async function updateThreadMembers(viewer: Viewer) { const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.parent_thread_id IS NOT NULL `, ); const updateDatas = []; const time = Date.now(); for (const threadID in threadInfos) { updateDatas.push({ type: updateTypes.UPDATE_THREAD, userID: viewer.id, time, threadID: threadID, targetSession: viewer.session, }); } await createUpdates(updateDatas); } async function toggleMessagePinForThread( viewer: Viewer, request: ToggleMessagePinRequest, ): Promise { const { messageID, action } = request; const targetMessage = await fetchMessageInfoByID(viewer, messageID); if (!targetMessage) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessage; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.MANAGE_PINS, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const pinnedValue = action === 'pin' ? 1 : 0; const pinTimeValue = action === 'pin' ? Date.now() : null; const togglePinQuery = SQL` UPDATE messages SET pinned = ${pinnedValue}, pin_time = ${pinTimeValue} WHERE id = ${messageID} AND thread = ${threadID} `; const messageData = { type: messageTypes.TOGGLE_PIN, threadID, targetMessageID: messageID, action, pinnedContent: getPinnedContentFromMessage(targetMessage), creatorID: viewer.userID, time: Date.now(), }; let updateThreadQuery; if (action === 'pin') { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count + 1 WHERE id = ${threadID} `; } else { updateThreadQuery = SQL` UPDATE threads SET pinned_count = pinned_count - 1 WHERE id = ${threadID} `; } const [{ threadInfos: serverThreadInfos }] = await Promise.all([ fetchServerThreadInfos(SQL`t.id = ${threadID}`), dbQuery(togglePinQuery), dbQuery(updateThreadQuery), ]); const newMessageInfos = await createMessages(viewer, [messageData]); const time = Date.now(); const updates = []; for (const member of serverThreadInfos[threadID].members) { updates.push({ userID: member.id, time, threadID, type: updateTypes.UPDATE_THREAD, }); } await createUpdates(updates); return { newMessageInfos, threadID, }; } export { updateRole, removeMembers, leaveThread, updateThread, joinThread, updateThreadMembers, toggleMessagePinForThread, }; diff --git a/keyserver/src/updaters/user-subscription-updaters.js b/keyserver/src/updaters/user-subscription-updaters.js index 4f15379ac..ccb0a8350 100644 --- a/keyserver/src/updaters/user-subscription-updaters.js +++ b/keyserver/src/updaters/user-subscription-updaters.js @@ -1,63 +1,63 @@ // @flow import { viewerIsMember } from 'lib/shared/thread-utils.js'; import type { ThreadSubscription, SubscriptionUpdateRequest, } from 'lib/types/subscription-types.js'; -import { updateTypes } from 'lib/types/update-types.js'; +import { updateTypes } from 'lib/types/update-types-enum.js'; import { ServerError } from 'lib/utils/errors.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import type { Viewer } from '../session/viewer.js'; async function userSubscriptionUpdater( viewer: Viewer, update: SubscriptionUpdateRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { threadInfos } = await fetchThreadInfos( viewer, SQL`t.id = ${update.threadID}`, ); const threadInfo = threadInfos[update.threadID]; if (!viewerIsMember(threadInfo)) { throw new ServerError('not_member'); } const promises = []; const newSubscription = { ...threadInfo.currentUser.subscription, ...update.updatedFields, }; const saveQuery = SQL` UPDATE memberships SET subscription = ${JSON.stringify(newSubscription)} WHERE user = ${viewer.userID} AND thread = ${update.threadID} `; promises.push(dbQuery(saveQuery)); const time = Date.now(); const updateDatas = [ { type: updateTypes.UPDATE_THREAD, userID: viewer.userID, time, threadID: update.threadID, }, ]; promises.push( createUpdates(updateDatas, { viewer, updatesForCurrentSession: 'ignore' }), ); await Promise.all(promises); return newSubscription; } export { userSubscriptionUpdater }; diff --git a/lib/reducers/calendar-filters-reducer.js b/lib/reducers/calendar-filters-reducer.js index 45742216f..6d7230fb1 100644 --- a/lib/reducers/calendar-filters-reducer.js +++ b/lib/reducers/calendar-filters-reducer.js @@ -1,169 +1,169 @@ // @flow import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { newThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, deleteThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, } from '../actions/user-actions.js'; import { filteredThreadIDs, nonThreadCalendarFilters, nonExcludeDeletedCalendarFilters, } from '../selectors/calendar-filter-selectors.js'; import { threadInFilterList } from '../shared/thread-utils.js'; import { type CalendarFilter, defaultCalendarFilters, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, calendarThreadFilterTypes, } from '../types/filter-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { RawThreadInfo } from '../types/thread-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; import { - updateTypes, type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; export default function reduceCalendarFilters( state: $ReadOnlyArray, action: BaseAction, ): $ReadOnlyArray { if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return defaultCalendarFilters; } else if (action.type === updateCalendarThreadFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: action.payload.threadIDs, }, ]; } else if (action.type === clearCalendarThreadFilter) { return nonThreadCalendarFilters(state); } else if (action.type === setCalendarDeletedFilter) { const otherFilters = nonExcludeDeletedCalendarFilters(state); if (action.payload.includeDeleted && otherFilters.length === state.length) { // Attempting to remove NOT_DELETED filter, but it doesn't exist return state; } else if (action.payload.includeDeleted) { // Removing NOT_DELETED filter return otherFilters; } else if (otherFilters.length < state.length) { // Attempting to add NOT_DELETED filter, but it already exists return state; } else { // Adding NOT_DELETED filter return [...state, { type: calendarThreadFilterTypes.NOT_DELETED }]; } } else if ( action.type === newThreadActionTypes.success || action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === processUpdatesActionType ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === incrementalStateSyncActionType) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === fullStateSyncActionType) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } return state; } function updateFilterListFromUpdateInfos( state: $ReadOnlyArray, updateInfos: $ReadOnlyArray, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } let changeOccurred = false; for (const update of updateInfos) { if (update.type === updateTypes.DELETE_THREAD) { const result = currentlyFilteredIDs.delete(update.threadID); if (result) { changeOccurred = true; } } else if (update.type === updateTypes.JOIN_THREAD) { if ( !threadInFilterList(update.threadInfo) || currentlyFilteredIDs.has(update.threadInfo.id) ) { continue; } currentlyFilteredIDs.add(update.threadInfo.id); changeOccurred = true; } else if (update.type === updateTypes.UPDATE_THREAD) { if (threadInFilterList(update.threadInfo)) { continue; } const result = currentlyFilteredIDs.delete(update.threadInfo.id); if (result) { changeOccurred = true; } } } if (changeOccurred) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: [...currentlyFilteredIDs] }, ]; } return state; } function removeDeletedThreadIDsFromFilterList( state: $ReadOnlyArray, threadInfos: { +[id: string]: RawThreadInfo }, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const filtered = [...currentlyFilteredIDs].filter(threadID => threadInFilterList(threadInfos[threadID]), ); if (filtered.length < currentlyFilteredIDs.size) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: filtered }, ]; } return state; } diff --git a/lib/reducers/device-token-reducer.js b/lib/reducers/device-token-reducer.js index 77dda9c50..8708b8642 100644 --- a/lib/reducers/device-token-reducer.js +++ b/lib/reducers/device-token-reducer.js @@ -1,25 +1,25 @@ // @flow import { setDeviceTokenActionTypes } from '../actions/device-actions.js'; import type { BaseAction } from '../types/redux-types'; import { incrementalStateSyncActionType } from '../types/socket-types.js'; -import { updateTypes } from '../types/update-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; function reduceDeviceToken(state: ?string, action: BaseAction): ?string { if (action.type === setDeviceTokenActionTypes.success) { return action.payload; } if (action.type === incrementalStateSyncActionType) { for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state ) { return null; } } } return state; } export { reduceDeviceToken }; diff --git a/lib/reducers/entry-reducer.js b/lib/reducers/entry-reducer.js index 300d8081d..3a15cac3b 100644 --- a/lib/reducers/entry-reducer.js +++ b/lib/reducers/entry-reducer.js @@ -1,726 +1,726 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _isEqual from 'lodash/fp/isEqual.js'; import _map from 'lodash/fp/map.js'; import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _union from 'lodash/fp/union.js'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createLocalEntryActionType, createEntryActionTypes, saveEntryActionTypes, concurrentModificationResetActionType, deleteEntryActionTypes, fetchRevisionsForEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { deleteThreadActionTypes, leaveThreadActionTypes, joinThreadActionTypes, changeThreadSettingsActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from '../actions/user-actions.js'; import { entryID, filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from '../shared/entry-utils.js'; import { threadInFilterList } from '../shared/thread-utils.js'; import type { RawEntryInfo, EntryStore, CalendarQuery, } from '../types/entry-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ClientEntryInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { type RawThreadInfo } from '../types/thread-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; import { - updateTypes, type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { actionLogger } from '../utils/action-logger.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { dateString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; import { sanitizeActionSecrets } from '../utils/sanitization.js'; function daysToEntriesFromEntryInfos( entryInfos: $ReadOnlyArray, ): { [day: string]: string[] } { return _flow( _sortBy((['id', 'localID']: $ReadOnlyArray)), _groupBy((entryInfo: RawEntryInfo) => dateString(entryInfo.year, entryInfo.month, entryInfo.day), ), _mapValues((entryInfoGroup: $ReadOnlyArray) => _map(entryID)(entryInfoGroup), ), )([...entryInfos]); } function filterExistingDaysToEntriesWithNewEntryInfos( oldDaysToEntries: { +[id: string]: string[] }, newEntryInfos: { +[id: string]: RawEntryInfo }, ) { return _mapValues((entryIDs: string[]) => _filter((id: string) => newEntryInfos[id])(entryIDs), )(oldDaysToEntries); } function mergeNewEntryInfos( currentEntryInfos: { +[id: string]: RawEntryInfo }, currentDaysToEntries: ?{ +[day: string]: string[] }, newEntryInfos: $ReadOnlyArray, threadInfos: { +[id: string]: RawThreadInfo }, ) { const mergedEntryInfos = {}; let someEntryUpdated = false; for (const rawEntryInfo of newEntryInfos) { const serverID = rawEntryInfo.id; invariant(serverID, 'new entryInfos should have serverID'); const currentEntryInfo = currentEntryInfos[serverID]; let newEntryInfo; if (currentEntryInfo && currentEntryInfo.localID) { newEntryInfo = { id: serverID, // Try to preserve localIDs. This is because we use them as React // keys and changing React keys leads to loss of component state. localID: currentEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } else { newEntryInfo = { id: serverID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } if (_isEqual(currentEntryInfo)(newEntryInfo)) { mergedEntryInfos[serverID] = currentEntryInfo; } else { mergedEntryInfos[serverID] = newEntryInfo; someEntryUpdated = true; } } for (const id in currentEntryInfos) { const newEntryInfo = mergedEntryInfos[id]; if (!newEntryInfo) { mergedEntryInfos[id] = currentEntryInfos[id]; } } for (const id in mergedEntryInfos) { const entryInfo = mergedEntryInfos[id]; if (!threadInFilterList(threadInfos[entryInfo.threadID])) { someEntryUpdated = true; delete mergedEntryInfos[id]; } } const daysToEntries = !currentDaysToEntries || someEntryUpdated ? daysToEntriesFromEntryInfos(values(mergedEntryInfos)) : currentDaysToEntries; const entryInfos = someEntryUpdated ? mergedEntryInfos : currentEntryInfos; return [entryInfos, daysToEntries]; } function reduceEntryInfos( entryStore: EntryStore, action: BaseAction, newThreadInfos: { +[id: string]: RawThreadInfo }, ): [EntryStore, $ReadOnlyArray] { const { entryInfos, daysToEntries, lastUserInteractionCalendar } = entryStore; if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newLastUserInteractionCalendar = action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return [ { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } else if (action.type === setNewSessionActionType) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); const newLastUserInteractionCalendar = action.payload.sessionChange .cookieInvalidated ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return [ { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } else if (action.type === fetchEntriesActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if ( action.type === updateCalendarQueryActionTypes.started && action.payload && action.payload.calendarQuery ) { return [ { entryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === updateCalendarQueryActionTypes.success) { const newLastUserInteractionCalendar = action.payload.calendarQuery ? Date.now() : lastUserInteractionCalendar; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return [ { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } else if (action.type === createLocalEntryActionType) { const entryInfo = action.payload; const localID = entryInfo.localID; invariant(localID, 'localID should be set in CREATE_LOCAL_ENTRY'); const newEntryInfos = { ...entryInfos, [localID]: entryInfo, }; const dayString = dateString( entryInfo.year, entryInfo.month, entryInfo.day, ); const newDaysToEntries = { ...daysToEntries, [dayString]: _union([localID])(daysToEntries[dayString]), }; return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === createEntryActionTypes.success) { const localID = action.payload.localID; const serverID = action.payload.entryID; // If an entry with this serverID already got into the store somehow // (likely through an unrelated request), we need to dedup them. let rekeyedEntryInfos; if (entryInfos[serverID]) { // It's fair to assume the serverID entry is newer than the localID // entry, and this probably won't happen often, so for now we can just // keep the serverID entry. rekeyedEntryInfos = _omitBy( (candidate: RawEntryInfo) => !candidate.id && candidate.localID === localID, )(entryInfos); } else if (entryInfos[localID]) { rekeyedEntryInfos = _mapKeys((oldKey: string) => entryInfos[oldKey].localID === localID ? serverID : oldKey, )(entryInfos); } else { // This happens if the entry is deauthorized before it's saved return [entryStore, []]; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( rekeyedEntryInfos, null, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === saveEntryActionTypes.success) { const serverID = action.payload.entryID; if ( !entryInfos[serverID] || !threadInFilterList(newThreadInfos[entryInfos[serverID].threadID]) ) { // This happens if the entry is deauthorized before it's saved return [entryStore, []]; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === concurrentModificationResetActionType) { const { payload } = action; if ( !entryInfos[payload.id] || !threadInFilterList(newThreadInfos[entryInfos[payload.id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return [entryStore, []]; } const newEntryInfos = { ...entryInfos, [payload.id]: { ...entryInfos[payload.id], text: payload.dbText, }, }; return [ { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === deleteEntryActionTypes.started) { const payload = action.payload; const id = payload.serverID && entryInfos[payload.serverID] ? payload.serverID : payload.localID; invariant(id, 'either serverID or localID should be set'); const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], deleted: true, }, }; return [ { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === deleteEntryActionTypes.success && action.payload) { const { payload } = action; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === fetchRevisionsForEntryActionTypes.success) { const id = action.payload.entryID; if ( !entryInfos[id] || !threadInFilterList(newThreadInfos[entryInfos[id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return [entryStore, []]; } // Make sure the entry is in sync with its latest revision const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], text: action.payload.text, deleted: action.payload.deleted, }, }; return [ { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === restoreEntryActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { const { calendarResult } = action.payload; if (calendarResult) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, calendarResult.rawEntryInfos, newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } } else if (action.type === incrementalStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos( action.payload.deltaEntryInfos, action.payload.updatesResult.newUpdates, ), newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return [ { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if ( action.type === processUpdatesActionType || action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.newUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === fullStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return [entryStore, []]; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return [entryStore, []]; } const { rawEntryInfos, deleteEntryIDs } = checkStateRequest.stateChanges; if (!rawEntryInfos && !deleteEntryIDs) { return [entryStore, []]; } let updatedEntryInfos = { ...entryInfos }; if (deleteEntryIDs) { for (const deleteEntryID of deleteEntryIDs) { delete updatedEntryInfos[deleteEntryID]; } } let updatedDaysToEntries; if (rawEntryInfos) { [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( updatedEntryInfos, null, rawEntryInfos, newThreadInfos, ); } else { updatedDaysToEntries = daysToEntriesFromEntryInfos( values(updatedEntryInfos), ); } const newInconsistencies = findInconsistencies( action, entryInfos, updatedEntryInfos, action.payload.calendarQuery, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, newInconsistencies, ]; } return [entryStore, []]; } function mergeUpdateEntryInfos( entryInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, ): RawEntryInfo[] { const entryIDs = new Set(entryInfos.map(entryInfo => entryInfo.id)); const mergedEntryInfos = [...entryInfos]; for (const updateInfo of newUpdates) { if (updateInfo.type === updateTypes.JOIN_THREAD) { for (const entryInfo of updateInfo.rawEntryInfos) { if (entryIDs.has(entryInfo.id)) { continue; } mergedEntryInfos.push(entryInfo); entryIDs.add(entryInfo.id); } } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { const { entryInfo } = updateInfo; if (entryIDs.has(entryInfo.id)) { continue; } mergedEntryInfos.push(entryInfo); entryIDs.add(entryInfo.id); } } return mergedEntryInfos; } const emptyArray = []; function findInconsistencies( action: BaseAction, beforeStateCheck: { +[id: string]: RawEntryInfo }, afterStateCheck: { +[id: string]: RawEntryInfo }, calendarQuery: CalendarQuery, ): ClientEntryInconsistencyReportCreationRequest[] { // We don't want to bother reporting an inconsistency if it's just because of // extraneous EntryInfos (not within the current calendarQuery) on either side const filteredBeforeResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(beforeStateCheck)), calendarQuery, ); const filteredAfterResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(afterStateCheck)), calendarQuery, ); if (_isEqual(filteredBeforeResult)(filteredAfterResult)) { return emptyArray; } return [ { type: reportTypes.ENTRY_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), calendarQuery, pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function markDeletedEntries( entryInfos: { +[id: string]: RawEntryInfo }, deletedEntryIDs: $ReadOnlyArray, ): { +[id: string]: RawEntryInfo } { let result = entryInfos; for (const deletedEntryID of deletedEntryIDs) { const entryInfo = entryInfos[deletedEntryID]; if (!entryInfo || entryInfo.deleted) { continue; } result = { ...result, [deletedEntryID]: { ...entryInfo, deleted: true, }, }; } return result; } export { daysToEntriesFromEntryInfos, reduceEntryInfos }; diff --git a/lib/reducers/local-id-reducer.js b/lib/reducers/local-id-reducer.js index 2f099adf0..39fbb41f4 100644 --- a/lib/reducers/local-id-reducer.js +++ b/lib/reducers/local-id-reducer.js @@ -1,125 +1,125 @@ // @flow import invariant from 'invariant'; import { createLocalEntryActionType } from '../actions/entry-actions.js'; import { sendTextMessageActionTypes, sendMultimediaMessageActionTypes, createLocalMessageActionType, processMessagesActionType, fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendReactionMessageActionTypes, } from '../actions/message-actions.js'; import { joinThreadActionTypes } from '../actions/thread-actions.js'; import { numberFromLocalID, highestLocalIDSelector, } from '../selectors/local-id-selectors.js'; import type { RawMessageInfo } from '../types/message-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { rehydrateActionType } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; import { - updateTypes, type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; export default function reduceNextLocalID( state: number, action: BaseAction, ): number { let newCandidate = null; if (action.type === rehydrateActionType) { newCandidate = highestLocalIDSelector(action.payload) + 1; if ( action.payload && action.payload.nextLocalID && action.payload.nextLocalID > newCandidate ) { newCandidate = action.payload.nextLocalID; } } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started || action.type === sendReactionMessageActionTypes.started || action.type === createLocalEntryActionType || action.type === createLocalMessageActionType ) { const { localID } = action.payload; invariant(localID, 'should be set'); newCandidate = numberFromLocalID(localID) + 1; } else if (action.type === incrementalStateSyncActionType) { const messages = [ ...action.payload.messagesResult.rawMessageInfos, ...getRawMessageInfosFromUpdates(action.payload.updatesResult.newUpdates), ]; newCandidate = findHighestLocalID(messages) + 1; } else if (action.type === processUpdatesActionType) { const messages = getRawMessageInfosFromUpdates( action.payload.updatesResult.newUpdates, ); newCandidate = findHighestLocalID(messages) + 1; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const messages = action.payload.messagesResult.rawMessageInfos; newCandidate = findHighestLocalID(messages) + 1; } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { const messages = action.payload.rawMessageInfos; newCandidate = findHighestLocalID(messages) + 1; } else if (action.type === joinThreadActionTypes.success) { const messages = [ ...action.payload.rawMessageInfos, ...getRawMessageInfosFromUpdates(action.payload.updatesResult.newUpdates), ]; newCandidate = findHighestLocalID(messages) + 1; } return newCandidate && newCandidate > state ? newCandidate : state; } function findHighestLocalID( rawMessageInfos: $ReadOnlyArray, ): number { let oldestID = -1; for (const rawMessageInfo of rawMessageInfos) { if (!rawMessageInfo.localID) { continue; } const { localID } = rawMessageInfo; if (!localID) { continue; } const thisLocalID = numberFromLocalID(localID); if (thisLocalID > oldestID) { oldestID = thisLocalID; } } return oldestID; } function getRawMessageInfosFromUpdates( updates: $ReadOnlyArray, ): RawMessageInfo[] { const rawMessageInfos = []; for (const updateInfo of updates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (const messageInfo of updateInfo.rawMessageInfos) { rawMessageInfos.push(messageInfo); } } return rawMessageInfos; } diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 446496a7f..a8af11d8a 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,1777 +1,1777 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import _flow from 'lodash/fp/flow.js'; import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _map from 'lodash/fp/map.js'; import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.js'; import _omitBy from 'lodash/fp/omitBy.js'; import _pick from 'lodash/fp/pick.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _uniq from 'lodash/fp/uniq.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions.js'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendTextMessageActionTypes, sendMultimediaMessageActionTypes, sendReactionMessageActionTypes, sendEditMessageActionTypes, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, createLocalMessageActionType, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { sendMessageReportActionTypes } from '../actions/message-report-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, toggleMessagePinActionTypes, } from '../actions/thread-actions.js'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, } from '../actions/user-actions.js'; import { pendingToRealizedThreadIDsSelector } from '../selectors/thread-selectors.js'; import { messageID, combineTruncationStatuses, sortMessageInfoList, sortMessageIDs, mergeThreadMessageInfos, } from '../shared/message-utils.js'; import { threadHasPermission, threadInChatList, threadIsPending, } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import { unshimMessageInfos } from '../shared/unshim-utils.js'; import type { Media, Image } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RawMessageInfo, type LocalMessageInfo, type MessageStore, type ReplaceMessageOperation, type MessageStoreOperation, type MessageTruncationStatus, type MessageTruncationStatuses, messageTruncationStatus, defaultNumberPerThread, type ThreadMessageInfo, } from '../types/message-types.js'; import type { RawImagesMessageInfo } from '../types/messages/images.js'; import type { RawMediaMessageInfo } from '../types/messages/media.js'; import { type BaseAction } from '../types/redux-types.js'; import { processServerRequestsActionType } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type RawThreadInfo } from '../types/thread-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; import { - updateTypes, type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { isDev } from '../utils/dev-utils.js'; import { translateClientDBMessageInfosToRawMessageInfos, translateClientDBThreadMessageInfos, } from '../utils/message-ops-utils.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; const PROCESSED_MSG_STORE_INVARIANTS_DISABLED = !isDev; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); // Input must already be ordered! function mapThreadsToMessageIDsFromOrderedMessageInfos( orderedMessageInfos: $ReadOnlyArray, ): { [threadID: string]: string[] } { const threadsToMessageIDs: { [threadID: string]: string[] } = {}; for (const messageInfo of orderedMessageInfos) { const key = messageID(messageInfo); if (!threadsToMessageIDs[messageInfo.threadID]) { threadsToMessageIDs[messageInfo.threadID] = [key]; } else { threadsToMessageIDs[messageInfo.threadID].push(key); } } return threadsToMessageIDs; } function isThreadWatched( threadID: string, threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadIsPending(threadID) || (threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadID))) ); } function assertMessageStoreThreadsAreEqual( processedMessageStore: MessageStore, expectedMessageStore: MessageStore, location: string, ) { if (PROCESSED_MSG_STORE_INVARIANTS_DISABLED) { return; } assertObjectsAreEqual( processedMessageStore.threads, expectedMessageStore.threads, `MessageStore.threads - ${location}`, ); } const newThread = () => ({ messageIDs: [], startReached: false, lastNavigatedTo: 0, lastPruned: Date.now(), }); type FreshMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function freshMessageStore( messageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { +[threadID: string]: RawThreadInfo }, ): FreshMessageStoreResult { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = sortMessageInfoList(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const messageStoreReplaceOperations = orderedMessageInfos.map( messageInfo => ({ type: 'replace', payload: { id: messageID(messageInfo), messageInfo }, }), ); const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos(orderedMessageInfos); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ ...newThread(), messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threads[threadID] || !isThreadWatched(threadID, threadInfo, watchedIDs) ) { continue; } threads[threadID] = newThread(); } const messageStoreOperations = [ { type: 'remove_all' }, { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads }, }, ...messageStoreReplaceOperations, ]; return { messageStoreOperations, messageStore: { messages, threads, local: {}, currentAsOf }, }; } type ReassignmentResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function reassignMessagesToRealizedThreads( messageStore: MessageStore, threadInfos: { +[threadID: string]: RawThreadInfo }, ): ReassignmentResult { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(threadInfos); const messageStoreOperations: MessageStoreOperation[] = []; const messages = {}; for (const storeMessageID in messageStore.messages) { const message = messageStore.messages[storeMessageID]; const newThreadID = pendingToRealizedThreadIDs.get(message.threadID); messages[storeMessageID] = newThreadID ? { ...message, threadID: newThreadID, time: threadInfos[newThreadID]?.creationTime ?? message.time, } : message; if (!newThreadID) { continue; } const updateMsgOperation: ReplaceMessageOperation = { type: 'replace', payload: { id: storeMessageID, messageInfo: messages[storeMessageID] }, }; messageStoreOperations.push(updateMsgOperation); } const threads = {}; const reassignedThreadIDs = []; const updatedThreads = {}; const threadsToRemove = []; for (const threadID in messageStore.threads) { const threadMessageInfo = messageStore.threads[threadID]; const newThreadID = pendingToRealizedThreadIDs.get(threadID); if (!newThreadID) { threads[threadID] = threadMessageInfo; continue; } const realizedThread = messageStore.threads[newThreadID]; if (!realizedThread) { reassignedThreadIDs.push(newThreadID); threads[newThreadID] = threadMessageInfo; updatedThreads[newThreadID] = threadMessageInfo; threadsToRemove.push(threadID); continue; } threads[newThreadID] = mergeThreadMessageInfos( threadMessageInfo, realizedThread, messages, ); updatedThreads[newThreadID] = threads[newThreadID]; } if (threadsToRemove.length) { messageStoreOperations.push({ type: 'remove_threads', payload: { ids: threadsToRemove, }, }); } messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); return { messageStoreOperations, messageStore: { ...messageStore, threads, messages, }, reassignedThreadIDs, }; } type MergeNewMessagesResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; // oldMessageStore is from the old state // newMessageInfos, truncationStatus come from server function mergeNewMessages( oldMessageStore: MessageStore, newMessageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, threadInfos: { +[threadID: string]: RawThreadInfo }, actionType: string, ): MergeNewMessagesResult { const { messageStoreOperations: updateWithLatestThreadInfosOps, messageStore: messageStoreUpdatedWithLatestThreadInfos, reassignedThreadIDs, } = updateMessageStoreWithLatestThreadInfos(oldMessageStore, threadInfos); const messageStoreAfterUpdateOps = processMessageStoreOperations( oldMessageStore, updateWithLatestThreadInfosOps, ); assertMessageStoreThreadsAreEqual( messageStoreAfterUpdateOps, messageStoreUpdatedWithLatestThreadInfos, `${actionType} | reassignment and filtering`, ); const updatedMessageStore = { ...messageStoreUpdatedWithLatestThreadInfos, messages: messageStoreAfterUpdateOps.messages, }; const localIDsToServerIDs: Map = new Map(); const watchedThreadIDs = [ ...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs, ]; const unshimmedNewMessages = unshimMessageInfos(newMessageInfos); const unshimmedNewMessagesOfWatchedThreads = unshimmedNewMessages.filter( msg => isThreadWatched( msg.threadID, threadInfos[msg.threadID], watchedThreadIDs, ), ); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); invariant( !threadIsPending(messageInfo.threadID), 'new messageInfos should have realized thread id', ); const currentMessageInfo = updatedMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? updatedMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'inputLocalID should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } else if ( currentMessageInfo && messageInfo.time > currentMessageInfo.time ) { // When thick threads will be introduced it will be possible for two // clients to create the same message (e.g. when they create the same // sidebar at the same time). We're going to use deterministic ids for // messages which should be unique within a thread and we have to find // a way for clients to agree which message to keep. We can't rely on // always choosing incoming messages nor messages from the store, // because a message that is in one user's store, will be send to // another user. One way to deal with it is to always choose a message // which is older, according to its timestamp. We can use this strategy // only for messages that can start a thread, because for other types // it might break the "contiguous" property of message ids (we can // consider selecting younger messages in that case, but for now we use // an invariant). invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR || messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.SIDEBAR_SOURCE, `Two different messages of type ${messageInfo.type} with the same ` + 'id found', ); return currentMessageInfo; } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), sortMessageInfoList, )(unshimmedNewMessagesOfWatchedThreads); const newMessageOps: MessageStoreOperation[] = []; const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const threadsThatNeedMessageIDsResorted = []; const local = {}; const updatedThreads = {}; const threads = _flow( _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = updatedMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (!oldThread) { updatedThreads[threadID] = { ...newThread(), messageIDs, startReached: truncate === messageTruncationStatus.EXHAUSTIVE, }; return updatedThreads[threadID]; } let oldMessageIDsUnchanged = true; const oldMessageIDs = oldThread.messageIDs.map(oldID => { const newID = localIDsToServerIDs.get(oldID); if (newID !== null && newID !== undefined) { oldMessageIDsUnchanged = false; return newID; } return oldID; }); if (truncate === messageTruncationStatus.TRUNCATED) { // If the result set in the payload isn't contiguous with what we have // now, that means we need to dump what we have in the state and replace // it with the result set. We do this to achieve our two goals for the // messageStore: currentness and contiguousness. newMessageOps.push({ type: 'remove_messages_for_threads', payload: { threadIDs: [threadID] }, }); updatedThreads[threadID] = { messageIDs, startReached: false, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; return updatedThreads[threadID]; } const oldNotInNew = _difference(oldMessageIDs)(messageIDs); for (const id of oldNotInNew) { const oldMessageInfo = updatedMessageStore.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = updatedMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } updatedThreads[threadID] = { messageIDs: oldMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; return updatedThreads[threadID]; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; threadsThatNeedMessageIDsResorted.push(threadID); return { messageIDs: mergedMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; }), _pickBy(thread => !!thread), )(threadsToMessageIDs); for (const threadID in updatedMessageStore.threads) { if (threads[threadID]) { continue; } let thread = updatedMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; updatedThreads[threadID] = thread; for (const id of thread.messageIDs) { const messageInfo = updatedMessageStore.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = updatedMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } } const messages = _flow( sortMessageInfoList, _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); const newMessages = _keyBy(messageID)(orderedNewMessageInfos); for (const id in newMessages) { newMessageOps.push({ type: 'replace', payload: { id, messageInfo: newMessages[id] }, }); } if (localIDsToServerIDs.size > 0) { newMessageOps.push({ type: 'remove', payload: { ids: [...localIDsToServerIDs.keys()] }, }); } for (const threadID of threadsThatNeedMessageIDsResorted) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); updatedThreads[threadID] = threads[threadID]; } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, updatedMessageStore.currentAsOf, ); newMessageOps.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); const processedMessageStore = processMessageStoreOperations( updatedMessageStore, newMessageOps, ); const messageStore = { messages: processedMessageStore.messages, threads, local, currentAsOf, }; assertMessageStoreThreadsAreEqual( processedMessageStore, messageStore, `${actionType} | processed`, ); return { messageStoreOperations: [ ...updateWithLatestThreadInfosOps, ...newMessageOps, ], messageStore, }; } type UpdateMessageStoreWithLatestThreadInfosResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function updateMessageStoreWithLatestThreadInfos( messageStore: MessageStore, threadInfos: { +[id: string]: RawThreadInfo }, ): UpdateMessageStoreWithLatestThreadInfosResult { const messageStoreOperations: MessageStoreOperation[] = []; const { messageStore: reassignedMessageStore, messageStoreOperations: reassignMessagesOps, reassignedThreadIDs, } = reassignMessagesToRealizedThreads(messageStore, threadInfos); messageStoreOperations.push(...reassignMessagesOps); const watchedIDs = [...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs]; const watchedThreadInfos = _pickBy((threadInfo: RawThreadInfo) => isThreadWatched(threadInfo.id, threadInfo, watchedIDs), )(threadInfos); const filteredThreads = _pick(Object.keys(watchedThreadInfos))( reassignedMessageStore.threads, ); const messageIDsToRemove = []; const threadsToRemoveMessagesFrom = []; for (const threadID in reassignedMessageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } threadsToRemoveMessagesFrom.push(threadID); for (const id of reassignedMessageStore.threads[threadID].messageIDs) { messageIDsToRemove.push(id); } } const updatedThreads = {}; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( isThreadWatched(threadID, threadInfo, watchedIDs) && !filteredThreads[threadID] ) { filteredThreads[threadID] = newThread(); updatedThreads[threadID] = filteredThreads[threadID]; } } messageStoreOperations.push({ type: 'remove_threads', payload: { ids: threadsToRemoveMessagesFrom }, }); messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); messageStoreOperations.push({ type: 'remove_messages_for_threads', payload: { threadIDs: threadsToRemoveMessagesFrom }, }); return { messageStoreOperations, messageStore: { messages: _omit(messageIDsToRemove)(reassignedMessageStore.messages), threads: filteredThreads, local: _omit(messageIDsToRemove)(reassignedMessageStore.local), currentAsOf: reassignedMessageStore.currentAsOf, }, reassignedThreadIDs, }; } function ensureRealizedThreadIDIsUsedWhenPossible( payload: T, threadInfos: { +[id: string]: RawThreadInfo }, ): T { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(threadInfos); const realizedThreadID = pendingToRealizedThreadIDs.get(payload.threadID); return realizedThreadID ? { ...payload, threadID: realizedThreadID } : payload; } function processMessageStoreOperations( messageStore: MessageStore, messageStoreOperations: $ReadOnlyArray, ): MessageStore { if (messageStoreOperations.length === 0) { return messageStore; } let processedMessages = { ...messageStore.messages }; let processedThreads = { ...messageStore.threads }; for (const operation of messageStoreOperations) { if (operation.type === 'replace') { processedMessages[operation.payload.id] = operation.payload.messageInfo; } else if (operation.type === 'remove') { for (const id of operation.payload.ids) { delete processedMessages[id]; } } else if (operation.type === 'remove_messages_for_threads') { for (const msgID in processedMessages) { if ( operation.payload.threadIDs.includes( processedMessages[msgID].threadID, ) ) { delete processedMessages[msgID]; } } } else if (operation.type === 'rekey') { processedMessages[operation.payload.to] = processedMessages[operation.payload.from]; delete processedMessages[operation.payload.from]; } else if (operation.type === 'remove_all') { processedMessages = {}; } else if (operation.type === 'replace_threads') { for (const threadID in operation.payload.threads) { processedThreads[threadID] = operation.payload.threads[threadID]; } } else if (operation.type === 'remove_threads') { for (const id of operation.payload.ids) { delete processedThreads[id]; } } else if (operation.type === 'remove_all_threads') { processedThreads = {}; } } return { ...messageStore, threads: processedThreads, messages: processedMessages, }; } type ReduceMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: { +[id: string]: RawThreadInfo }, ): ReduceMessageStoreResult { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { const messagesResult = action.payload.messagesResult; const { messageStoreOperations, messageStore: freshStore } = freshMessageStore( messagesResult.messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); assertMessageStoreThreadsAreEqual( processedMessageStore, freshStore, `${action.type} | fresh store`, ); return { messageStoreOperations, messageStore: { ...freshStore, messages: processedMessageStore.messages }, }; } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( action.payload.messagesResult.rawMessageInfos, action.payload.updatesResult.newUpdates, action.payload.messagesResult.truncationStatuses, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === processUpdatesActionType) { if (action.payload.updatesResult.newUpdates.length === 0) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( [], action.payload.updatesResult.newUpdates, ); const { messageStoreOperations, messageStore: newMessageStore } = mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchSingleMostRecentMessagesFromThreadsActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, action.type, ); } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { const { messageStoreOperations, messageStore: filteredMessageStore } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); assertMessageStoreThreadsAreEqual( processedMessageStore, filteredMessageStore, action.type, ); return { messageStoreOperations, messageStore: { ...filteredMessageStore, messages: processedMessageStore.messages, }, }; } else if (action.type === newThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.newMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === sendMessageReportActionTypes.success) { return mergeNewMessages( messageStore, [action.payload.messageInfo], { [action.payload.messageInfo.threadID]: messageTruncationStatus.UNCHANGED, }, newThreadInfos, action.type, ); } else if (action.type === registerActionTypes.success) { const truncationStatuses = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success || action.type === restoreEntryActionTypes.success || action.type === toggleMessagePinActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } } else if (action.type === joinThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.rawMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === sendEditMessageActionTypes.success) { const { newMessageInfos } = action.payload; const truncationStatuses = {}; for (const messageInfo of newMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } return mergeNewMessages( messageStore, newMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started || action.type === sendReactionMessageActionTypes.started ) { const payload = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = payload; invariant(localID, `localID should be set on ${action.type}`); const messageIDs = messageStore.threads[threadID]?.messageIDs ?? []; if (!messageStore.messages[localID]) { for (const existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return { messageStoreOperations: [], messageStore }; } } } const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo: payload }, }, ]; const now = Date.now(); let updatedThreads; let local = { ...messageStore.local }; if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [localID]: payload }; local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const thread = messageStore.threads[threadID]; updatedThreads = { [threadID]: { messageIDs: sortMessageIDs(messages)(messageIDs), startReached: thread?.startReached ?? true, lastNavigatedTo: thread?.lastNavigatedTo ?? now, lastPruned: thread?.lastPruned ?? now, }, }; } else { updatedThreads = { [threadID]: messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }, }; } const threads = { ...messageStore.threads, ...updatedThreads, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: { ...updatedThreads }, }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads, local, currentAsOf: messageStore.currentAsOf, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messageStoreOperations: [], messageStore: { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [localID]: { sendFailed: true }, }, currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === sendReactionMessageActionTypes.failed) { const { localID, threadID } = action.payload; const messageStoreOperations = []; messageStoreOperations.push({ type: 'remove', payload: { ids: [localID] }, }); const newMessageIDs = messageStore.threads[threadID].messageIDs.filter( id => id !== localID, ); const updatedThreads = { [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { ...processedMessageStore, threads: { ...messageStore.threads, ...updatedThreads, }, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if ( action.type === sendTextMessageActionTypes.success || action.type === sendMultimediaMessageActionTypes.success || action.type === sendReactionMessageActionTypes.success ) { const { payload } = action; invariant( !threadIsPending(payload.threadID), 'Successful message action should have realized thread id', ); const replaceMessageKey = (messageKey: string) => messageKey === payload.localID ? payload.serverID : messageKey; let newMessages; const messageStoreOperations = []; if (messageStore.messages[payload.serverID]) { // If somehow the serverID got in there already, we'll just update the // serverID message and scrub the localID one newMessages = _omitBy( (messageInfo: RawMessageInfo) => messageInfo.type === messageTypes.TEXT && !messageInfo.id && messageInfo.localID === payload.localID, )(messageStore.messages); messageStoreOperations.push({ type: 'remove', payload: { ids: [payload.localID] }, }); } else if (messageStore.messages[payload.localID]) { // The normal case, the localID message gets replaced by the serverID one newMessages = _mapKeys(replaceMessageKey)(messageStore.messages); messageStoreOperations.push({ type: 'rekey', payload: { from: payload.localID, to: payload.serverID }, }); } else { // Well this is weird, we probably got deauthorized between when the // action was dispatched and when we ran this reducer... return { messageStoreOperations, messageStore }; } const newMessage = { ...newMessages[payload.serverID], id: payload.serverID, localID: payload.localID, time: payload.time, }; newMessages[payload.serverID] = newMessage; messageStoreOperations.push({ type: 'replace', payload: { id: payload.serverID, messageInfo: newMessage }, }); const threadID = payload.threadID; const newMessageIDs = _flow( _uniq, sortMessageIDs(newMessages), )(messageStore.threads[threadID].messageIDs.map(replaceMessageKey)); const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); const updatedThreads = { [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { ...messageStore, messages: processedMessageStore.messages, threads: { ...messageStore.threads, ...updatedThreads, }, local, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const { messageStoreOperations, messageStore: newMessageStore } = mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, // We avoid bumping currentAsOf because notifs may include a contracted // RawMessageInfo, so we want to make sure we still fetch it currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const messageIDsToPrune = []; const updatedThreads = {}; for (const threadID of action.payload.threadIDs) { let thread = messageStore.threads[threadID]; if (!thread) { continue; } thread = { ...thread, lastPruned: now }; const newMessageIDs = [...thread.messageIDs]; const removed = newMessageIDs.splice(defaultNumberPerThread); if (removed.length > 0) { thread = { ...thread, messageIDs: newMessageIDs, startReached: false, }; } for (const id of removed) { messageIDsToPrune.push(id); } updatedThreads[threadID] = thread; } const messageStoreOperations = [ { type: 'remove', payload: { ids: messageIDsToPrune }, }, { type: 'replace_threads', payload: { threads: updatedThreads, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads: { ...messageStore.threads, ...updatedThreads, }, local: _omit(messageIDsToPrune)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if (action.type === updateMultimediaMessageMediaActionType) { const { messageID: id, currentMediaID, mediaUpdate } = action.payload; const message = messageStore.messages[id]; invariant(message, `message with ID ${id} could not be found`); invariant( message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, `message with ID ${id} is not multimedia`, ); let updatedMessage; let replaced = false; if (message.type === messageTypes.IMAGES) { const media: Image[] = []; for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else { let updatedMedia: Image = { id: mediaUpdate.id ?? singleMedia.id, type: 'photo', uri: mediaUpdate.uri ?? singleMedia.uri, dimensions: mediaUpdate.dimensions ?? singleMedia.dimensions, }; if ( 'localMediaSelection' in singleMedia && !('localMediaSelection' in mediaUpdate) ) { updatedMedia = { ...updatedMedia, localMediaSelection: singleMedia.localMediaSelection, }; } else if (mediaUpdate.localMediaSelection) { updatedMedia = { ...updatedMedia, localMediaSelection: mediaUpdate.localMediaSelection, }; } media.push(updatedMedia); replaced = true; } } updatedMessage = { ...message, media }; } else { const media: Media[] = []; for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else if ( singleMedia.type === 'photo' && mediaUpdate.type === 'photo' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'video' && mediaUpdate.type === 'video' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'encrypted_photo' && mediaUpdate.type === 'encrypted_photo' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'encrypted_video' && mediaUpdate.type === 'encrypted_video' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'photo' && mediaUpdate.type === 'encrypted_photo' ) { // extract fields that are absent in encrypted_photo type const { uri, localMediaSelection, ...original } = singleMedia; const { holder, encryptionKey, ...update } = mediaUpdate; invariant( holder && encryptionKey, 'holder and encryptionKey are required for encrypted_photo message', ); media.push({ ...original, ...update, type: 'encrypted_photo', holder, encryptionKey, }); replaced = true; } else if ( singleMedia.type === 'video' && mediaUpdate.type === 'encrypted_video' ) { const { uri, thumbnailURI, localMediaSelection, ...original } = singleMedia; const { holder, encryptionKey, thumbnailHolder, thumbnailEncryptionKey, ...update } = mediaUpdate; invariant( holder && encryptionKey, 'holder and encryptionKey are required for encrypted_video message', ); invariant( thumbnailHolder && thumbnailEncryptionKey, 'thumbnailHolder and thumbnailEncryptionKey are required for ' + 'encrypted_video message', ); media.push({ ...original, ...update, type: 'encrypted_video', holder, encryptionKey, thumbnailHolder, thumbnailEncryptionKey, }); replaced = true; } } updatedMessage = { ...message, media }; } invariant( replaced, `message ${id} did not contain media with ID ${currentMediaID}`, ); const messageStoreOperations = [ { type: 'replace', payload: { id, messageInfo: updatedMessage, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = messageInfo; const messageIDs = messageStore.threads[messageInfo.threadID]?.messageIDs ?? []; const now = Date.now(); const threadState: ThreadMessageInfo = messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }; const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo }, }, { type: 'replace_threads', payload: { threads: { [threadID]: threadState }, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { ...messageStore, threads: { ...messageStore.threads, [threadID]: threadState, }, messages: processedMessageStore.messages, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if (action.type === processServerRequestsActionType) { const { messageStoreOperations, messageStore: messageStoreAfterReassignment, } = reassignMessagesToRealizedThreads(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); assertMessageStoreThreadsAreEqual( processedMessageStore, messageStoreAfterReassignment, action.type, ); return { messageStoreOperations, messageStore: { ...messageStoreAfterReassignment, messages: processedMessageStore.messages, }, }; } else if (action.type === setClientDBStoreActionType) { const payloadMessages = action.payload.messages; if (!payloadMessages) { return { messageStoreOperations: [], messageStore }; } const actionPayloadMessageStoreThreads = translateClientDBThreadMessageInfos( action.payload.messageStoreThreads ?? [], ); const newThreads = {}; for (const threadID in actionPayloadMessageStoreThreads) { newThreads[threadID] = { ...actionPayloadMessageStoreThreads[threadID], messageIDs: messageStore.threads[threadID]?.messageIDs ?? [], }; } assertMessageStoreThreadsAreEqual( { ...messageStore, threads: newThreads, }, messageStore, `${action.type} | comparing SQLite with redux-persist`, ); const { messageStoreOperations, messageStore: updatedMessageStore } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); let threads = { ...updatedMessageStore.threads }; let local = { ...updatedMessageStore.local }; // Store message IDs already contained within threads so that we // do not insert duplicates const existingMessageIDs = new Set(); for (const threadID in threads) { threads[threadID].messageIDs.forEach(msgID => { existingMessageIDs.add(msgID); }); } const threadsNeedMsgIDsResorting = new Set(); const actionPayloadMessages = translateClientDBMessageInfosToRawMessageInfos(payloadMessages); // When starting the app on native, we filter out any local-only multimedia // messages because the relevant context is no longer available const messageIDsToBeRemoved = []; const threadsToAdd = {}; for (const id in actionPayloadMessages) { const message = actionPayloadMessages[id]; const { threadID } = message; let existingThread = threads[threadID]; if (!existingThread) { existingThread = newThread(); threadsToAdd[threadID] = existingThread; } if ( (message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA) && !message.id ) { messageIDsToBeRemoved.push(id); threads = { ...threads, [threadID]: { ...existingThread, messageIDs: existingThread.messageIDs.filter( curMessageID => curMessageID !== id, ), }, }; local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== id, )(local); } else if (!existingMessageIDs.has(id)) { threads = { ...threads, [threadID]: { ...existingThread, messageIDs: [...existingThread.messageIDs, id], }, }; threadsNeedMsgIDsResorting.add(threadID); } else if (!threads[threadID]) { threads = { ...threads, [threadID]: existingThread }; } } for (const threadID of threadsNeedMsgIDsResorting) { threads[threadID].messageIDs = sortMessageIDs(actionPayloadMessages)( threads[threadID].messageIDs, ); } const newMessageStore = { ...updatedMessageStore, messages: actionPayloadMessages, threads: threads, local: local, }; if (messageIDsToBeRemoved.length > 0) { messageStoreOperations.push({ type: 'remove', payload: { ids: messageIDsToBeRemoved }, }); } const processedMessageStore = processMessageStoreOperations( newMessageStore, messageStoreOperations, ); messageStoreOperations.push({ type: 'replace_threads', payload: { threads: threadsToAdd }, }); return { messageStoreOperations, messageStore: processedMessageStore, }; } return { messageStoreOperations: [], messageStore }; } type MergedUpdatesWithMessages = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; function mergeUpdatesWithMessageInfos( messageInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, truncationStatuses?: MessageTruncationStatuses, ): MergedUpdatesWithMessages { const messageIDs = new Set(messageInfos.map(messageInfo => messageInfo.id)); const mergedMessageInfos = [...messageInfos]; const mergedTruncationStatuses = { ...truncationStatuses }; for (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (const messageInfo of updateInfo.rawMessageInfos) { if (messageIDs.has(messageInfo.id)) { continue; } mergedMessageInfos.push(messageInfo); messageIDs.add(messageInfo.id); } mergedTruncationStatuses[updateInfo.threadInfo.id] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index 74cf15d4a..10aef0cad 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,510 +1,510 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { setThreadUnreadStatusActionTypes, updateActivityActionTypes, } from '../actions/activity-actions.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { saveMessagesActionType } from '../actions/message-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, leaveThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ClientThreadInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { RawThreadInfo, ThreadStore, ThreadStoreOperation, } from '../types/thread-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; import { - updateTypes, type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { actionLogger } from '../utils/action-logger.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { sanitizeActionSecrets } from '../utils/sanitization.js'; function generateOpsForThreadUpdates( threadInfos: { +[id: string]: RawThreadInfo }, payload: { +updatesResult: { +newUpdates: $ReadOnlyArray, ... }, ... }, ): $ReadOnlyArray { const threadOperations: ThreadStoreOperation[] = []; for (const update of payload.updatesResult.newUpdates) { if ( (update.type === updateTypes.UPDATE_THREAD || update.type === updateTypes.JOIN_THREAD) && !_isEqual(threadInfos[update.threadInfo.id])(update.threadInfo) ) { threadOperations.push({ type: 'replace', payload: { id: update.threadInfo.id, threadInfo: update.threadInfo, }, }); } else if ( update.type === updateTypes.UPDATE_THREAD_READ_STATUS && threadInfos[update.threadID] && threadInfos[update.threadID].currentUser.unread !== update.unread ) { const updatedThread = { ...threadInfos[update.threadID], currentUser: { ...threadInfos[update.threadID].currentUser, unread: update.unread, }, }; threadOperations.push({ type: 'replace', payload: { id: update.threadID, threadInfo: updatedThread, }, }); } else if ( update.type === updateTypes.DELETE_THREAD && threadInfos[update.threadID] ) { threadOperations.push({ type: 'remove', payload: { ids: [update.threadID], }, }); } else if (update.type === updateTypes.DELETE_ACCOUNT) { for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const newMembers = threadInfo.members.filter( member => member.id !== update.deletedUserID, ); if (newMembers.length < threadInfo.members.length) { const updatedThread = { ...threadInfo, members: newMembers, }; threadOperations.push({ type: 'replace', payload: { id: threadID, threadInfo: updatedThread, }, }); } } } } return threadOperations; } function findInconsistencies( action: BaseAction, beforeStateCheck: { +[id: string]: RawThreadInfo }, afterStateCheck: { +[id: string]: RawThreadInfo }, ): ClientThreadInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return []; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): { threadStore: ThreadStore, newThreadInconsistencies: $ReadOnlyArray, threadStoreOperations: $ReadOnlyArray, } { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === fullStateSyncActionType ) { const newThreadInfos = action.payload.threadInfos; const threadStoreOperations = [ { type: 'remove_all', }, ...Object.keys(newThreadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: newThreadInfos[id] }, })), ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.threadInfos).length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = [ { type: 'remove_all', }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success ) { const { newUpdates } = action.payload.updatesResult; if (newUpdates.length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = generateOpsForThreadUpdates( state.threadInfos, action.payload, ); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === updateSubscriptionActionTypes.success) { const { threadID, subscription } = action.payload; const newThreadInfo = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, subscription, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: newThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === saveMessagesActionType) { const threadIDToMostRecentTime = new Map(); for (const messageInfo of action.payload.rawMessageInfos) { const current = threadIDToMostRecentTime.get(messageInfo.threadID); if (!current || current < messageInfo.time) { threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time); } } const changedThreadInfos = {}; for (const [threadID, mostRecentTime] of threadIDToMostRecentTime) { const threadInfo = state.threadInfos[threadID]; if ( !threadInfo || threadInfo.currentUser.unread || action.payload.updatesCurrentAsOf > mostRecentTime ) { continue; } changedThreadInfos[threadID] = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread: true, }, }; } if (Object.keys(changedThreadInfos).length !== 0) { const threadStoreOperations = Object.keys(changedThreadInfos).map(id => ({ type: 'replace', payload: { id, threadInfo: changedThreadInfos[id], }, })); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const { rawThreadInfos, deleteThreadIDs } = checkStateRequest.stateChanges; if (!rawThreadInfos && !deleteThreadIDs) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations: ThreadStoreOperation[] = []; if (rawThreadInfos) { for (const rawThreadInfo of rawThreadInfos) { threadStoreOperations.push({ type: 'replace', payload: { id: rawThreadInfo.id, threadInfo: rawThreadInfo, }, }); } } if (deleteThreadIDs) { threadStoreOperations.push({ type: 'remove', payload: { ids: deleteThreadIDs, }, }); } const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); const newThreadInconsistencies = findInconsistencies( action, state.threadInfos, updatedThreadStore.threadInfos, ); return { threadStore: updatedThreadStore, newThreadInconsistencies, threadStoreOperations, }; } else if (action.type === updateActivityActionTypes.success) { const updatedThreadInfos = {}; for (const setToUnread of action.payload.result.unfocusedToUnread) { const threadInfo = state.threadInfos[setToUnread]; if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } if (Object.keys(updatedThreadInfos).length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = Object.keys(updatedThreadInfos).map(id => ({ type: 'replace', payload: { id, threadInfo: updatedThreadInfos[id], }, })); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setThreadUnreadStatusActionTypes.started) { const { threadID, unread } = action.payload; const updatedThreadInfo = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setThreadUnreadStatusActionTypes.success) { const { threadID, resetToUnread } = action.payload; const currentUser = state.threadInfos[threadID].currentUser; if (!resetToUnread || currentUser.unread) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const updatedUser = { ...currentUser, unread: true, }; const updatedThread = { ...state.threadInfos[threadID], currentUser: updatedUser, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThread, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setClientDBStoreActionType) { return { threadStore: action.payload.threadStore ?? state, newThreadInconsistencies: [], threadStoreOperations: [], }; } return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } function processThreadStoreOperations( threadStore: ThreadStore, threadStoreOperations: $ReadOnlyArray, ): ThreadStore { if (threadStoreOperations.length === 0) { return threadStore; } let processedThreads = { ...threadStore.threadInfos }; for (const operation of threadStoreOperations) { if (operation.type === 'replace') { processedThreads[operation.payload.id] = operation.payload.threadInfo; } else if (operation.type === 'remove') { for (const id of operation.payload.ids) { delete processedThreads[id]; } } else if (operation.type === 'remove_all') { processedThreads = {}; } } return { ...threadStore, threadInfos: processedThreads }; } export { reduceThreadInfos, processThreadStoreOperations }; diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 93e72420c..ea5dd115e 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,264 +1,262 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { joinThreadActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, setUserSettingsActionTypes, updateUserAvatarActionTypes, } from '../actions/user-actions.js'; import type { BaseAction } from '../types/redux-types.js'; import { type UserInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; -import { - updateTypes, - processUpdatesActionType, -} from '../types/update-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; +import { processUpdatesActionType } from '../types/update-types.js'; import type { CurrentUserInfo, UserStore, UserInfos, } from '../types/user-types.js'; import { actionLogger } from '../utils/action-logger.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { sanitizeActionSecrets } from '../utils/sanitization.js'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo)(state) ) { return update.currentUserInfo; } } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if ( checkStateRequest && checkStateRequest.stateChanges && checkStateRequest.stateChanges.currentUserInfo && !_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state) ) { return checkStateRequest.stateChanges.currentUserInfo; } } else if ( action.type === updateUserAvatarActionTypes.success && state && !state.anonymous ) { const { viewerUpdates } = action.payload.updates; for (const update of viewerUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo.avatar)(state.avatar) ) { return { ...state, avatar: update.currentUserInfo.avatar, }; } } return state; } else if (action.type === setUserSettingsActionTypes.success) { if (state?.settings) { return { ...state, settings: { ...state.settings, ...action.payload, }, }; } } return state; } function findInconsistencies( action: BaseAction, beforeStateCheck: UserInfos, afterStateCheck: UserInfos, ): UserInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return []; } return [ { type: reportTypes.USER_INCONSISTENCY, platformDetails: getConfig().platformDetails, action: sanitizeActionSecrets(action), beforeStateCheck, afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function reduceUserInfos(state: UserStore, action: BaseAction): UserStore { if ( action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; if (!_isEqual(state.userInfos)(updated)) { return { ...state, userInfos: updated, }; } } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.userInfos).length === 0) { return state; } return { userInfos: {}, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); if (!_isEqual(state.userInfos)(newUserInfos)) { return { userInfos: newUserInfos, inconsistencyReports: state.inconsistencyReports, }; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; for (const update of action.payload.updatesResult.newUpdates) { if (update.type === updateTypes.DELETE_ACCOUNT) { delete updated[update.deletedUserID]; } } if (!_isEqual(state.userInfos)(updated)) { return { userInfos: updated, inconsistencyReports: state.inconsistencyReports, }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return state; } const newUserInfos = { ...state.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } } const newInconsistencies = findInconsistencies( action, state.userInfos, newUserInfos, ); return { userInfos: newUserInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } else if (action.type === updateUserAvatarActionTypes.success) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.updates.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; return !_isEqual(state.userInfos)(updated) ? { ...state, userInfos: updated, } : state; } return state; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 20c690e0c..441f36a59 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1648 +1,1649 @@ // @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 { extractMentionsFromText } from './mention-utils.js'; import { getMessageTitle } 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, } 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, type ThreadPermission, type ThreadPermissionsInfo, } 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, } from '../types/thread-types.js'; -import { type ClientUpdateInfo, updateTypes } from '../types/update-types.js'; +import { updateTypes } from '../types/update-types-enum.js'; +import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, } 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'; const chatNameMaxLength = 191; const chatNameMinLength = 0; const secondCharRange = `{${chatNameMinLength},${chatNameMaxLength}}`; const validChatNameRegexString = `^.${secondCharRange}$`; const validChatNameRegex: RegExp = new RegExp(validChatNameRegexString); 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 | RawThreadInfo): boolean { return ( threadMembersWithoutAddedAshoat(threadInfo).filter( member => member.role || member.permissions[threadPermissions.VOICED]?.value, ).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}`; } const pendingThreadIDRegex = 'pending/(type[0-9]+/[0-9]+(\\+[0-9]+)*|sidebar/[0-9]+)'; 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); } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } // 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 = extractMentionsFromText(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, 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, 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 = { +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, +hideThreadStructure?: ?boolean, +shimThreadTypes?: ?{ +[inType: ThreadType]: ThreadType, }, +filterDetailedThreadEditPermissions?: boolean, +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const hideThreadStructure = options?.hideThreadStructure; const shimThreadTypes = options?.shimThreadTypes; const filterDetailedThreadEditPermissions = options?.filterDetailedThreadEditPermissions; const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterThreadPermissions = _omitBy( (v, k) => (filterDetailedThreadEditPermissions && [ threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, ].includes(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)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } 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; } let { type } = serverThreadInfo; if ( shimThreadTypes && shimThreadTypes[type] !== null && shimThreadTypes[type] !== undefined ) { type = shimThreadTypes[type]; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, }; if (!hideThreadStructure) { rawThreadInfo = { ...rawThreadInfo, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; } const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (includeVisibilityRules) { return { ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }; } 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 usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (const member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } 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) { 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]; } function draftKeyFromThreadID(threadID: string): string { return `${threadID}/message_composer`; } 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( chatListData: $ReadOnlyArray, 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, chatListData, 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<'remove_user' | 'remove_admin' | 'make_admin'> { 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 ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push( memberIsAdmin(memberInfo, threadInfo) ? 'remove_admin' : 'make_admin', ); } 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; } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, pendingThreadIDRegex, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, validChatNameRegex, chatNameMaxLength, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, }; diff --git a/lib/shared/update-utils.js b/lib/shared/update-utils.js index 844307e88..a8509dd45 100644 --- a/lib/shared/update-utils.js +++ b/lib/shared/update-utils.js @@ -1,147 +1,147 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; +import { updateTypes } from '../types/update-types-enum.js'; import { type ServerUpdateInfo, type UpdateData, type RawUpdateInfo, - updateTypes, } from '../types/update-types.js'; function mostRecentUpdateTimestamp( updateInfos: ServerUpdateInfo[], previousTimestamp: number, ): number { if (updateInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(updateInfos).time; } function keyForUpdateData(updateData: UpdateData): ?string { if ( updateData.type === updateTypes.UPDATE_THREAD || updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS || updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { return updateData.threadID; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { return updateData.entryID; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { return updateData.userID; } else if (updateData.type === updateTypes.DELETE_ACCOUNT) { return updateData.deletedUserID; } else if (updateData.type === updateTypes.UPDATE_USER) { return updateData.updatedUserID; } return null; } function keyForUpdateInfo(updateInfo: ServerUpdateInfo): ?string { if ( updateInfo.type === updateTypes.UPDATE_THREAD || updateInfo.type === updateTypes.JOIN_THREAD ) { return updateInfo.threadInfo.id; } else if ( updateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS || updateInfo.type === updateTypes.DELETE_THREAD ) { return updateInfo.threadID; } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { const { id } = updateInfo.entryInfo; invariant(id, 'should be set'); return id; } else if (updateInfo.type === updateTypes.UPDATE_CURRENT_USER) { return updateInfo.currentUserInfo.id; } else if (updateInfo.type === updateTypes.DELETE_ACCOUNT) { return updateInfo.deletedUserID; } else if (updateInfo.type === updateTypes.UPDATE_USER) { return updateInfo.updatedUserID; } return null; } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function rawUpdateInfoFromUpdateData( updateData: UpdateData, id: string, ): RawUpdateInfo { if (updateData.type === updateTypes.DELETE_ACCOUNT) { return { type: updateTypes.DELETE_ACCOUNT, id, time: updateData.time, deletedUserID: updateData.deletedUserID, }; } else if (updateData.type === updateTypes.UPDATE_THREAD) { return { type: updateTypes.UPDATE_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id, time: updateData.time, threadID: updateData.threadID, unread: updateData.unread, }; } else if (updateData.type === updateTypes.DELETE_THREAD) { return { type: updateTypes.DELETE_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.JOIN_THREAD) { return { type: updateTypes.JOIN_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.BAD_DEVICE_TOKEN) { return { type: updateTypes.BAD_DEVICE_TOKEN, id, time: updateData.time, deviceToken: updateData.deviceToken, }; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { return { type: updateTypes.UPDATE_ENTRY, id, time: updateData.time, entryID: updateData.entryID, }; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { return { type: updateTypes.UPDATE_CURRENT_USER, id, time: updateData.time, }; } else if (updateData.type === updateTypes.UPDATE_USER) { return { type: updateTypes.UPDATE_USER, id, time: updateData.time, updatedUserID: updateData.updatedUserID, }; } else { invariant(false, `unrecognized updateType ${updateData.type}`); } } export { mostRecentUpdateTimestamp, keyForUpdateData, keyForUpdateInfo, rawUpdateInfoFromUpdateData, }; diff --git a/lib/types/update-types-enum.js b/lib/types/update-types-enum.js new file mode 100644 index 000000000..e4b1fc49f --- /dev/null +++ b/lib/types/update-types-enum.js @@ -0,0 +1,31 @@ +// @flow + +import invariant from 'invariant'; + +export const updateTypes = Object.freeze({ + DELETE_ACCOUNT: 0, + UPDATE_THREAD: 1, + UPDATE_THREAD_READ_STATUS: 2, + DELETE_THREAD: 3, + JOIN_THREAD: 4, + BAD_DEVICE_TOKEN: 5, + UPDATE_ENTRY: 6, + UPDATE_CURRENT_USER: 7, + UPDATE_USER: 8, +}); +export type UpdateType = $Values; +export function assertUpdateType(ourUpdateType: number): UpdateType { + invariant( + ourUpdateType === 0 || + ourUpdateType === 1 || + ourUpdateType === 2 || + ourUpdateType === 3 || + ourUpdateType === 4 || + ourUpdateType === 5 || + ourUpdateType === 6 || + ourUpdateType === 7 || + ourUpdateType === 8, + 'number is not UpdateType enum', + ); + return ourUpdateType; +} diff --git a/lib/types/update-types.js b/lib/types/update-types.js index 78353ada8..d36881125 100644 --- a/lib/types/update-types.js +++ b/lib/types/update-types.js @@ -1,432 +1,404 @@ // @flow -import invariant from 'invariant'; import t, { type TUnion, type TInterface } from 'tcomb'; import { type RawEntryInfo, rawEntryInfoValidator } from './entry-types.js'; import { type RawMessageInfo, rawMessageInfoValidator, type MessageTruncationStatus, messageTruncationStatusValidator, } from './message-types.js'; import { type RawThreadInfo, rawThreadInfoValidator } from './thread-types.js'; +import { updateTypes } from './update-types-enum.js'; import { type UserInfo, userInfoValidator, type UserInfos, userInfosValidator, type LoggedInUserInfo, loggedInUserInfoValidator, type OldLoggedInUserInfo, oldLoggedInUserInfoValidator, } from './user-types.js'; import { tNumber, tShape, tID } from '../utils/validation-utils.js'; -export const updateTypes = Object.freeze({ - DELETE_ACCOUNT: 0, - UPDATE_THREAD: 1, - UPDATE_THREAD_READ_STATUS: 2, - DELETE_THREAD: 3, - JOIN_THREAD: 4, - BAD_DEVICE_TOKEN: 5, - UPDATE_ENTRY: 6, - UPDATE_CURRENT_USER: 7, - UPDATE_USER: 8, -}); -export type UpdateType = $Values; -export function assertUpdateType(ourUpdateType: number): UpdateType { - invariant( - ourUpdateType === 0 || - ourUpdateType === 1 || - ourUpdateType === 2 || - ourUpdateType === 3 || - ourUpdateType === 4 || - ourUpdateType === 5 || - ourUpdateType === 6 || - ourUpdateType === 7 || - ourUpdateType === 8, - 'number is not UpdateType enum', - ); - return ourUpdateType; -} - type AccountDeletionData = { +deletedUserID: string, }; type ThreadData = { +threadID: string, }; type ThreadReadStatusData = { +threadID: string, +unread: boolean, }; type ThreadDeletionData = { +threadID: string, }; type ThreadJoinData = { +threadID: string, }; type BadDeviceTokenData = { +deviceToken: string, }; type EntryData = { +entryID: string, }; type CurrentUserData = {}; type UserData = { // ID of the UserInfo being updated +updatedUserID: string, }; type SharedUpdateData = { +userID: string, +time: number, }; type AccountDeletionUpdateData = { ...SharedUpdateData, ...AccountDeletionData, +type: 0, }; type ThreadUpdateData = { ...SharedUpdateData, ...ThreadData, +type: 1, +targetSession?: string, }; type ThreadReadStatusUpdateData = { ...SharedUpdateData, ...ThreadReadStatusData, +type: 2, }; type ThreadDeletionUpdateData = { ...SharedUpdateData, ...ThreadDeletionData, +type: 3, }; type ThreadJoinUpdateData = { ...SharedUpdateData, ...ThreadJoinData, +type: 4, +targetSession?: string, }; type BadDeviceTokenUpdateData = { ...SharedUpdateData, ...BadDeviceTokenData, +type: 5, +targetCookie: string, }; type EntryUpdateData = { ...SharedUpdateData, ...EntryData, +type: 6, +targetSession: string, }; type CurrentUserUpdateData = { ...SharedUpdateData, ...CurrentUserData, +type: 7, }; type UserUpdateData = { ...SharedUpdateData, ...UserData, +type: 8, +targetSession?: string, }; export type UpdateData = | AccountDeletionUpdateData | ThreadUpdateData | ThreadReadStatusUpdateData | ThreadDeletionUpdateData | ThreadJoinUpdateData | BadDeviceTokenUpdateData | EntryUpdateData | CurrentUserUpdateData | UserUpdateData; type SharedRawUpdateInfo = { +id: string, +time: number, }; type AccountDeletionRawUpdateInfo = { ...SharedRawUpdateInfo, ...AccountDeletionData, +type: 0, }; type ThreadRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadData, +type: 1, }; type ThreadReadStatusRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadReadStatusData, +type: 2, }; type ThreadDeletionRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadDeletionData, +type: 3, }; type ThreadJoinRawUpdateInfo = { ...SharedRawUpdateInfo, ...ThreadJoinData, +type: 4, }; type BadDeviceTokenRawUpdateInfo = { ...SharedRawUpdateInfo, ...BadDeviceTokenData, +type: 5, }; type EntryRawUpdateInfo = { ...SharedRawUpdateInfo, ...EntryData, +type: 6, }; type CurrentUserRawUpdateInfo = { ...SharedRawUpdateInfo, ...CurrentUserData, +type: 7, }; type UserRawUpdateInfo = { ...SharedRawUpdateInfo, ...UserData, +type: 8, }; export type RawUpdateInfo = | AccountDeletionRawUpdateInfo | ThreadRawUpdateInfo | ThreadReadStatusRawUpdateInfo | ThreadDeletionRawUpdateInfo | ThreadJoinRawUpdateInfo | BadDeviceTokenRawUpdateInfo | EntryRawUpdateInfo | CurrentUserRawUpdateInfo | UserRawUpdateInfo; type AccountDeletionUpdateInfo = { +type: 0, +id: string, +time: number, +deletedUserID: string, }; export const accountDeletionUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.DELETE_ACCOUNT), id: t.String, time: t.Number, deletedUserID: t.String, }); type ThreadUpdateInfo = { +type: 1, +id: string, +time: number, +threadInfo: RawThreadInfo, }; export const threadUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_THREAD), id: t.String, time: t.Number, threadInfo: rawThreadInfoValidator, }); type ThreadReadStatusUpdateInfo = { +type: 2, +id: string, +time: number, +threadID: string, +unread: boolean, }; export const threadReadStatusUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_THREAD_READ_STATUS), id: t.String, time: t.Number, threadID: tID, unread: t.Boolean, }); type ThreadDeletionUpdateInfo = { +type: 3, +id: string, +time: number, +threadID: string, }; export const threadDeletionUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.DELETE_THREAD), id: t.String, time: t.Number, threadID: tID, }); type ThreadJoinUpdateInfo = { +type: 4, +id: string, +time: number, +threadInfo: RawThreadInfo, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, +rawEntryInfos: $ReadOnlyArray, }; export const threadJoinUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.JOIN_THREAD), id: t.String, time: t.Number, threadInfo: rawThreadInfoValidator, rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatus: messageTruncationStatusValidator, rawEntryInfos: t.list(rawEntryInfoValidator), }); type BadDeviceTokenUpdateInfo = { +type: 5, +id: string, +time: number, +deviceToken: string, }; export const badDeviceTokenUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.BAD_DEVICE_TOKEN), id: t.String, time: t.Number, deviceToken: t.String, }); type EntryUpdateInfo = { +type: 6, +id: string, +time: number, +entryInfo: RawEntryInfo, }; export const entryUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_ENTRY), id: t.String, time: t.Number, entryInfo: rawEntryInfoValidator, }); type CurrentUserUpdateInfo = { +type: 7, +id: string, +time: number, +currentUserInfo: LoggedInUserInfo, }; type UserUpdateInfo = { +type: 8, +id: string, +time: number, // Updated UserInfo is already contained within the UpdatesResultWithUserInfos +updatedUserID: string, }; export const userUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_USER), id: t.String, time: t.Number, updatedUserID: t.String, }); export type ClientUpdateInfo = | AccountDeletionUpdateInfo | ThreadUpdateInfo | ThreadReadStatusUpdateInfo | ThreadDeletionUpdateInfo | ThreadJoinUpdateInfo | BadDeviceTokenUpdateInfo | EntryUpdateInfo | CurrentUserUpdateInfo | UserUpdateInfo; type ServerCurrentUserUpdateInfo = { +type: 7, +id: string, +time: number, +currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, }; export const serverCurrentUserUpdateInfoValidator: TInterface = tShape({ type: tNumber(updateTypes.UPDATE_CURRENT_USER), id: t.String, time: t.Number, currentUserInfo: t.union([ loggedInUserInfoValidator, oldLoggedInUserInfoValidator, ]), }); export type ServerUpdateInfo = | AccountDeletionUpdateInfo | ThreadUpdateInfo | ThreadReadStatusUpdateInfo | ThreadDeletionUpdateInfo | ThreadJoinUpdateInfo | BadDeviceTokenUpdateInfo | EntryUpdateInfo | ServerCurrentUserUpdateInfo | UserUpdateInfo; export const serverUpdateInfoValidator: TUnion = t.union([ accountDeletionUpdateInfoValidator, threadUpdateInfoValidator, threadReadStatusUpdateInfoValidator, threadDeletionUpdateInfoValidator, threadJoinUpdateInfoValidator, badDeviceTokenUpdateInfoValidator, entryUpdateInfoValidator, serverCurrentUserUpdateInfoValidator, userUpdateInfoValidator, ]); export type ServerUpdatesResult = { +currentAsOf: number, +newUpdates: $ReadOnlyArray, }; export const serverUpdatesResultValidator: TInterface = tShape({ currentAsOf: t.Number, newUpdates: t.list(serverUpdateInfoValidator), }); export type ServerUpdatesResultWithUserInfos = { +updatesResult: ServerUpdatesResult, +userInfos: $ReadOnlyArray, }; export const serverUpdatesResultWithUserInfosValidator: TInterface = tShape({ updatesResult: serverUpdatesResultValidator, userInfos: t.list(userInfoValidator), }); export type ClientUpdatesResult = { +currentAsOf: number, +newUpdates: $ReadOnlyArray, }; export type ClientUpdatesResultWithUserInfos = { +updatesResult: ClientUpdatesResult, +userInfos: $ReadOnlyArray, }; export type CreateUpdatesResult = { +viewerUpdates: $ReadOnlyArray, +userInfos: UserInfos, }; export const createUpdatesResultValidator: TInterface = tShape({ viewerUpdates: t.list(serverUpdateInfoValidator), userInfos: userInfosValidator, }); export type ServerCreateUpdatesResponse = { +viewerUpdates: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; export const serverCreateUpdatesResponseValidator: TInterface = tShape({ viewerUpdates: t.list(serverUpdateInfoValidator), userInfos: t.list(userInfoValidator), }); export type ClientCreateUpdatesResponse = { +viewerUpdates: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; export const processUpdatesActionType = 'PROCESS_UPDATES'; diff --git a/lib/types/validation.test.js b/lib/types/validation.test.js index f0338fa33..de97c98c7 100644 --- a/lib/types/validation.test.js +++ b/lib/types/validation.test.js @@ -1,904 +1,904 @@ // @flow import _findKey from 'lodash/fp/findKey.js'; import { rawEntryInfoValidator } from './entry-types.js'; import { imageValidator, videoValidator, mediaValidator, } from './media-types.js'; import { messageTypes } from './message-types-enum.js'; import { activityUpdateResponseServerSocketMessageValidator, apiResponseServerSocketMessageValidator, authErrorServerSocketMessageValidator, errorServerSocketMessageValidator, messagesServerSocketMessageValidator, pongServerSocketMessageValidator, serverRequestsServerSocketMessageValidator, serverSocketMessageTypes, serverStateSyncServerSocketMessageValidator, serverUpdatesServerSocketMessageValidator, } from './socket-types.js'; import { threadTypes } from './thread-types-enum.js'; import { rawThreadInfoValidator } from './thread-types.js'; +import { updateTypes } from './update-types-enum.js'; import { - updateTypes, accountDeletionUpdateInfoValidator, badDeviceTokenUpdateInfoValidator, entryUpdateInfoValidator, serverCurrentUserUpdateInfoValidator, threadDeletionUpdateInfoValidator, threadJoinUpdateInfoValidator, threadReadStatusUpdateInfoValidator, threadUpdateInfoValidator, userUpdateInfoValidator, } from './update-types.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; describe('media validation', () => { const photo = { id: '92696', type: 'photo', uri: 'http://0.0.0.0:3000/comm/upload/92696/0fb272bd1c75d976', dimensions: { width: 340, height: 288, }, }; const video = { type: 'video', id: '92769', uri: 'http://0.0.0.0:3000/comm/upload/92769/4bcc6987b25b2f66', dimensions: { width: 480, height: 270, }, thumbnailID: '92770', thumbnailURI: 'http://0.0.0.0:3000/comm/upload/92770/d56466051dcef1db', }; it('should validate correct media', () => { expect(mediaValidator.is(photo)).toBe(true); expect(imageValidator.is(photo)).toBe(true); expect(mediaValidator.is(video)).toBe(true); expect(videoValidator.is(video)).toBe(true); }); it('should not validate incorrect media', () => { expect(imageValidator.is(video)).toBe(false); expect(videoValidator.is(photo)).toBe(false); expect(mediaValidator.is({ ...photo, type: undefined })).toBe(false); expect(mediaValidator.is({ ...video, dimensions: undefined })).toBe(false); }); }); const messages = [ { type: messageTypes.TEXT, threadID: '83859', creatorID: '83853', time: 1682077048858, text: 'text', localID: 'local1', id: '92837', }, { type: messageTypes.CREATE_THREAD, id: '83876', threadID: '83859', time: 1673561105839, creatorID: '83853', initialThreadState: { type: 6, name: null, parentThreadID: '1', color: '57697f', memberIDs: ['256', '83853'], }, }, { type: messageTypes.ADD_MEMBERS, id: '4754380', threadID: '4746046', time: 1680179819346, creatorID: '256', addedUserIDs: ['518252', '1329299', '1559042'], }, { type: messageTypes.CREATE_SUB_THREAD, threadID: '87111', creatorID: '83928', time: 1682083573756, childThreadID: '92993', id: '93000', }, { type: messageTypes.CHANGE_SETTINGS, threadID: '83859', creatorID: '83853', time: 1682082984605, field: 'color', value: 'b8753d', id: '92880', }, { type: messageTypes.REMOVE_MEMBERS, threadID: '92993', creatorID: '83928', time: 1682083613415, removedUserIDs: ['83890'], id: '93012', }, { type: messageTypes.CHANGE_ROLE, threadID: '85027', creatorID: '256', time: 1632393331694, userIDs: ['85081'], newRole: 'role', id: '85431', }, { type: messageTypes.LEAVE_THREAD, id: '93027', threadID: '92993', time: 1682083651037, creatorID: '83928', }, { type: messageTypes.JOIN_THREAD, threadID: '92993', creatorID: '83928', time: 1682083678595, id: '93035', }, { type: messageTypes.CREATE_ENTRY, threadID: '84695', creatorID: '83928', time: 1682083217395, entryID: '92917', date: '2023-04-02', text: 'text', id: '92920', }, { type: messageTypes.EDIT_ENTRY, threadID: '84695', creatorID: '83928', time: 1682083374471, entryID: '92917', date: '2023-04-02', text: 'text', id: '92950', }, { type: messageTypes.DELETE_ENTRY, threadID: '86033', creatorID: '83928', time: 1682083220296, entryID: '92904', date: '2023-04-02', text: 'text', id: '92932', }, { type: messageTypes.RESTORE_ENTRY, id: '92962', threadID: '86033', time: 1682083414244, creatorID: '83928', entryID: '92904', date: '2023-04-02', text: 'text', }, { type: messageTypes.UNSUPPORTED, threadID: '87080', creatorID: '256', time: 1640733462322, robotext: 'unsupported message', unsupportedMessageInfo: { type: 105, threadID: '97489', creatorID: '256', time: 1640773011289, id: '97672', }, id: '97730', }, { type: messageTypes.IMAGES, threadID: '92796', creatorID: '83928', time: 1682083469079, media: [ { id: '92974', uri: 'http://0.0.0.0:3000/comm/upload/92974/ff3d02ded71e2762', type: 'photo', dimensions: { width: 220, height: 220, }, }, ], localID: 'local0', id: '92976', }, { type: messageTypes.MULTIMEDIA, threadID: '89644', creatorID: '83853', time: 1682076177257, media: [ { type: 'video', id: '92769', uri: 'http://0.0.0.0:3000/comm/upload/92769/4bcc6987b25b2f66', dimensions: { width: 480, height: 270, }, thumbnailID: '92770', thumbnailURI: 'http://0.0.0.0:3000/comm/upload/92770/d56466051dcef1db', }, ], id: '92771', }, { type: messageTypes.UPDATE_RELATIONSHIP, threadID: '92796', creatorID: '83928', targetID: '83853', time: 1682083716312, operation: 'request_sent', id: '93039', }, { type: messageTypes.SIDEBAR_SOURCE, threadID: '93044', creatorID: '83928', time: 1682083756831, sourceMessage: { type: 0, id: '92816', threadID: '92796', time: 1682076737518, creatorID: '83928', text: 'text', }, id: '93049', }, { type: messageTypes.CREATE_SIDEBAR, threadID: '93044', creatorID: '83928', time: 1682083756831, sourceMessageAuthorID: '83928', initialThreadState: { name: 'text', parentThreadID: '92796', color: 'aa4b4b', memberIDs: ['83853', '83928'], }, id: '93050', }, { type: messageTypes.REACTION, threadID: '86033', localID: 'local8', creatorID: '83928', time: 1682083295820, targetMessageID: '91607', reaction: '😂', action: 'add_reaction', id: '92943', }, { type: messageTypes.EDIT_MESSAGE, threadID: '86033', creatorID: '83928', time: 1682083295820, targetMessageID: '91607', text: 'text', id: '92943', }, { type: messageTypes.TOGGLE_PIN, threadID: '86033', targetMessageID: '91607', action: 'pin', pinnedContent: 'text', creatorID: '83928', time: 1682083295820, id: '92943', }, ]; describe('message validation', () => { for (const validatorMessageTypeName in messageTypes) { const validatorMessageType = messageTypes[validatorMessageTypeName]; const validator = messageSpecs[validatorMessageType].validator; for (const message of messages) { const messageTypeName = _findKey(e => e === message.type)(messageTypes); if (validatorMessageType === message.type) { it(`${validatorMessageTypeName} should validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(true); }); } else if ( !( (validatorMessageType === messageTypes.IMAGES && message.type === messageTypes.MULTIMEDIA) || (validatorMessageType === messageTypes.MULTIMEDIA && message.type === messageTypes.IMAGES) ) ) { it(`${validatorMessageTypeName} shouldn't validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(false); }); } } } }); const thread = { id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: '1', }, membership: { value: false, source: null, }, visible: { value: true, source: '1', }, voiced: { value: true, source: '1', }, edit_entries: { value: true, source: '1', }, edit_thread: { value: true, source: '1', }, edit_thread_description: { value: true, source: '1', }, edit_thread_color: { value: true, source: '1', }, delete_thread: { value: true, source: '1', }, create_subthreads: { value: true, source: '1', }, create_sidebars: { value: true, source: '1', }, join_thread: { value: true, source: '1', }, edit_permissions: { value: true, source: '1', }, add_members: { value: true, source: '1', }, remove_members: { value: true, source: '1', }, change_role: { value: true, source: '1', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: '1', }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: '85171', }, membership: { value: false, source: null, }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: '85171', }, membership: { value: false, source: null, }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; describe('thread validation', () => { it('should validate correct thread', () => { expect(rawThreadInfoValidator.is(thread)).toBe(true); }); it('should not validate incorrect thread', () => { expect( rawThreadInfoValidator.is({ ...thread, creationTime: undefined }), ).toBe(false); }); }); const entry = { id: '92860', threadID: '85068', text: 'text', year: 2023, month: 4, day: 2, creationTime: 1682082939882, creatorID: '83853', deleted: false, }; describe('entry validation', () => { it('should validate correct entry', () => { expect(rawEntryInfoValidator.is(entry)).toBe(true); }); it('should not validate incorrect entry', () => { expect(rawEntryInfoValidator.is({ ...entry, threadID: 0 })).toBe(false); }); }); const updates = [ { type: updateTypes.DELETE_ACCOUNT, id: '98424', time: 1640870111106, deletedUserID: '98262', }, { type: updateTypes.UPDATE_THREAD, id: '97948', time: 1640868525494, threadInfo: thread, }, { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: '98002', time: 1640869373326, threadID: '83794', unread: true, }, { type: updateTypes.DELETE_THREAD, id: '98208', time: 1640869773339, threadID: '97852', }, { type: updateTypes.JOIN_THREAD, id: '98126', time: 1640869494461, threadInfo: thread, rawMessageInfos: messages, truncationStatus: 'exhaustive', rawEntryInfos: [entry], }, { type: updateTypes.BAD_DEVICE_TOKEN, id: '98208', time: 1640869773495, deviceToken: 'some-device-token', }, { type: updateTypes.UPDATE_ENTRY, id: '98233', time: 1640869844908, entryInfo: entry, }, { type: updateTypes.UPDATE_CURRENT_USER, id: '98237', time: 1640869934058, currentUserInfo: { id: '256', username: 'ashoat', }, }, { type: updateTypes.UPDATE_USER, id: '97988', time: 1640869211822, updatedUserID: '86565', }, ]; describe('server update validation', () => { const validatorByUpdateType = { [updateTypes.DELETE_ACCOUNT]: accountDeletionUpdateInfoValidator, [updateTypes.UPDATE_THREAD]: threadUpdateInfoValidator, [updateTypes.UPDATE_THREAD_READ_STATUS]: threadReadStatusUpdateInfoValidator, [updateTypes.DELETE_THREAD]: threadDeletionUpdateInfoValidator, [updateTypes.JOIN_THREAD]: threadJoinUpdateInfoValidator, [updateTypes.BAD_DEVICE_TOKEN]: badDeviceTokenUpdateInfoValidator, [updateTypes.UPDATE_ENTRY]: entryUpdateInfoValidator, [updateTypes.UPDATE_CURRENT_USER]: serverCurrentUserUpdateInfoValidator, [updateTypes.UPDATE_USER]: userUpdateInfoValidator, }; for (const validatorUpdateType in validatorByUpdateType) { const validator = validatorByUpdateType[validatorUpdateType]; const validatorUpdateTypeName = _findKey( e => e === Number(validatorUpdateType), )(updateTypes); for (const update of updates) { const updateTypeName = _findKey(e => e === update.type)(updateTypes); if (Number(validatorUpdateType) === update.type) { it(`${validatorUpdateTypeName} should validate ${updateTypeName}`, () => { expect(validator.is(update)).toBe(true); }); } else { it(`${validatorUpdateTypeName} shouldn't validate ${updateTypeName}`, () => { expect(validator.is(update)).toBe(false); }); } } } }); describe('socket message validation', () => { const socketMessages = [ { type: serverSocketMessageTypes.STATE_SYNC, responseTo: 0, payload: { type: 1, messagesResult: { rawMessageInfos: messages, truncationStatuses: { '86033': 'unchanged' }, currentAsOf: 1683296863468, }, updatesResult: { newUpdates: updates, currentAsOf: 1683296863489, }, deltaEntryInfos: [], deletedEntryIDs: [], userInfos: [], }, }, { type: serverSocketMessageTypes.REQUESTS, payload: { serverRequests: [ { type: 6, hashesToCheck: { threadInfos: 3311950643, entryInfos: 3191324567, currentUserInfo: 820850779, userInfos: 707653884, }, }, ], }, }, { type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, responseTo: 194, payload: { unfocusedToUnread: [] }, }, { type: serverSocketMessageTypes.PONG, responseTo: 190 }, { type: 6, payload: { updatesResult: { currentAsOf: 1683298141720, newUpdates: [ { type: 1, id: '94428', time: 1683298141720, threadInfo: thread, }, ], }, userInfos: [], }, }, { type: serverSocketMessageTypes.MESSAGES, payload: { messagesResult: { rawMessageInfos: messages, truncationStatuses: { '86033': 'unchanged' }, currentAsOf: 1683298141707, }, }, }, { type: serverSocketMessageTypes.API_RESPONSE, responseTo: 209, payload: { rawMessageInfos: messages, truncationStatuses: { '1': 'exhaustive' }, userInfos: {}, }, }, ]; const validatorByMessageType = { [serverSocketMessageTypes.STATE_SYNC]: serverStateSyncServerSocketMessageValidator, [serverSocketMessageTypes.REQUESTS]: serverRequestsServerSocketMessageValidator, [serverSocketMessageTypes.ERROR]: errorServerSocketMessageValidator, [serverSocketMessageTypes.AUTH_ERROR]: authErrorServerSocketMessageValidator, [serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE]: activityUpdateResponseServerSocketMessageValidator, [serverSocketMessageTypes.PONG]: pongServerSocketMessageValidator, [serverSocketMessageTypes.UPDATES]: serverUpdatesServerSocketMessageValidator, [serverSocketMessageTypes.MESSAGES]: messagesServerSocketMessageValidator, [serverSocketMessageTypes.API_RESPONSE]: apiResponseServerSocketMessageValidator, }; for (const validatorMessageType in validatorByMessageType) { const validator = validatorByMessageType[validatorMessageType]; const validatorMessageTypeName = _findKey( e => e === Number(validatorMessageType), )(serverSocketMessageTypes); for (const message of socketMessages) { const messageTypeName = _findKey(e => e === message.type)( serverSocketMessageTypes, ); if (Number(validatorMessageType) === message.type) { it(`${validatorMessageTypeName} should validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(true); }); } else { it(`${validatorMessageTypeName} shouldn't validate ${messageTypeName}`, () => { expect(validator.is(message)).toBe(false); }); } } } });