diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js index 7301c8b46..6415b777b 100644 --- a/keyserver/src/responders/message-responders.js +++ b/keyserver/src/responders/message-responders.js @@ -1,290 +1,294 @@ // @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 FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, } from 'lib/types/message-types.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 { 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, } from '../fetchers/message-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { fetchMedia, fetchMediaFromMediaMessageContent, } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; import { assignMedia, 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([ 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 ? fetchMedia(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'); } const messageData = createMediaMessageData({ localID, threadID, creatorID: viewer.id, media, sidebarCreation, }); 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 assignMedia(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.VOICED), + 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 { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, }; diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js index fd4becb73..450f92cfd 100644 --- a/lib/shared/reaction-utils.js +++ b/lib/shared/reaction-utils.js @@ -1,107 +1,107 @@ // @flow import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { threadHasPermission } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import { threadPermissions, type ThreadInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function stringForReactionList(reactions: ReactionInfo): string { const reactionText = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionText.push(reaction); const { length: numberOfReacts } = reactionInfo.users; if (numberOfReacts <= 1) { continue; } reactionText.push(numberOfReacts > 9 ? '9+' : numberOfReacts.toString()); } return reactionText.join(' '); } type MessageReactionListInfo = { +id: string, +isViewer: boolean, +reaction: string, +username: string, }; function useMessageReactionsList( reactions: ReactionInfo, ): $ReadOnlyArray { const withoutENSNames = React.useMemo(() => { const result = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionInfo.users.forEach(user => { result.push({ ...user, username: stringForUserExplicit(user), reaction, }); }); } const sortByNumReactions = (reactionInfo: MessageReactionListInfo) => { const numOfReactions = reactions[reactionInfo.reaction].users.length; return numOfReactions ? -numOfReactions : 0; }; return _sortBy( ([sortByNumReactions, 'username']: $ReadOnlyArray< ((reactionInfo: MessageReactionListInfo) => mixed) | string, >), )(result); }, [reactions]); return useENSNames(withoutENSNames); } function useCanCreateReactionFromMessage( threadInfo: ThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const targetMessageCreatorRelationship = useSelector( state => state.userStore.userInfos[targetMessageInfo.creator.id] ?.relationshipStatus, ); if ( !targetMessageInfo.id || threadInfo.sourceMessageID === targetMessageInfo.id ) { return false; } const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, - threadPermissions.VOICED, + threadPermissions.REACT_TO_MESSAGE, ); return hasPermission && !creatorRelationshipHasBlock; } export { stringForReactionList, useMessageReactionsList, useCanCreateReactionFromMessage, };