diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js index 58d4db04b..0b1530852 100644 --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -1,262 +1,267 @@ // @flow import { baseLegalPolicies } from 'lib/facts/policies.js'; import type { Endpoint } from 'lib/types/endpoints.js'; import { updateActivityResponder, threadSetUnreadStatusResponder, } from './responders/activity-responders.js'; import { deviceTokenUpdateResponder } from './responders/device-responders.js'; import { entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, } from './responders/entry-responders.js'; import type { JSONResponder } from './responders/handlers.js'; import { getSessionPublicKeysResponder } from './responders/keys-responders.js'; import { messageReportCreationResponder } from './responders/message-report-responder.js'; import { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, + fetchPinnedMessagesResponder, } from './responders/message-responders.js'; import { updateRelationshipsResponder } from './responders/relationship-responders.js'; import { reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, } from './responders/report-responders.js'; import { userSearchResponder } from './responders/search-responders.js'; import { siweNonceResponder } from './responders/siwe-nonce-responders.js'; import { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadFetchMediaResponder, threadJoinResponder, toggleMessagePinResponder, } from './responders/thread-responders.js'; import { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, } from './responders/user-responders.js'; import { codeVerificationResponder } from './responders/verification-responders.js'; import { uploadDeletionResponder } from './uploads/uploads.js'; const jsonEndpoints: { [id: Endpoint]: JSONResponder } = { create_account: { responder: accountCreationResponder, requiredPolicies: [], }, create_entry: { responder: entryCreationResponder, requiredPolicies: baseLegalPolicies, }, create_error_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_message_report: { responder: messageReportCreationResponder, requiredPolicies: baseLegalPolicies, }, create_multimedia_message: { responder: multimediaMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_reaction_message: { responder: reactionMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, edit_message: { responder: editMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_reports: { responder: reportMultiCreationResponder, requiredPolicies: [], }, create_text_message: { responder: textMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_thread: { responder: threadCreationResponder, requiredPolicies: baseLegalPolicies, }, delete_account: { responder: accountDeletionResponder, requiredPolicies: [], }, delete_entry: { responder: entryDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_thread: { responder: threadDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_upload: { responder: uploadDeletionResponder, requiredPolicies: baseLegalPolicies, }, fetch_entries: { responder: entryFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_entry_revisions: { responder: entryRevisionFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_error_report_infos: { responder: errorReportFetchInfosResponder, requiredPolicies: baseLegalPolicies, }, fetch_messages: { responder: messageFetchResponder, requiredPolicies: baseLegalPolicies, }, + fetch_pinned_messages: { + responder: fetchPinnedMessagesResponder, + requiredPolicies: baseLegalPolicies, + }, fetch_thread_media: { responder: threadFetchMediaResponder, requiredPolicies: baseLegalPolicies, }, get_session_public_keys: { responder: getSessionPublicKeysResponder, requiredPolicies: baseLegalPolicies, }, join_thread: { responder: threadJoinResponder, requiredPolicies: baseLegalPolicies, }, leave_thread: { responder: threadLeaveResponder, requiredPolicies: baseLegalPolicies, }, log_in: { responder: logInResponder, requiredPolicies: [], }, log_out: { responder: logOutResponder, requiredPolicies: [], }, policy_acknowledgment: { responder: policyAcknowledgmentResponder, requiredPolicies: [], }, remove_members: { responder: memberRemovalResponder, requiredPolicies: baseLegalPolicies, }, restore_entry: { responder: entryRestorationResponder, requiredPolicies: baseLegalPolicies, }, search_users: { responder: userSearchResponder, requiredPolicies: baseLegalPolicies, }, send_password_reset_email: { responder: sendPasswordResetEmailResponder, requiredPolicies: [], }, send_verification_email: { responder: sendVerificationEmailResponder, requiredPolicies: [], }, set_thread_unread_status: { responder: threadSetUnreadStatusResponder, requiredPolicies: baseLegalPolicies, }, toggle_message_pin: { responder: toggleMessagePinResponder, requiredPolicies: baseLegalPolicies, }, update_account: { responder: passwordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_activity: { responder: updateActivityResponder, requiredPolicies: baseLegalPolicies, }, update_calendar_query: { responder: calendarQueryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_settings: { responder: updateUserSettingsResponder, requiredPolicies: baseLegalPolicies, }, update_device_token: { responder: deviceTokenUpdateResponder, requiredPolicies: [], }, update_entry: { responder: entryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_password: { responder: oldPasswordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_relationships: { responder: updateRelationshipsResponder, requiredPolicies: baseLegalPolicies, }, update_role: { responder: roleUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_thread: { responder: threadUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_subscription: { responder: userSubscriptionUpdateResponder, requiredPolicies: baseLegalPolicies, }, verify_code: { responder: codeVerificationResponder, requiredPolicies: baseLegalPolicies, }, siwe_nonce: { responder: siweNonceResponder, requiredPolicies: [], }, siwe_auth: { responder: siweAuthResponder, requiredPolicies: [], }, update_user_avatar: { responder: updateUserAvatarResponder, requiredPolicies: baseLegalPolicies, }, }; export { jsonEndpoints }; diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js index ccd5bc972..a57a582c9 100644 --- a/keyserver/src/responders/message-responders.js +++ b/keyserver/src/responders/message-responders.js @@ -1,391 +1,411 @@ // @flow import invariant from 'invariant'; import t 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, type SendTextMessageRequest, type SendMultimediaMessageRequest, type SendReactionMessageRequest, type SendEditMessageRequest, type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, type SendEditMessageResponse, + type FetchPinnedMessagesRequest, + type FetchPinnedMessagesResult, } 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-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { tRegex, tShape, tMediaMessageMedia, } from 'lib/utils/validation-utils.js'; import createMessages from '../creators/message-creator.js'; import { SQL } from '../database/database.js'; import { fetchMessageInfos, fetchMessageInfoForLocalID, fetchMessageInfoByID, fetchThreadMessagesCount, + fetchPinnedMessageInfos, } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { fetchImages, fetchMediaFromMediaMessageContent, } 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'; import { validateInput } from '../utils/validation-utils.js'; const sendTextMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.maybe(t.String), text: t.String, sidebarCreation: t.maybe(t.Boolean), }); async function textMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendTextMessageRequest = input; await validateInput(viewer, sendTextMessageRequestInputValidator, request); 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] }; } const fetchMessageInfosRequestInputValidator = tShape({ cursors: t.dict(t.String, t.maybe(t.String)), numberPerThread: t.maybe(t.Number), }); async function messageFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchMessageInfosRequest = input; await validateInput(viewer, fetchMessageInfosRequestInputValidator, request); const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); return { ...response, userInfos: {} }; } const sendMultimediaMessageRequestInputValidator = t.union([ // This option is only used for messageTypes.IMAGES tShape({ threadID: t.String, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaIDs: t.list(t.String), }), tShape({ threadID: t.String, localID: t.String, sidebarCreation: t.maybe(t.Boolean), mediaMessageContents: t.list(tMediaMessageMedia), }), ]); async function multimediaMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendMultimediaMessageRequest = input; await validateInput( viewer, sendMultimediaMessageRequestInputValidator, request, ); 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 ? fetchImages(viewer, request.mediaIDs) : fetchMediaFromMediaMessageContent(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 }; } const sendReactionMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.maybe(t.String), targetMessageID: t.String, reaction: tRegex(onlyOneEmojiRegex), action: t.enums.of(['add_reaction', 'remove_reaction']), }); async function reactionMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendReactionMessageRequest = input; await validateInput(viewer, sendReactionMessageRequestInputValidator, input); 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(SQL`t.id = ${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] }; } const editMessageRequestInputValidator = tShape({ targetMessageID: t.String, text: t.String, }); async function editMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendEditMessageRequest = input; await validateInput(viewer, editMessageRequestInputValidator, input); 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(SQL`t.id = ${threadID}`), checkThreadPermission(viewer, threadID, threadPermissions.EDIT_MESSAGE), fetchServerThreadInfos( SQL`t.parent_thread_id = ${threadID} AND t.source_message = ${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 }; } +const fetchPinnedMessagesResponderInputValidator = tShape({ + threadID: t.String, +}); +async function fetchPinnedMessagesResponder( + viewer: Viewer, + input: any, +): Promise { + const request: FetchPinnedMessagesRequest = input; + await validateInput( + viewer, + fetchPinnedMessagesResponderInputValidator, + input, + ); + return await fetchPinnedMessageInfos(viewer, request); +} + export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, editMessageCreationResponder, + fetchPinnedMessagesResponder, }; diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index 3d60c1f8c..8500e491d 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,287 +1,301 @@ // @flow import invariant from 'invariant'; import type { FetchMessageInfosPayload, SendMessageResult, SendEditMessageResult, SendReactionMessageRequest, SimpleMessagesPayload, SendEditMessageRequest, + FetchPinnedMessagesRequest, + FetchPinnedMessagesResult, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; import type { CallServerEndpoint, CallServerEndpointResultInfo, } from '../utils/call-server-endpoint.js'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); const fetchMessagesBeforeCursor = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, beforeMessageID: string, ) => Promise) => async (threadID, beforeMessageID) => { const response = await callServerEndpoint('fetch_messages', { cursors: { [threadID]: beforeMessageID, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; }; const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); const fetchMostRecentMessages = ( callServerEndpoint: CallServerEndpoint, ): ((threadID: string) => Promise) => async threadID => { const response = await callServerEndpoint('fetch_messages', { cursors: { [threadID]: null, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; }; 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 = ( callServerEndpoint: CallServerEndpoint, ): ((threadIDs: $ReadOnlyArray) => Promise) => async threadIDs => { const cursors = Object.fromEntries( threadIDs.map(threadID => [threadID, null]), ); const response = await callServerEndpoint('fetch_messages', { cursors, numberPerThread: 1, }); return { rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, }; }; const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); const sendTextMessage = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, localID: string, text: string, sidebarCreation?: boolean, ) => Promise) => async (threadID, localID, text, sidebarCreation) => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; let payload = { threadID, localID, text }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const response = await callServerEndpoint('create_text_message', payload, { getResultInfo, }); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callServerEndpoint resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; }; const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); const sendMultimediaMessage = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, localID: string, mediaMessageContents: $ReadOnlyArray, sidebarCreation?: boolean, ) => Promise) => async (threadID, localID, mediaMessageContents, sidebarCreation) => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; let payload = { threadID, localID, mediaMessageContents }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const response = await callServerEndpoint( 'create_multimedia_message', payload, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callServerEndpoint resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; }; const legacySendMultimediaMessage = ( callServerEndpoint: CallServerEndpoint, ): (( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, sidebarCreation?: boolean, ) => Promise) => async (threadID, localID, mediaIDs, sidebarCreation) => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; let payload = { threadID, localID, mediaIDs }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const response = await callServerEndpoint( 'create_multimedia_message', payload, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callServerEndpoint resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; }; const sendReactionMessageActionTypes = Object.freeze({ started: 'SEND_REACTION_MESSAGE_STARTED', success: 'SEND_REACTION_MESSAGE_SUCCESS', failed: 'SEND_REACTION_MESSAGE_FAILED', }); const sendReactionMessage = ( callServerEndpoint: CallServerEndpoint, ): ((request: SendReactionMessageRequest) => Promise) => async request => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; const response = await callServerEndpoint( 'create_reaction_message', { threadID: request.threadID, localID: request.localID, targetMessageID: request.targetMessageID, reaction: request.reaction, action: request.action, }, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before callServerEndpoint resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; }; const sendEditMessageActionTypes = Object.freeze({ started: 'SEND_EDIT_MESSAGE_STARTED', success: 'SEND_EDIT_MESSAGE_SUCCESS', failed: 'SEND_EDIT_MESSAGE_FAILED', }); const sendEditMessage = ( callServerEndpoint: CallServerEndpoint, ): ((request: SendEditMessageRequest) => Promise) => async request => { const response = await callServerEndpoint('edit_message', { targetMessageID: request.targetMessageID, text: request.text, }); return { newMessageInfos: response.newMessageInfos, }; }; const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; +const fetchPinnedMessages = + ( + callServerEndpoint: CallServerEndpoint, + ): (( + request: FetchPinnedMessagesRequest, + ) => Promise) => + async request => { + const response = await callServerEndpoint('fetch_pinned_messages', request); + return { pinnedMessages: response.pinnedMessages }; + }; + export { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessagesFromThreads, sendTextMessageActionTypes, sendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, legacySendMultimediaMessage, sendReactionMessageActionTypes, sendReactionMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, sendEditMessageActionTypes, sendEditMessage, + fetchPinnedMessages, }; diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js index ece588e7d..001d78586 100644 --- a/lib/types/endpoints.js +++ b/lib/types/endpoints.js @@ -1,118 +1,119 @@ // @flow export type APIRequest = { endpoint: Endpoint, input: Object, }; export type SocketAPIHandler = (request: APIRequest) => Promise; export type Endpoint = | HTTPOnlyEndpoint | SocketOnlyEndpoint | HTTPPreferredEndpoint | SocketPreferredEndpoint; // Endpoints that can cause session changes should occur over HTTP, since the // socket code does not currently support changing sessions. In the future they // could be made to work for native, but cookie changes on web require HTTP // since websockets aren't able to Set-Cookie. Note that technically any // endpoint can cause a sessionChange, and in that case the server will close // the socket with a specific error code, and the client will proceed via HTTP. const sessionChangingEndpoints = Object.freeze({ LOG_OUT: 'log_out', DELETE_ACCOUNT: 'delete_account', CREATE_ACCOUNT: 'create_account', LOG_IN: 'log_in', UPDATE_PASSWORD: 'update_password', POLICY_ACKNOWLEDGMENT: 'policy_acknowledgment', }); type SessionChangingEndpoint = $Values; // We do uploads over HTTP as well. This is because Websockets use TCP, which // guarantees ordering. That means that if we start an upload, any messages we // try to send the server after the upload starts will have to wait until the // upload ends. To avoid blocking other messages we upload using HTTP // multipart/form-data. const uploadEndpoints = Object.freeze({ UPLOAD_MULTIMEDIA: 'upload_multimedia', }); type UploadEndpoint = $Values; type HTTPOnlyEndpoint = SessionChangingEndpoint | UploadEndpoint; const socketOnlyEndpoints = Object.freeze({ UPDATE_ACTIVITY: 'update_activity', UPDATE_CALENDAR_QUERY: 'update_calendar_query', }); type SocketOnlyEndpoint = $Values; const socketPreferredEndpoints = Object.freeze({ CREATE_ENTRY: 'create_entry', CREATE_ERROR_REPORT: 'create_error_report', CREATE_MESSAGE_REPORT: 'create_message_report', CREATE_MULTIMEDIA_MESSAGE: 'create_multimedia_message', CREATE_REACTION_MESSAGE: 'create_reaction_message', EDIT_MESSAGE: 'edit_message', CREATE_TEXT_MESSAGE: 'create_text_message', CREATE_THREAD: 'create_thread', DELETE_ENTRY: 'delete_entry', DELETE_THREAD: 'delete_thread', DELETE_UPLOAD: 'delete_upload', FETCH_ENTRIES: 'fetch_entries', FETCH_ENTRY_REVISIONS: 'fetch_entry_revisions', FETCH_ERROR_REPORT_INFOS: 'fetch_error_report_infos', FETCH_MESSAGES: 'fetch_messages', + FETCH_PINNED_MESSAGES: 'fetch_pinned_messages', FETCH_THREAD_MEDIA: 'fetch_thread_media', GET_SESSION_PUBLIC_KEYS: 'get_session_public_keys', JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', REMOVE_MEMBERS: 'remove_members', REQUEST_ACCESS: 'request_access', RESTORE_ENTRY: 'restore_entry', SEARCH_USERS: 'search_users', SEND_PASSWORD_RESET_EMAIL: 'send_password_reset_email', SEND_VERIFICATION_EMAIL: 'send_verification_email', SET_THREAD_UNREAD_STATUS: 'set_thread_unread_status', TOGGLE_MESSAGE_PIN: 'toggle_message_pin', UPDATE_ACCOUNT: 'update_account', UPDATE_USER_SETTINGS: 'update_user_settings', UPDATE_DEVICE_TOKEN: 'update_device_token', UPDATE_ENTRY: 'update_entry', UPDATE_RELATIONSHIPS: 'update_relationships', UPDATE_ROLE: 'update_role', UPDATE_THREAD: 'update_thread', UPDATE_USER_SUBSCRIPTION: 'update_user_subscription', VERIFY_CODE: 'verify_code', SIWE_NONCE: 'siwe_nonce', SIWE_AUTH: 'siwe_auth', UPDATE_USER_AVATAR: 'update_user_avatar', }); type SocketPreferredEndpoint = $Values; const httpPreferredEndpoints = Object.freeze({ CREATE_REPORT: 'create_report', CREATE_REPORTS: 'create_reports', }); type HTTPPreferredEndpoint = $Values; const socketPreferredEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ]); export function endpointIsSocketPreferred(endpoint: Endpoint): boolean { return socketPreferredEndpointSet.has(endpoint); } const socketSafeEndpointSet = new Set([ ...Object.values(socketOnlyEndpoints), ...Object.values(socketPreferredEndpoints), ...Object.values(httpPreferredEndpoints), ]); export function endpointIsSocketSafe(endpoint: Endpoint): boolean { return socketSafeEndpointSet.has(endpoint); } const socketOnlyEndpointSet = new Set(Object.values(socketOnlyEndpoints)); export function endpointIsSocketOnly(endpoint: Endpoint): boolean { return socketOnlyEndpointSet.has(endpoint); }