diff --git a/lib/shared/dm-ops/change-thread-read-status-spec.js b/lib/shared/dm-ops/change-thread-read-status-spec.js index 329badd09..d18aae409 100644 --- a/lib/shared/dm-ops/change-thread-read-status-spec.js +++ b/lib/shared/dm-ops/change-thread-read-status-spec.js @@ -1,87 +1,75 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec'; import type { DMChangeThreadReadStatusOperation } from '../../types/dm-ops'; import { updateTypes } from '../../types/update-types-enum.js'; const changeThreadReadStatusSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async ( dmOperation: DMChangeThreadReadStatusOperation, ) => { const { threadID, unread } = dmOperation; if (unread) { return { badgeUpdateData: { threadID } }; } return { rescindData: { threadID } }; }, processDMOperation: async ( dmOperation: DMChangeThreadReadStatusOperation, utilities: ProcessDMOperationUtilities, ) => { const { threadID, unread, time } = dmOperation; const threadInfo = utilities.threadInfos[threadID]; invariant(threadInfo.thick, 'Thread should be thick'); if (threadInfo.timestamps.currentUser.unread > time) { return { rawMessageInfos: [], updateInfos: [], }; } const updateInfos = [ { - type: updateTypes.UPDATE_THREAD, + type: updateTypes.UPDATE_THREAD_READ_STATUS, id: uuid.v4(), time, - threadInfo: { - ...threadInfo, - currentUser: { - ...threadInfo.currentUser, - unread, - }, - timestamps: { - ...threadInfo.timestamps, - currentUser: { - ...threadInfo.timestamps.currentUser, - unread: time, - }, - }, - }, + threadID: threadInfo.id, + unread, }, ]; return { rawMessageInfos: [], updateInfos, }; }, canBeProcessed: async ( dmOperation: DMChangeThreadReadStatusOperation, utilities: ProcessDMOperationUtilities, ) => { const { creatorID, threadID } = dmOperation; const { threadInfos, viewerID } = utilities; if (viewerID !== creatorID) { return { isProcessingPossible: false, reason: { type: 'invalid' } }; } if (!threadInfos[threadID]) { return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID }, }; } return { isProcessingPossible: true }; }, supportsAutoRetry: true, }); export { changeThreadReadStatusSpec }; diff --git a/lib/shared/dm-ops/process-dm-ops.js b/lib/shared/dm-ops/process-dm-ops.js index aa78ba61b..a24e244af 100644 --- a/lib/shared/dm-ops/process-dm-ops.js +++ b/lib/shared/dm-ops/process-dm-ops.js @@ -1,481 +1,452 @@ // @flow import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import uuid from 'uuid'; import type { ProcessDMOperationUtilities } from './dm-op-spec.js'; import { dmOpSpecs } from './dm-op-specs.js'; import { type OutboundDMOperationSpecification, type DMOperationSpecification, createMessagesToPeersFromDMOp, dmOperationSpecificationTypes, type OutboundComposableDMOperationSpecification, } from './dm-op-utils.js'; import { processNewUserIDsActionType, useFindUserIdentities, } from '../../actions/user-actions.js'; import { useLoggedInUserInfo } from '../../hooks/account-hooks.js'; import { useGetLatestMessageEdit } from '../../hooks/latest-message-edit.js'; import { useDispatchWithMetadata } from '../../hooks/ops-hooks.js'; import { mergeUpdatesWithMessageInfos } from '../../reducers/message-reducer.js'; import { getAllPeerUserIDAndDeviceIDs } from '../../selectors/user-selectors.js'; import { usePeerToPeerCommunication, type ProcessOutboundP2PMessagesResult, } from '../../tunnelbroker/peer-to-peer-context.js'; import { processDMOpsActionType, queueDMOpsActionType, dmOperationValidator, } from '../../types/dm-ops.js'; import type { RawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { NotificationsCreationData } from '../../types/notif-types.js'; import type { DispatchMetadata } from '../../types/redux-types.js'; import type { OutboundP2PMessage } from '../../types/sqlite-types.js'; import type { LegacyRawThreadInfo } from '../../types/thread-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import { extractUserIDsFromPayload } from '../../utils/conversion-utils.js'; import { useSelector, useDispatch } from '../../utils/redux-utils.js'; import { messageSpecs } from '../messages/message-specs.js'; import { updateSpecs } from '../updates/update-specs.js'; function useSendDMOperationUtils() { const fetchMessage = useGetLatestMessageEdit(); const threadInfos = useSelector(state => state.threadStore.threadInfos); const entryInfos = useSelector(state => state.entryStore.entryInfos); const findUserIdentities = useFindUserIdentities(); const loggedInUserInfo = useLoggedInUserInfo(); const viewerID = loggedInUserInfo?.id; return React.useMemo( () => ({ viewerID, fetchMessage, threadInfos, entryInfos, findUserIdentities, }), [viewerID, fetchMessage, threadInfos, entryInfos, findUserIdentities], ); } function useProcessDMOperation(): ( dmOperationSpecification: DMOperationSpecification, dmOpID: ?string, ) => Promise { const threadInfos = useSelector(state => state.threadStore.threadInfos); const baseUtilities = useSendDMOperationUtils(); const dispatchWithMetadata = useDispatchWithMetadata(); const allPeerUserIDAndDeviceIDs = useSelector(getAllPeerUserIDAndDeviceIDs); const currentUserInfo = useSelector(state => state.currentUserInfo); const dispatch = useDispatch(); return React.useCallback( async ( dmOperationSpecification: DMOperationSpecification, dmOpID: ?string, ) => { const { viewerID, ...restUtilities } = baseUtilities; if (!viewerID) { console.log('ignored DMOperation because logged out'); return; } const utilities: ProcessDMOperationUtilities = { ...restUtilities, viewerID, }; const { op: dmOp } = dmOperationSpecification; let outboundP2PMessages: ?$ReadOnlyArray = null; if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND ) { outboundP2PMessages = await createMessagesToPeersFromDMOp( dmOp, dmOperationSpecification.recipients, allPeerUserIDAndDeviceIDs, currentUserInfo, threadInfos, ); } let dispatchMetadata: ?DispatchMetadata = null; if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND && dmOpID ) { dispatchMetadata = { dmOpID, }; } else if ( dmOperationSpecification.type === dmOperationSpecificationTypes.INBOUND ) { dispatchMetadata = dmOperationSpecification.metadata; } let composableMessageID: ?string = null; if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND && !dmOpSpecs[dmOp.type].supportsAutoRetry ) { composableMessageID = dmOp.messageID; } if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND && dmOperationSpecification.sendOnly ) { const notificationsCreationData = await dmOpSpecs[ dmOp.type ].notificationsCreationData?.(dmOp, utilities); dispatchWithMetadata( { type: processDMOpsActionType, payload: { rawMessageInfos: [], updateInfos: [], outboundP2PMessages, composableMessageID, notificationsCreationData, }, }, dispatchMetadata, ); return; } const processingCheckResult = await dmOpSpecs[dmOp.type].canBeProcessed( dmOp, utilities, ); if (!processingCheckResult.isProcessingPossible) { if (processingCheckResult.reason.type === 'invalid') { return; } let condition; if (processingCheckResult.reason.type === 'missing_thread') { condition = { type: 'thread', threadID: processingCheckResult.reason.threadID, }; } else if (processingCheckResult.reason.type === 'missing_entry') { condition = { type: 'entry', entryID: processingCheckResult.reason.entryID, }; } else if (processingCheckResult.reason.type === 'missing_message') { condition = { type: 'message', messageID: processingCheckResult.reason.messageID, }; } else if (processingCheckResult.reason.type === 'missing_membership') { condition = { type: 'membership', threadID: processingCheckResult.reason.threadID, userID: processingCheckResult.reason.userID, }; } dispatchWithMetadata( { type: queueDMOpsActionType, payload: { operation: dmOp, timestamp: Date.now(), condition, }, }, dispatchMetadata, ); return; } const newUserIDs = extractUserIDsFromPayload(dmOperationValidator, dmOp); if (newUserIDs.length > 0) { dispatch({ type: processNewUserIDsActionType, payload: { userIDs: newUserIDs }, }); } const dmOpSpec = dmOpSpecs[dmOp.type]; const notificationsCreationDataPromise: Promise = (async () => { if ( dmOperationSpecification.type === dmOperationSpecificationTypes.INBOUND || !dmOpSpec.notificationsCreationData ) { return null; } return await dmOpSpec.notificationsCreationData(dmOp, utilities); })(); const [{ rawMessageInfos, updateInfos }, notificationsCreationData] = await Promise.all([ dmOpSpec.processDMOperation(dmOp, utilities), notificationsCreationDataPromise, ]); const { rawMessageInfos: allNewMessageInfos } = mergeUpdatesWithMessageInfos(rawMessageInfos, updateInfos); const messagesByThreadID = _groupBy(message => message.threadID)( allNewMessageInfos, ); const updatedThreadInfosByThreadID: { [string]: RawThreadInfo | LegacyRawThreadInfo, } = {}; for (const threadID in messagesByThreadID) { updatedThreadInfosByThreadID[threadID] = threadInfos[threadID]; } for (const update of updateInfos) { const updatedThreadInfo = updateSpecs[ update.type ].getUpdatedThreadInfo?.(update, updatedThreadInfosByThreadID); if (updatedThreadInfo) { updatedThreadInfosByThreadID[updatedThreadInfo.id] = updatedThreadInfo; } } for (const threadID in messagesByThreadID) { const repliesCountIncreasingMessages = messagesByThreadID[ threadID ].filter(message => messageSpecs[message.type].includedInRepliesCount); const threadInfo = updatedThreadInfosByThreadID[threadID]; if (repliesCountIncreasingMessages.length > 0) { const repliesCountIncreaseTime = Math.max( repliesCountIncreasingMessages.map(message => message.time), ); updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time: repliesCountIncreaseTime, threadInfo: { ...threadInfo, repliesCount: threadInfo.repliesCount + repliesCountIncreasingMessages.length, }, }); } const messagesFromOtherPeers = messagesByThreadID[threadID].filter( message => message.creatorID !== viewerID, ); if (messagesFromOtherPeers.length === 0) { continue; } // We take the most recent timestamp to make sure that // change_thread_read_status operation older // than it won't flip the status to read. const time = Math.max( messagesFromOtherPeers.map(message => message.time), ); invariant(threadInfo.thick, 'Thread should be thick'); // We aren't checking if the unread timestamp is lower than the time. // We're doing this because we want to flip the thread to unread after // any new message from a non-viewer. - const updatedThreadInfo = threadInfo.minimallyEncoded - ? { - ...threadInfo, - currentUser: { - ...threadInfo.currentUser, - unread: true, - }, - timestamps: { - ...threadInfo.timestamps, - currentUser: { - ...threadInfo.timestamps.currentUser, - unread: time, - }, - }, - } - : { - ...threadInfo, - currentUser: { - ...threadInfo.currentUser, - unread: true, - }, - timestamps: { - ...threadInfo.timestamps, - currentUser: { - ...threadInfo.timestamps.currentUser, - unread: time, - }, - }, - }; - updateInfos.push({ - type: updateTypes.UPDATE_THREAD, + type: updateTypes.UPDATE_THREAD_READ_STATUS, id: uuid.v4(), time, - threadInfo: updatedThreadInfo, + threadID: threadInfo.id, + unread: true, }); } dispatchWithMetadata( { type: processDMOpsActionType, payload: { rawMessageInfos, updateInfos, outboundP2PMessages, composableMessageID, notificationsCreationData, }, }, dispatchMetadata, ); }, [ baseUtilities, dispatchWithMetadata, allPeerUserIDAndDeviceIDs, currentUserInfo, threadInfos, dispatch, ], ); } function useProcessAndSendDMOperation(): ( dmOperationSpecification: OutboundDMOperationSpecification, ) => Promise { const processDMOps = useProcessDMOperation(); const { getDMOpsSendingPromise } = usePeerToPeerCommunication(); return React.useCallback( async (dmOperationSpecification: OutboundDMOperationSpecification) => { const { promise, dmOpID } = getDMOpsSendingPromise(); await processDMOps(dmOperationSpecification, dmOpID); await promise; }, [getDMOpsSendingPromise, processDMOps], ); } function useSendComposableDMOperation(): ( dmOperationSpecification: OutboundComposableDMOperationSpecification, ) => Promise { const threadInfos = useSelector(state => state.threadStore.threadInfos); const { getDMOpsSendingPromise } = usePeerToPeerCommunication(); const dispatchWithMetadata = useDispatchWithMetadata(); const allPeerUserIDAndDeviceIDs = useSelector(getAllPeerUserIDAndDeviceIDs); const currentUserInfo = useSelector(state => state.currentUserInfo); const baseUtilities = useSendDMOperationUtils(); const { processOutboundMessages } = usePeerToPeerCommunication(); const localMessageInfos = useSelector(state => state.messageStore.local); return React.useCallback( async ( dmOperationSpecification: OutboundComposableDMOperationSpecification, ): Promise => { const { viewerID, ...restUtilities } = baseUtilities; if (!viewerID) { console.log('ignored DMOperation because logged out'); return { result: 'failure', failedMessageIDs: [], }; } const utilities: ProcessDMOperationUtilities = { ...restUtilities, viewerID, }; const { promise, dmOpID } = getDMOpsSendingPromise(); const { op, composableMessageID, recipients } = dmOperationSpecification; const localMessageInfo = localMessageInfos[composableMessageID]; if ( localMessageInfo?.outboundP2PMessageIDs && localMessageInfo.outboundP2PMessageIDs.length > 0 ) { processOutboundMessages(localMessageInfo.outboundP2PMessageIDs, dmOpID); try { // This code should never throw. return await promise; } catch (e) { invariant( localMessageInfo.outboundP2PMessageIDs, 'outboundP2PMessageIDs should be defined', ); return { result: 'failure', failedMessageIDs: localMessageInfo.outboundP2PMessageIDs, }; } } const outboundP2PMessages = await createMessagesToPeersFromDMOp( op, recipients, allPeerUserIDAndDeviceIDs, currentUserInfo, threadInfos, ); const notificationsCreationData = await dmOpSpecs[ op.type ].notificationsCreationData?.(op, utilities); dispatchWithMetadata( { type: processDMOpsActionType, payload: { rawMessageInfos: [], updateInfos: [], outboundP2PMessages, composableMessageID, notificationsCreationData, }, }, { dmOpID, }, ); try { // This code should never throw. return await promise; } catch (e) { return { result: 'failure', failedMessageIDs: outboundP2PMessages.map( message => message.messageID, ), }; } }, [ allPeerUserIDAndDeviceIDs, currentUserInfo, dispatchWithMetadata, getDMOpsSendingPromise, localMessageInfos, processOutboundMessages, threadInfos, baseUtilities, ], ); } export { useProcessDMOperation, useProcessAndSendDMOperation, useSendComposableDMOperation, }; diff --git a/lib/shared/updates/update-thread-read-status-spec.js b/lib/shared/updates/update-thread-read-status-spec.js index d4c6fc6f1..5c29323d1 100644 --- a/lib/shared/updates/update-thread-read-status-spec.js +++ b/lib/shared/updates/update-thread-read-status-spec.js @@ -1,140 +1,155 @@ // @flow import t from 'tcomb'; import type { UpdateSpec } from './update-spec.js'; import type { RawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { LegacyRawThreadInfo, RawThreadInfos, } from '../../types/thread-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { ThreadReadStatusUpdateInfo, ThreadReadStatusRawUpdateInfo, ThreadReadStatusUpdateData, } from '../../types/update-types.js'; import { tID, tNumber, tShape } from '../../utils/validation-utils.js'; export const updateThreadReadStatusSpec: UpdateSpec< ThreadReadStatusUpdateInfo, ThreadReadStatusRawUpdateInfo, ThreadReadStatusUpdateData, > = Object.freeze({ generateOpsForThreadUpdates( storeThreadInfos: RawThreadInfos, update: ThreadReadStatusUpdateInfo, ) { if ( !storeThreadInfos[update.threadID] || storeThreadInfos[update.threadID].currentUser.unread === update.unread ) { return null; } const storeThreadInfo = storeThreadInfos[update.threadID]; let updatedThread; - if (storeThreadInfo.minimallyEncoded) { + if (storeThreadInfo.thick) { + updatedThread = { + ...storeThreadInfo, + currentUser: { + ...storeThreadInfo.currentUser, + unread: update.unread, + }, + timestamps: { + ...storeThreadInfo.timestamps, + currentUser: { + ...storeThreadInfo.timestamps.currentUser, + unread: update.time, + }, + }, + }; + } else if (storeThreadInfo.minimallyEncoded) { updatedThread = { ...storeThreadInfo, currentUser: { ...storeThreadInfo.currentUser, unread: update.unread, }, }; } else { updatedThread = { ...storeThreadInfo, currentUser: { ...storeThreadInfo.currentUser, unread: update.unread, }, }; } return [ { type: 'replace', payload: { id: update.threadID, threadInfo: updatedThread, }, }, ]; }, rawUpdateInfoFromRow(row: Object) { const { threadID, unread } = JSON.parse(row.content); return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: row.id.toString(), time: row.time, threadID, unread, }; }, updateContentForServerDB(data: ThreadReadStatusUpdateData) { const { threadID, unread } = data; return JSON.stringify({ threadID, unread }); }, rawInfoFromData(data: ThreadReadStatusUpdateData, id: string) { return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id, time: data.time, threadID: data.threadID, unread: data.unread, }; }, updateInfoFromRawInfo(info: ThreadReadStatusRawUpdateInfo) { return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: info.id, time: info.time, threadID: info.threadID, unread: info.unread, }; }, deleteCondition: new Set([updateTypes.UPDATE_THREAD_READ_STATUS]), keyForUpdateData(data: ThreadReadStatusUpdateData) { return data.threadID; }, keyForUpdateInfo(info: ThreadReadStatusUpdateInfo) { return info.threadID; }, typesOfReplacedUpdatesForMatchingKey: new Set([ updateTypes.UPDATE_THREAD_READ_STATUS, ]), infoValidator: tShape({ type: tNumber(updateTypes.UPDATE_THREAD_READ_STATUS), id: t.String, time: t.Number, threadID: tID, unread: t.Boolean, }), getUpdatedThreadInfo( update: ThreadReadStatusUpdateInfo, threadInfos: { +[string]: LegacyRawThreadInfo | RawThreadInfo, }, ): ?(LegacyRawThreadInfo | RawThreadInfo) { const threadInfo = threadInfos[update.threadID]; if (!threadInfo) { return null; } if (threadInfo.minimallyEncoded) { return { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: update.unread, }, }; } return { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: update.unread, }, }; }, });