diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js index ef78d8d5f..2444229fc 100644 --- a/keyserver/src/responders/message-responders.js +++ b/keyserver/src/responders/message-responders.js @@ -1,426 +1,426 @@ // @flow import invariant from 'invariant'; import t, { type TInterface, type TUnion } from 'tcomb'; import { onlyOneEmojiRegex } from 'lib/shared/emojis.js'; import { createMediaMessageData, trimMessage, } from 'lib/shared/message-utils.js'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils.js'; import type { Media } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type SendTextMessageRequest, type SendMultimediaMessageRequest, type SendReactionMessageRequest, type SendEditMessageRequest, type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, type SendEditMessageResponse, type FetchPinnedMessagesRequest, type FetchPinnedMessagesResult, type SearchMessagesResponse, - type SearchMessagesRequest, + type SearchMessagesKeyserverRequest, } from 'lib/types/message-types.js'; import type { EditMessageData } from 'lib/types/messages/edit.js'; import type { ReactionMessageData } from 'lib/types/messages/reaction.js'; import type { TextMessageData } from 'lib/types/messages/text.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { tRegex, tShape, tMediaMessageMedia, tID, } from 'lib/utils/validation-utils.js'; import createMessages from '../creators/message-creator.js'; import { fetchMessageInfos, fetchMessageInfoForLocalID, fetchMessageInfoByID, fetchThreadMessagesCount, fetchPinnedMessageInfos, searchMessagesInSingleChat, } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { fetchUnassignedImages, fetchUnassignedMediaFromMediaMessageContent, } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { assignImages, assignMessageContainerToMedia, } from '../updaters/upload-updaters.js'; export const sendTextMessageRequestInputValidator: TInterface = tShape({ threadID: tID, localID: t.maybe(t.String), text: t.String, sidebarCreation: t.maybe(t.Boolean), }); async function textMessageCreationResponder( viewer: Viewer, request: SendTextMessageRequest, ): Promise { const { threadID, localID, text: rawText, sidebarCreation } = request; const text = trimMessage(rawText); if (!text) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } let messageData: TextMessageData = { type: messageTypes.TEXT, threadID, creatorID: viewer.id, time: Date.now(), text, }; if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { const numMessages = await fetchThreadMessagesCount(threadID); if (numMessages === 2) { // sidebarCreation is set below to prevent double notifs from a sidebar // creation. We expect precisely two messages to appear before a // sidebarCreation: a SIDEBAR_SOURCE and a CREATE_SIDEBAR. If two users // attempt to create a sidebar at the same time, then both clients will // attempt to set sidebarCreation here, but we only want to suppress // notifs for the client that won the race. messageData = { ...messageData, sidebarCreation }; } } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } export const fetchMessageInfosRequestInputValidator: TInterface = tShape({ cursors: t.dict(tID, t.maybe(tID)), numberPerThread: t.maybe(t.Number), }); async function messageFetchResponder( viewer: Viewer, request: FetchMessageInfosRequest, ): Promise { const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); return { ...response, userInfos: {}, }; } export const sendMultimediaMessageRequestInputValidator: TUnion = t.union([ // This option is only used for messageTypes.IMAGES tShape({ threadID: tID, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaIDs: t.list(tID), }), tShape({ threadID: tID, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaMessageContents: t.list(tMediaMessageMedia), }), ]); async function multimediaMessageCreationResponder( viewer: Viewer, request: SendMultimediaMessageRequest, ): Promise { if ( (request.mediaIDs && request.mediaIDs.length === 0) || (request.mediaMessageContents && request.mediaMessageContents.length === 0) ) { throw new ServerError('invalid_parameters'); } const { threadID, localID, sidebarCreation } = request; const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const existingMessageInfoPromise = fetchMessageInfoForLocalID( viewer, localID, ); const mediaPromise: Promise<$ReadOnlyArray> = request.mediaIDs ? fetchUnassignedImages(viewer, request.mediaIDs) : fetchUnassignedMediaFromMediaMessageContent( viewer, request.mediaMessageContents, ); const [existingMessageInfo, media] = await Promise.all([ existingMessageInfoPromise, mediaPromise, ]); if (media.length === 0 && !existingMessageInfo) { throw new ServerError('invalid_parameters'); } // We use the MULTIMEDIA type for encrypted photos const containsEncryptedMedia = media.some( m => m.type === 'encrypted_photo' || m.type === 'encrypted_video', ); const messageData = createMediaMessageData( { localID, threadID, creatorID: viewer.id, media, sidebarCreation, }, { forceMultimediaMessageType: containsEncryptedMedia }, ); const [newMessageInfo] = await createMessages(viewer, [messageData]); const { id } = newMessageInfo; invariant( id !== null && id !== undefined, 'serverID should be set in createMessages result', ); if (request.mediaIDs) { await assignImages(viewer, request.mediaIDs, id, threadID); } else { await assignMessageContainerToMedia( viewer, request.mediaMessageContents, id, threadID, ); } return { newMessageInfo }; } export const sendReactionMessageRequestInputValidator: TInterface = tShape({ threadID: tID, localID: t.maybe(t.String), targetMessageID: tID, reaction: tRegex(onlyOneEmojiRegex), action: t.enums.of(['add_reaction', 'remove_reaction']), }); async function reactionMessageCreationResponder( viewer: Viewer, request: SendReactionMessageRequest, ): Promise { const { threadID, localID, targetMessageID, reaction, action } = request; if (!targetMessageID || !reaction) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } const [serverThreadInfos, hasPermission, targetMessageUserInfos] = await Promise.all([ fetchServerThreadInfos({ threadID }), checkThreadPermission( viewer, threadID, threadPermissions.REACT_TO_MESSAGE, ), fetchKnownUserInfos(viewer, [targetMessageInfo.creatorID]), ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { throw new ServerError('invalid_parameters'); } const targetMessageCreator = targetMessageUserInfos[targetMessageInfo.creatorID]; const targetMessageCreatorRelationship = targetMessageCreator?.relationshipStatus; const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); if (!hasPermission || creatorRelationshipHasBlock) { throw new ServerError('invalid_parameters'); } let messageData: ReactionMessageData = { type: messageTypes.REACTION, threadID, creatorID: viewer.id, time: Date.now(), targetMessageID, reaction, action, }; if (localID) { messageData = { ...messageData, localID }; } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } export const editMessageRequestInputValidator: TInterface = tShape({ targetMessageID: tID, text: t.String, }); async function editMessageCreationResponder( viewer: Viewer, request: SendEditMessageRequest, ): Promise { const { targetMessageID, text: rawText } = request; const text = trimMessage(rawText); if (!targetMessageID || !text) { throw new ServerError('invalid_parameters'); } const targetMessageInfo = await fetchMessageInfoByID(viewer, targetMessageID); if (!targetMessageInfo || !targetMessageInfo.id) { throw new ServerError('invalid_parameters'); } if (targetMessageInfo.type !== messageTypes.TEXT) { throw new ServerError('invalid_parameters'); } const { threadID } = targetMessageInfo; const [serverThreadInfos, hasPermission, rawSidebarThreadInfos] = await Promise.all([ fetchServerThreadInfos({ threadID }), checkThreadPermission(viewer, threadID, threadPermissions.EDIT_MESSAGE), fetchServerThreadInfos({ parentThreadID: threadID, sourceMessageID: targetMessageID, }), ]); const targetMessageThreadInfo = serverThreadInfos.threadInfos[threadID]; if (targetMessageThreadInfo.sourceMessageID === targetMessageID) { // We are editing first message of the sidebar // If client wants to do that it sends id of the sourceMessage instead throw new ServerError('invalid_parameters'); } if (!hasPermission) { throw new ServerError('invalid_parameters'); } if (targetMessageInfo.creatorID !== viewer.id) { throw new ServerError('invalid_parameters'); } const time = Date.now(); const messagesData = []; let messageData: EditMessageData = { type: messageTypes.EDIT_MESSAGE, threadID, creatorID: viewer.id, time, targetMessageID, text, }; messagesData.push(messageData); const sidebarThreadValues = values(rawSidebarThreadInfos.threadInfos); for (const sidebarThreadValue of sidebarThreadValues) { if (sidebarThreadValue && sidebarThreadValue.id) { messageData = { type: messageTypes.EDIT_MESSAGE, threadID: sidebarThreadValue.id, creatorID: viewer.id, time, targetMessageID, text: text, }; messagesData.push(messageData); } } const newMessageInfos = await createMessages(viewer, messagesData); return { newMessageInfos }; } export const fetchPinnedMessagesResponderInputValidator: TInterface = tShape({ threadID: tID, }); async function fetchPinnedMessagesResponder( viewer: Viewer, request: FetchPinnedMessagesRequest, ): Promise { return await fetchPinnedMessageInfos(viewer, request); } -export const searchMessagesResponderInputValidator: TInterface = - tShape({ +export const searchMessagesResponderInputValidator: TInterface = + tShape({ query: t.String, threadID: tID, cursor: t.maybe(tID), }); async function searchMessagesResponder( viewer: Viewer, - request: SearchMessagesRequest, + request: SearchMessagesKeyserverRequest, ): Promise { return await searchMessagesInSingleChat( request.query, request.threadID, viewer, request.cursor, ); } export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, fetchPinnedMessagesResponder, searchMessagesResponder, }; diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index dafe51d24..52481205f 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,530 +1,569 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; import type { CallSingleKeyserverEndpointResultInfo } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { + extractKeyserverIDFromIDOptional, extractKeyserverIDFromID, sortThreadIDsPerKeyserver, } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import type { FetchMessageInfosPayload, SendMessageResult, SendEditMessageResult, SendReactionMessageRequest, SimpleMessagesPayload, SendEditMessageRequest, FetchPinnedMessagesRequest, FetchPinnedMessagesResult, SearchMessagesRequest, + SearchMessagesKeyserverRequest, SearchMessagesResponse, FetchMessageInfosRequest, RawMessageInfo, MessageTruncationStatuses, } from '../types/message-types.js'; +import { defaultNumberPerThread } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; import type { ToggleMessagePinRequest, ToggleMessagePinResult, } from '../types/thread-types.js'; +import { getConfig } from '../utils/config.js'; +import { translateClientDBMessageInfoToRawMessageInfo } from '../utils/message-ops-utils.js'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); export type FetchMessagesBeforeCursorInput = { +threadID: string, +beforeMessageID: string, }; const fetchMessagesBeforeCursor = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: FetchMessagesBeforeCursorInput, ) => Promise) => async input => { const { threadID, beforeMessageID } = input; const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: { cursors: { [threadID]: beforeMessageID, }, }, }; const responses = await callKeyserverEndpoint('fetch_messages', requests); return { threadID, rawMessageInfos: responses[keyserverID].rawMessageInfos, truncationStatus: responses[keyserverID].truncationStatuses[threadID], }; }; function useFetchMessagesBeforeCursor(): ( input: FetchMessagesBeforeCursorInput, ) => Promise { return useKeyserverCall(fetchMessagesBeforeCursor); } export type FetchMostRecentMessagesInput = { +threadID: string, }; const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); const fetchMostRecentMessages = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: FetchMostRecentMessagesInput, ) => Promise) => async input => { const { threadID } = input; const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: { cursors: { [threadID]: null, }, }, }; const responses = await callKeyserverEndpoint('fetch_messages', requests); return { threadID, rawMessageInfos: responses[keyserverID].rawMessageInfos, truncationStatus: responses[keyserverID].truncationStatuses[threadID], }; }; function useFetchMostRecentMessages(): ( input: FetchMostRecentMessagesInput, ) => Promise { return useKeyserverCall(fetchMostRecentMessages); } const fetchSingleMostRecentMessagesFromThreadsActionTypes = Object.freeze({ started: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_STARTED', success: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_SUCCESS', failed: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_FAILED', }); const fetchSingleMostRecentMessagesFromThreads = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((threadIDs: $ReadOnlyArray) => Promise) => async threadIDs => { const sortedThreadIDs = sortThreadIDsPerKeyserver(threadIDs); const requests: { [string]: FetchMessageInfosRequest } = {}; for (const keyserverID in sortedThreadIDs) { const cursors = Object.fromEntries( sortedThreadIDs[keyserverID].map(threadID => [threadID, null]), ); requests[keyserverID] = { cursors, numberPerThread: 1, }; } const responses = await callKeyserverEndpoint('fetch_messages', requests); let rawMessageInfos: $ReadOnlyArray = []; let truncationStatuses: MessageTruncationStatuses = {}; for (const keyserverID in responses) { rawMessageInfos = rawMessageInfos.concat( responses[keyserverID].rawMessageInfos, ); truncationStatuses = { ...truncationStatuses, ...responses[keyserverID].truncationStatuses, }; } return { rawMessageInfos, truncationStatuses, }; }; function useFetchSingleMostRecentMessagesFromThreads(): ( threadIDs: $ReadOnlyArray, ) => Promise { return useKeyserverCall(fetchSingleMostRecentMessagesFromThreads); } export type SendTextMessageInput = { +threadID: string, +localID: string, +text: string, +sidebarCreation?: boolean, }; const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); const sendTextMessage = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendTextMessageInput) => Promise) => async input => { let resultInfo; const getResultInfo = ( passedResultInfo: CallSingleKeyserverEndpointResultInfo, ) => { resultInfo = passedResultInfo; }; const { threadID, localID, text, sidebarCreation } = input; let payload = { threadID, localID, text }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: payload }; const responses = await callKeyserverEndpoint( 'create_text_message', requests, { getResultInfo, }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { id: responses[keyserverID].newMessageInfo.id, time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; function useSendTextMessage(): ( input: SendTextMessageInput, ) => Promise { return useKeyserverCall(sendTextMessage); } const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; export type SendMultimediaMessageInput = { +threadID: string, +localID: string, +mediaMessageContents: $ReadOnlyArray, +sidebarCreation?: boolean, }; const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); const sendMultimediaMessage = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendMultimediaMessageInput) => Promise) => async input => { let resultInfo; const getResultInfo = ( passedResultInfo: CallSingleKeyserverEndpointResultInfo, ) => { resultInfo = passedResultInfo; }; const { threadID, localID, mediaMessageContents, sidebarCreation } = input; let payload = { threadID, localID, mediaMessageContents }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: payload }; const responses = await callKeyserverEndpoint( 'create_multimedia_message', requests, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { id: responses[keyserverID].newMessageInfo.id, time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; function useSendMultimediaMessage(): ( input: SendMultimediaMessageInput, ) => Promise { return useKeyserverCall(sendMultimediaMessage); } export type LegacySendMultimediaMessageInput = { +threadID: string, +localID: string, +mediaIDs: $ReadOnlyArray, +sidebarCreation?: boolean, }; const legacySendMultimediaMessage = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: LegacySendMultimediaMessageInput, ) => Promise) => async input => { let resultInfo; const getResultInfo = ( passedResultInfo: CallSingleKeyserverEndpointResultInfo, ) => { resultInfo = passedResultInfo; }; const { threadID, localID, mediaIDs, sidebarCreation } = input; let payload = { threadID, localID, mediaIDs }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: payload }; const responses = await callKeyserverEndpoint( 'create_multimedia_message', requests, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { id: responses[keyserverID].newMessageInfo.id, time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; function useLegacySendMultimediaMessage(): ( input: LegacySendMultimediaMessageInput, ) => Promise { return useKeyserverCall(legacySendMultimediaMessage); } const sendReactionMessageActionTypes = Object.freeze({ started: 'SEND_REACTION_MESSAGE_STARTED', success: 'SEND_REACTION_MESSAGE_SUCCESS', failed: 'SEND_REACTION_MESSAGE_FAILED', }); const sendReactionMessage = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendReactionMessageRequest) => Promise) => async input => { let resultInfo; const getResultInfo = ( passedResultInfo: CallSingleKeyserverEndpointResultInfo, ) => { resultInfo = passedResultInfo; }; const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: { threadID: input.threadID, localID: input.localID, targetMessageID: input.targetMessageID, reaction: input.reaction, action: input.action, }, }; const responses = await callKeyserverEndpoint( 'create_reaction_message', requests, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { id: responses[keyserverID].newMessageInfo.id, time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; function useSendReactionMessage(): ( input: SendReactionMessageRequest, ) => Promise { return useKeyserverCall(sendReactionMessage); } const sendEditMessageActionTypes = Object.freeze({ started: 'SEND_EDIT_MESSAGE_STARTED', success: 'SEND_EDIT_MESSAGE_SUCCESS', failed: 'SEND_EDIT_MESSAGE_FAILED', }); const sendEditMessage = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendEditMessageRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.targetMessageID); const requests = { [keyserverID]: { targetMessageID: input.targetMessageID, text: input.text, }, }; const responses = await callKeyserverEndpoint('edit_message', requests); return { newMessageInfos: responses[keyserverID].newMessageInfos, }; }; function useSendEditMessage(): ( input: SendEditMessageRequest, ) => Promise { return useKeyserverCall(sendEditMessage); } const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; const fetchPinnedMessageActionTypes = Object.freeze({ started: 'FETCH_PINNED_MESSAGES_STARTED', success: 'FETCH_PINNED_MESSAGES_SUCCESS', failed: 'FETCH_PINNED_MESSAGES_FAILED', }); const fetchPinnedMessages = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: FetchPinnedMessagesRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'fetch_pinned_messages', requests, ); return { pinnedMessages: responses[keyserverID].pinnedMessages }; }; function useFetchPinnedMessages(): ( input: FetchPinnedMessagesRequest, ) => Promise { return useKeyserverCall(fetchPinnedMessages); } const searchMessagesActionTypes = Object.freeze({ started: 'SEARCH_MESSAGES_STARTED', success: 'SEARCH_MESSAGES_SUCCESS', failed: 'SEARCH_MESSAGES_FAILED', }); const searchMessages = ( callKeyserverEndpoint: CallKeyserverEndpoint, - ): ((input: SearchMessagesRequest) => Promise) => + ): (( + input: SearchMessagesKeyserverRequest, + ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('search_messages', requests); return { messages: responses[keyserverID].messages, endReached: responses[keyserverID].endReached, }; }; function useSearchMessages(): ( input: SearchMessagesRequest, ) => Promise { - return useKeyserverCall(searchMessages); + const thinThreadCallback = useKeyserverCall(searchMessages); + return React.useCallback( + async (input: SearchMessagesRequest) => { + const isThreadThin = !!extractKeyserverIDFromIDOptional(input.threadID); + + if (isThreadThin) { + return await thinThreadCallback({ + query: input.query, + threadID: input.threadID, + cursor: input.messageIDcursor, + }); + } + + const { sqliteAPI } = getConfig(); + const timestampCursor = input.timestampCursor?.toString(); + const clientDBMessageInfos = await sqliteAPI.searchMessages( + input.query, + input.threadID, + timestampCursor, + input.messageIDcursor, + ); + + const messages = clientDBMessageInfos.map( + translateClientDBMessageInfoToRawMessageInfo, + ); + return { + endReached: messages.length < defaultNumberPerThread, + messages, + }; + }, + [thinThreadCallback], + ); } const toggleMessagePinActionTypes = Object.freeze({ started: 'TOGGLE_MESSAGE_PIN_STARTED', success: 'TOGGLE_MESSAGE_PIN_SUCCESS', failed: 'TOGGLE_MESSAGE_PIN_FAILED', }); const toggleMessagePin = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ToggleMessagePinRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.messageID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'toggle_message_pin', requests, ); const response = responses[keyserverID]; return { newMessageInfos: response.newMessageInfos, threadID: response.threadID, }; }; function useToggleMessagePin(): ( input: ToggleMessagePinRequest, ) => Promise { return useKeyserverCall(toggleMessagePin); } export { fetchMessagesBeforeCursorActionTypes, useFetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, fetchSingleMostRecentMessagesFromThreadsActionTypes, useFetchSingleMostRecentMessagesFromThreads, sendTextMessageActionTypes, useSendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, useSendMultimediaMessage, useLegacySendMultimediaMessage, searchMessagesActionTypes, useSearchMessages, sendReactionMessageActionTypes, useSendReactionMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, sendEditMessageActionTypes, useSendEditMessage, useFetchPinnedMessages, fetchPinnedMessageActionTypes, toggleMessagePinActionTypes, useToggleMessagePin, }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index 0999e1546..8c9e0b95f 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,492 +1,501 @@ // @flow import * as React from 'react'; import { messageID } from './message-utils.js'; import SearchIndex from './search-index.js'; import { getContainingThreadID, userIsMember } from './thread-utils.js'; import { searchMessagesActionTypes, useSearchMessages as useSearchMessagesAction, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import genesis from '../facts/genesis.js'; import { useIdentitySearch } from '../identity-search/identity-search-context.js'; import { useLegacyAshoatKeyserverCall } from '../keyserver-conn/legacy-keyserver-call.js'; import { decodeThreadRolePermissionsBitmaskArray } from '../permissions/minimally-encoded-thread-permissions.js'; import type { ChatMessageInfoItem, MessageListData, } from '../selectors/chat-selectors.js'; import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import { relationshipBlockedInEitherDirection } from '../shared/relationship-utils.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import type { RoleInfo, ThreadInfo, RelativeMemberInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import type { ThreadRolePermissionsBlob } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypeIsSidebar, } from '../types/thread-types-enum.js'; import type { AccountUserInfo, GlobalAccountUserInfo, UserListItem, } from '../types/user-types.js'; import { isValidENSName } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; const notFriendNotice = 'not friend'; function appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }: { +results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, }, +excludeUserIDs: $ReadOnlyArray, +userInfo: AccountUserInfo | GlobalAccountUserInfo, +parentThreadInfo: ?ThreadInfo, +communityThreadInfo: ?ThreadInfo, +containingThreadInfo: ?ThreadInfo, }) { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } const memberInfo: ?RelativeMemberInfo = communityThreadInfo?.members.find( m => m.id === id, ); const role: ?RoleInfo = memberInfo?.role ? communityThreadInfo?.roles[memberInfo.role] : null; const decodedRolePermissions: ?ThreadRolePermissionsBlob = role?.permissions ? decodeThreadRolePermissionsBitmaskArray(role.permissions) : null; const hasKnowOfPermission = decodedRolePermissions?.[threadPermissions.KNOW_OF] === true; if (communityThreadInfo && !hasKnowOfPermission) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; } function usePotentialMemberItems({ text, userInfos, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, +inputParentThreadInfo?: ?ThreadInfo, +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const memoizedUserInfos = React.useMemo(() => values(userInfos), [userInfos]); const searchIndex: SearchIndex = useUserSearchIndex(memoizedUserInfos); const communityThreadInfo = React.useMemo( () => inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis().id ? inputCommunityThreadInfo : null, [inputCommunityThreadInfo], ); const parentThreadInfo = React.useMemo( () => inputParentThreadInfo && inputParentThreadInfo.id !== genesis().id ? inputParentThreadInfo : null, [inputParentThreadInfo], ); const containingThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; const containingThreadInfo = React.useMemo(() => { if (containingThreadID === parentThreadInfo?.id) { return parentThreadInfo; } else if (containingThreadID === communityThreadInfo?.id) { return communityThreadInfo; } return null; }, [containingThreadID, communityThreadInfo, parentThreadInfo]); const filteredUserResults = React.useMemo(() => { const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; if (text === '') { for (const id in userInfos) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => { if (!containingThreadInfo) { return userInfo.relationshipStatus === userRelationshipStatus.FRIEND; } if (!userInfo.isMemberOfContainingThread) { return false; } const { relationshipStatus } = userInfo; if (!relationshipStatus) { return true; } return !relationshipBlockedInEitherDirection(relationshipStatus); }); } return userResults; }, [ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, parentThreadInfo, containingThreadInfo, communityThreadInfo, ]); const sortedMembers = React.useMemo(() => { const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of filteredUserResults) { const { relationshipStatus } = userResult; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (!threadType || !threadTypeIsSidebar(threadType)) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); }, [containingThreadInfo, filteredUserResults, parentThreadInfo, threadType]); return sortedMembers; } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, queryID: number, threadID: string, ) => mixed, queryID: number, - cursor?: ?string, + timestampCursor?: ?number, + messageIDCursor?: ?string, ) => void { const callSearchMessages = useSearchMessagesAction(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( - (query, threadID, onResultsReceived, queryID, cursor) => { + ( + query, + threadID, + onResultsReceived, + queryID, + timestampCursor, + messageIDcursor, + ) => { const searchMessagesPromise = (async () => { if (query === '') { onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, - cursor, + timestampCursor, + messageIDcursor, }); onResultsReceived(messages, endReached, queryID, threadID); })(); void dispatchActionPromise( searchMessagesActionTypes, searchMessagesPromise, ); }, [callSearchMessages, dispatchActionPromise], ); } function useForwardLookupSearchText(originalText: string): string { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const lowercaseText = originalText.toLowerCase(); const [usernameToSearch, setUsernameToSearch] = React.useState(lowercaseText); React.useEffect(() => { void (async () => { if (!ensCache || !isValidENSName(lowercaseText)) { setUsernameToSearch(lowercaseText); return; } const address = await ensCache.getAddressForName(lowercaseText); if (address) { setUsernameToSearch(address); } else { setUsernameToSearch(lowercaseText); } })(); }, [ensCache, lowercaseText]); return usernameToSearch; } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const forwardLookupSearchText = useForwardLookupSearchText(usernameInputText); const [searchResults, setSearchResults] = React.useState< $ReadOnlyArray, >([]); const setSearchResultsFromServer = React.useCallback( (userInfos: $ReadOnlyArray) => { setSearchResults(userInfos.filter(({ id }) => id !== currentUserID)); }, [currentUserID], ); const callLegacyAshoatKeyserverSearchUsers = useLegacyAshoatKeyserverCall(searchUsers); const { connected: identitySearchSocketConnected, sendPrefixQuery: callIdentitySearchUsers, } = useIdentitySearch(); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { if (forwardLookupSearchText.length === 0) { setSearchResults([]); return; } const searchUsersPromise = (async () => { if (usingCommServicesAccessToken && identitySearchSocketConnected) { try { const identitySearchResult = await callIdentitySearchUsers( forwardLookupSearchText, ); const userInfos = identitySearchResult.map(user => ({ id: user.userID, username: user.username, avatar: null, })); setSearchResultsFromServer(userInfos); return; } catch (err) { console.error(err); } } const { userInfos: keyserverSearchResult } = await callLegacyAshoatKeyserverSearchUsers(forwardLookupSearchText); setSearchResultsFromServer(keyserverSearchResult); })(); void dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ setSearchResultsFromServer, callLegacyAshoatKeyserverSearchUsers, callIdentitySearchUsers, identitySearchSocketConnected, dispatchActionPromise, forwardLookupSearchText, ]); return searchResults; } function filterChatMessageInfosForSearch( chatMessageInfos: MessageListData, translatedSearchResults: $ReadOnlyArray, ): ?(ChatMessageInfoItem[]) { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(messageID)); const uniqueChatMessageInfoItemsMap = new Map(); for (const item of chatMessageInfos) { if (item.itemType !== 'message' || item.messageInfoType !== 'composable') { continue; } const id = messageID(item.messageInfo); if (idSet.has(id)) { uniqueChatMessageInfoItemsMap.set(id, item); } } const sortedChatMessageInfoItems: ChatMessageInfoItem[] = []; for (let i = 0; i < translatedSearchResults.length; i++) { const id = messageID(translatedSearchResults[i]); const match = uniqueChatMessageInfoItemsMap.get(id); if (match) { sortedChatMessageInfoItems.push(match); } } return sortedChatMessageInfoItems; } export { usePotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, useForwardLookupSearchText, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 23fa46647..27101c390 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,703 +1,710 @@ // @flow import invariant from 'invariant'; import t, { type TDict, type TEnums, type TInterface, type TUnion, } from 'tcomb'; import { type ClientDBMediaInfo } from './media-types.js'; import { type MessageType, messageTypes } from './message-types-enum.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from './messages/add-members.js'; import { type ChangeRoleMessageData, type ChangeRoleMessageInfo, type RawChangeRoleMessageInfo, rawChangeRoleMessageInfoValidator, } from './messages/change-role.js'; import { type ChangeSettingsMessageData, type ChangeSettingsMessageInfo, type RawChangeSettingsMessageInfo, rawChangeSettingsMessageInfoValidator, } from './messages/change-settings.js'; import { type CreateEntryMessageData, type CreateEntryMessageInfo, type RawCreateEntryMessageInfo, rawCreateEntryMessageInfoValidator, } from './messages/create-entry.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from './messages/create-sidebar.js'; import { type CreateSubthreadMessageData, type CreateSubthreadMessageInfo, type RawCreateSubthreadMessageInfo, rawCreateSubthreadMessageInfoValidator, } from './messages/create-subthread.js'; import { type CreateThreadMessageData, type CreateThreadMessageInfo, type RawCreateThreadMessageInfo, rawCreateThreadMessageInfoValidator, } from './messages/create-thread.js'; import { type DeleteEntryMessageData, type DeleteEntryMessageInfo, type RawDeleteEntryMessageInfo, rawDeleteEntryMessageInfoValidator, } from './messages/delete-entry.js'; import { type EditEntryMessageData, type EditEntryMessageInfo, type RawEditEntryMessageInfo, rawEditEntryMessageInfoValidator, } from './messages/edit-entry.js'; import { type EditMessageData, type EditMessageInfo, type RawEditMessageInfo, rawEditMessageInfoValidator, } from './messages/edit.js'; import { type ImagesMessageData, type ImagesMessageInfo, type RawImagesMessageInfo, rawImagesMessageInfoValidator, } from './messages/images.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from './messages/join-thread.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from './messages/leave-thread.js'; import { type RawLegacyUpdateRelationshipMessageInfo, rawLegacyUpdateRelationshipMessageInfoValidator, type LegacyUpdateRelationshipMessageData, type LegacyUpdateRelationshipMessageInfo, } from './messages/legacy-update-relationship.js'; import { type MediaMessageData, type MediaMessageInfo, type MediaMessageServerDBContent, type RawMediaMessageInfo, rawMediaMessageInfoValidator, } from './messages/media.js'; import { type RawReactionMessageInfo, rawReactionMessageInfoValidator, type ReactionMessageData, type ReactionMessageInfo, } from './messages/reaction.js'; import { type RawRemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, type RemoveMembersMessageData, type RemoveMembersMessageInfo, } from './messages/remove-members.js'; import { type RawRestoreEntryMessageInfo, rawRestoreEntryMessageInfoValidator, type RestoreEntryMessageData, type RestoreEntryMessageInfo, } from './messages/restore-entry.js'; import { type RawTextMessageInfo, rawTextMessageInfoValidator, type TextMessageData, type TextMessageInfo, } from './messages/text.js'; import { type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, type TogglePinMessageData, type TogglePinMessageInfo, } from './messages/toggle-pin.js'; import { type RawUnsupportedMessageInfo, rawUnsupportedMessageInfoValidator, type UnsupportedMessageInfo, } from './messages/unsupported.js'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { rawUpdateRelationshipMessageInfoValidator } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; import type { CallSingleKeyserverEndpointResultInfoInterface } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { values } from '../utils/objects.js'; import { tID, tNumber, tShape, tUserID } from '../utils/validation-utils.js'; const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function assertComposableRawMessage( message: RawMessageInfo, ): RawComposableMessageInfo { invariant( message.type === messageTypes.TEXT || message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, 'Message is not composable', ); return message; } export function messageDataLocalID(messageData: MessageData): ?string { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA && messageData.type !== messageTypes.REACTION ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assertMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type ValidRawSidebarSourceMessageInfo = | RawTextMessageInfo | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawImagesMessageInfo | RawMediaMessageInfo | RawLegacyUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawUpdateRelationshipMessageInfo; export type SidebarSourceMessageData = { +type: 17, +threadID: string, +creatorID: string, +time: number, +sourceMessage?: ValidRawSidebarSourceMessageInfo, }; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | LegacyUpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData | ReactionMessageData | EditMessageData | TogglePinMessageData | UpdateRelationshipMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export const rawMultimediaMessageInfoValidator: TUnion = t.union([rawImagesMessageInfoValidator, rawMediaMessageInfoValidator]); export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; const rawComposableMessageInfoValidator = t.union([ rawTextMessageInfoValidator, rawMultimediaMessageInfoValidator, ]); export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawLegacyUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawTogglePinMessageInfo | RawUpdateRelationshipMessageInfo; const rawRobotextMessageInfoValidator = t.union([ rawCreateThreadMessageInfoValidator, rawAddMembersMessageInfoValidator, rawCreateSubthreadMessageInfoValidator, rawChangeSettingsMessageInfoValidator, rawRemoveMembersMessageInfoValidator, rawChangeRoleMessageInfoValidator, rawLeaveThreadMessageInfoValidator, rawJoinThreadMessageInfoValidator, rawCreateEntryMessageInfoValidator, rawEditEntryMessageInfoValidator, rawDeleteEntryMessageInfoValidator, rawRestoreEntryMessageInfoValidator, rawLegacyUpdateRelationshipMessageInfoValidator, rawCreateSidebarMessageInfoValidator, rawUnsupportedMessageInfoValidator, rawTogglePinMessageInfoValidator, rawUpdateRelationshipMessageInfoValidator, ]); export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export const rawSidebarSourceMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.SIDEBAR_SOURCE), threadID: tID, creatorID: tUserID, time: t.Number, sourceMessage: t.maybe( t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, ]), ), id: tID, }); export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo | RawReactionMessageInfo | RawEditMessageInfo; export const rawMessageInfoValidator: TUnion = t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, rawSidebarSourceMessageInfoValidator, rawReactionMessageInfoValidator, rawEditMessageInfoValidator, ]); export type LocallyComposedMessageInfo = | ({ ...RawImagesMessageInfo, +localID: string, } & RawImagesMessageInfo) | ({ ...RawMediaMessageInfo, +localID: string, } & RawMediaMessageInfo) | ({ ...RawTextMessageInfo, +localID: string, } & RawTextMessageInfo) | ({ ...RawReactionMessageInfo, +localID: string, } & RawReactionMessageInfo); export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | LegacyUpdateRelationshipMessageInfo | CreateSidebarMessageInfo | TogglePinMessageInfo | UpdateRelationshipMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo | ReactionMessageInfo; export type ValidSidebarSourceMessageInfo = | TextMessageInfo | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | ImagesMessageInfo | MediaMessageInfo | LegacyUpdateRelationshipMessageInfo | CreateSidebarMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo; export type SidebarSourceMessageInfo = { +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ValidSidebarSourceMessageInfo, }; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo | ReactionMessageInfo | EditMessageInfo; export type ThreadMessageInfo = { messageIDs: string[], startReached: boolean, }; const threadMessageInfoValidator: TInterface = tShape({ messageIDs: t.list(tID), startReached: t.Boolean, }); // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = { +sendFailed?: boolean, }; const localMessageInfoValidator: TInterface = tShape({ sendFailed: t.maybe(t.Boolean), }); export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; const messageStoreThreadsValidator: TDict = t.dict( tID, threadMessageInfoValidator, ); export type MessageStoreLocalMessageInfos = { +[id: string]: LocalMessageInfo, }; const messageStoreLocalMessageInfosValidator: TDict = t.dict(tID, localMessageInfoValidator); export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: MessageStoreThreads, +local: MessageStoreLocalMessageInfos, +currentAsOf: { +[keyserverID: string]: number }, }; export const messageStoreValidator: TInterface = tShape({ messages: t.dict(tID, rawMessageInfoValidator), threads: messageStoreThreadsValidator, local: messageStoreLocalMessageInfosValidator, currentAsOf: t.dict(t.String, t.Number), }); // We were initially using `number`s` for `thread`, `type`, `future_type`, etc. // However, we ended up changing `thread` to `string` to account for thread IDs // including information about the keyserver (eg 'GENESIS|123') in the future. // // At that point we discussed whether we should switch the remaining `number` // fields to `string`s for consistency and flexibility. We researched whether // there was any performance cost to using `string`s instead of `number`s and // found the differences to be negligible. We also concluded using `string`s // may be safer after considering `jsi::Number` and the various C++ number // representations on the CommCoreModule side. export type ClientDBMessageInfo = { +id: string, +local_id: ?string, +thread: string, +user: string, +type: string, +future_type: ?string, +content: ?string, +time: string, +media_infos: ?$ReadOnlyArray, }; export type ClientDBThreadMessageInfo = { +id: string, +start_reached: string, }; export type ClientDBLocalMessageInfo = { +id: string, +localMessageInfo: string, }; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: MessageSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export const messageTruncationStatusValidator: TEnums = t.enums.of( values(messageTruncationStatus), ); export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export const messageTruncationStatusesValidator: TDict = t.dict(tID, messageTruncationStatusValidator); export type ThreadCursors = { +[threadID: string]: ?string }; export type MessageSelectionCriteria = { +threadCursors?: ?ThreadCursors, +joinedThreads?: ?boolean, +newerThan?: ?number, }; export type SimpleMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export type FetchMessageInfosRequest = { +cursors: ThreadCursors, +numberPerThread?: ?number, }; export type FetchMessageInfosResponse = $ReadOnly<{ ...SimpleMessagesPayload, +userInfos: UserInfos, }>; export type FetchMessageInfosResult = SimpleMessagesPayload; export type FetchMessageInfosPayload = { +threadID: string, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, }; export type MessagesResponse = $ReadOnly<{ ...SimpleMessagesPayload, +currentAsOf: number, }>; export const messagesResponseValidator: TInterface = tShape({ rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, currentAsOf: t.Number, }); export const defaultNumberPerThread = 20; export const defaultMaxMessageAge = 14 * 24 * 60 * 60 * 1000; // 2 weeks export type SendMessageResponse = { +newMessageInfo: RawMessageInfo, }; export type SendMessageResult = { +id: string, +time: number, +interface: CallSingleKeyserverEndpointResultInfoInterface, }; export type SendMessagePayload = { +localID: string, +serverID: string, +threadID: string, +time: number, +interface: CallSingleKeyserverEndpointResultInfoInterface, }; export type SendTextMessageRequest = { +threadID: string, +localID?: string, +text: string, +sidebarCreation?: boolean, }; export type SendMultimediaMessageRequest = // This option is only used for messageTypes.IMAGES | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaIDs: $ReadOnlyArray, } | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaMessageContents: $ReadOnlyArray, }; export type SendReactionMessageRequest = { +threadID: string, +localID?: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export type SendEditMessageRequest = { +targetMessageID: string, +text: string, }; export type SendEditMessageResponse = { +newMessageInfos: $ReadOnlyArray, }; export type EditMessagePayload = SendEditMessageResponse; export type SendEditMessageResult = SendEditMessageResponse; export type EditMessageContent = { +text: string, }; // Used for the message info included in log-in type actions export type GenericMessagesResult = { +messageInfos: RawMessageInfo[], +truncationStatus: MessageTruncationStatuses, +watchedIDsAtRequestTime: $ReadOnlyArray, +currentAsOf: { +[keyserverID: string]: number }, }; export type SaveMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +updatesCurrentAsOf: number, }; export type NewMessagesPayload = { +messagesResult: MessagesResponse, }; export const newMessagesPayloadValidator: TInterface = tShape({ messagesResult: messagesResponseValidator, }); export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; export type FetchPinnedMessagesRequest = { +threadID: string, }; export type FetchPinnedMessagesResult = { +pinnedMessages: $ReadOnlyArray, }; export type SearchMessagesRequest = { + +query: string, + +threadID: string, + +timestampCursor?: ?number, + +messageIDcursor?: ?string, +}; + +export type SearchMessagesKeyserverRequest = { +query: string, +threadID: string, +cursor?: ?string, }; export type SearchMessagesResponse = { +messages: $ReadOnlyArray, +endReached: boolean, }; diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js index 928e2e021..723d6d255 100644 --- a/native/search/message-search.react.js +++ b/native/search/message-search.react.js @@ -1,232 +1,244 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo } from 'lib/shared/message-utils.js'; import { filterChatMessageInfosForSearch, useSearchMessages, } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import SearchFooter from './search-footer.react.js'; import { MessageSearchContext } from './search-provider.react.js'; import { useHeightMeasurer } from '../chat/chat-context.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import { MessageListContextProvider } from '../chat/message-list-types.js'; import MessageResult from '../chat/message-result.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; export type MessageSearchParams = { +threadInfo: ThreadInfo, }; export type MessageSearchProps = { +navigation: ChatNavigationProp<'MessageSearch'>, +route: NavigationRoute<'MessageSearch'>, }; function MessageSearch(props: MessageSearchProps): React.Node { const searchContext = React.useContext(MessageSearchContext); invariant(searchContext, 'searchContext should be set'); const { query, clearQuery } = searchContext; const { threadInfo } = props.route.params; React.useEffect(() => { return props.navigation.addListener('beforeRemove', clearQuery); }, [props.navigation, clearQuery]); const [lastID, setLastID] = React.useState(); + const [lastTimestamp, setLastTimestamp] = React.useState(); const [searchResults, setSearchResults] = React.useState< $ReadOnlyArray, >([]); const [endReached, setEndReached] = React.useState(false); const appendSearchResults = React.useCallback( ( newMessages: $ReadOnlyArray, end: boolean, queryID: number, ) => { if (queryID !== queryIDRef.current) { return; } setSearchResults(oldMessages => [...oldMessages, ...newMessages]); setEndReached(end); }, [], ); const searchMessages = useSearchMessages(); const queryIDRef = React.useRef(0); React.useEffect(() => { setSearchResults([]); setLastID(undefined); + setLastTimestamp(undefined); setEndReached(false); }, [query, searchMessages]); React.useEffect(() => { queryIDRef.current += 1; searchMessages( query, threadInfo.id, appendSearchResults, queryIDRef.current, + lastTimestamp, lastID, ); - }, [appendSearchResults, lastID, query, searchMessages, threadInfo.id]); + }, [ + appendSearchResults, + lastID, + query, + searchMessages, + threadInfo.id, + lastTimestamp, + ]); const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo(() => { const result = filterChatMessageInfosForSearch( chatMessageInfos, translatedSearchResults, ); if (result && !endReached) { return [...result, { itemType: 'loader' }]; } return result; }, [chatMessageInfos, endReached, translatedSearchResults]); const [measuredMessages, setMeasuredMessages] = React.useState< $ReadOnlyArray, >([]); const measureMessages = useHeightMeasurer(); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [setMeasuredMessages], ); React.useEffect(() => { measureMessages(filteredChatMessageInfos, threadInfo, measureCallback); }, [filteredChatMessageInfos, measureCallback, measureMessages, threadInfo]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef>(); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const renderItem = React.useCallback( ({ item }: { +item: ChatMessageItemWithHeight, ... }) => { if (item.itemType === 'loader') { return ; } return ( ); }, [messageVerticalBounds, props.navigation, props.route, threadInfo], ); const footer = React.useMemo(() => { if (query === '') { return ; } if (!endReached) { return null; } if (measuredMessages.length > 0) { return ; } const text = 'No results. Please try using different keywords to refine your search'; return ; }, [query, endReached, measuredMessages.length]); const onEndOfLoadedMessagesReached = React.useCallback(() => { if (endReached) { return; } - setLastID(oldestMessageID(measuredMessages)); - }, [endReached, measuredMessages, setLastID]); + const oldest = oldestMessage(measuredMessages); + setLastID(oldest?.id); + setLastTimestamp(oldest?.time); + }, [endReached, measuredMessages]); const styles = useStyles(unboundStyles); return ( ); } -function oldestMessageID(data: $ReadOnlyArray) { +function oldestMessage(data: $ReadOnlyArray) { for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { - return data[i].messageInfo.id; + return data[i].messageInfo; } } return undefined; } const unboundStyles = { content: { height: '100%', backgroundColor: 'panelBackground', }, }; export default MessageSearch; diff --git a/web/search/message-search-state-provider.react.js b/web/search/message-search-state-provider.react.js index 3d983444e..bc0198a0f 100644 --- a/web/search/message-search-state-provider.react.js +++ b/web/search/message-search-state-provider.react.js @@ -1,207 +1,213 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useSearchMessages } from 'lib/shared/search-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; type MessageSearchState = { +getQuery: (threadID: string) => string, +setQuery: (query: string, threadID: string) => void, +clearQuery: (threadID: string) => void, +getSearchResults: (threadID: string) => $ReadOnlyArray, +appendSearchResult: ( $ReadOnlyArray, threadID: string, ) => void, +getEndReached: (threadID: string) => boolean, +setEndReached: (threadID: string) => void, +searchMessages: (threadID: string) => void, }; const MessageSearchContext: React.Context = React.createContext(null); type Props = { +children: React.Node, }; function MessageSearchStateProvider(props: Props): React.Node { const queries = React.useRef<{ [threadID: string]: string, }>({}); const [results, setResults] = React.useState<{ [threadID: string]: $ReadOnlyArray, }>({}); const endsReached = React.useRef(new Set()); const lastIDs = React.useRef<{ [threadID: string]: string, }>({}); + const lastTimestamps = React.useRef<{ + [threadID: string]: number, + }>({}); const setEndReached = React.useCallback((threadID: string) => { endsReached.current.add(threadID); }, []); const removeEndReached = React.useCallback( (threadID: string) => endsReached.current.delete(threadID), [], ); const getEndReached = React.useCallback( (threadID: string) => endsReached.current.has(threadID), [], ); const appendResult = React.useCallback( (result: $ReadOnlyArray, threadID: string) => { - const lastMessageID = oldestMessageID(result); - if (lastMessageID) { - lastIDs.current[threadID] = lastMessageID; + const lastMessage = oldestMessage(result); + if (lastMessage?.id) { + lastIDs.current[threadID] = lastMessage.id; + lastTimestamps.current[threadID] = lastMessage.time; } setResults(prevResults => { const prevThreadResults = prevResults[threadID] ?? []; const newThreadResults = [...prevThreadResults, ...result]; return { ...prevResults, [threadID]: newThreadResults }; }); }, [], ); const clearResults = React.useCallback( (threadID: string) => { loading.current = false; delete lastIDs.current[threadID]; + delete lastTimestamps.current[threadID]; removeEndReached(threadID); setResults(prevResults => { const { [threadID]: deleted, ...newState } = prevResults; return newState; }); }, [removeEndReached], ); const getResults = React.useCallback( (threadID: string) => results[threadID] ?? [], [results], ); const getQuery = React.useCallback( (threadID: string) => queries.current[threadID] ?? '', [], ); const setQuery = React.useCallback( (query: string, threadID: string) => { clearResults(threadID); queries.current[threadID] = query; }, [clearResults], ); const clearQuery = React.useCallback( (threadID: string) => { clearResults(threadID); delete queries.current[threadID]; }, [clearResults], ); const searchMessagesCall = useSearchMessages(); const loading = React.useRef(false); const queryIDRef = React.useRef(0); const appendResults = React.useCallback( ( newMessages: $ReadOnlyArray, end: boolean, queryID: number, threadID: string, ) => { if (queryID !== queryIDRef.current) { return; } appendResult(newMessages, threadID); if (end) { setEndReached(threadID); } loading.current = false; }, [appendResult, setEndReached], ); const searchMessages = React.useCallback( (threadID: string) => { if (loading.current || endsReached.current.has(threadID)) { return; } queryIDRef.current += 1; loading.current = true; const query = getQuery(threadID); searchMessagesCall( query, threadID, appendResults, queryIDRef.current, + lastTimestamps.current[threadID], lastIDs.current[threadID], ); }, [appendResults, getQuery, searchMessagesCall], ); const state = React.useMemo( () => ({ getQuery, setQuery, clearQuery, getSearchResults: getResults, appendSearchResult: appendResult, getEndReached, setEndReached, searchMessages, }), [ getQuery, setQuery, clearQuery, getResults, appendResult, getEndReached, setEndReached, searchMessages, ], ); return ( {props.children} ); } -function oldestMessageID(data: $ReadOnlyArray) { +function oldestMessage(data: $ReadOnlyArray) { if (!data) { return undefined; } for (let i = data.length - 1; i >= 0; i--) { if (data[i].type === messageTypes.TEXT) { - return data[i].id; + return data[i]; } } return undefined; } function useMessageSearchContext(): MessageSearchState { const context = React.useContext(MessageSearchContext); invariant(context, 'MessageSearchContext not found'); return context; } export { MessageSearchStateProvider, useMessageSearchContext };