diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 5719dfcca..698966d67 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -737,7 +737,12 @@ export class ConversationModel extends Backbone.Model { const chatMessagePrivate = new VisibleMessage(chatMessageParams); await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate); - await handleMessageReaction(reaction, UserUtils.getOurPubKeyStrFromCache(), true); + await handleMessageReaction({ + reaction, + sender: UserUtils.getOurPubKeyStrFromCache(), + you: true, + isOpenGroup: false, + }); return; } @@ -749,7 +754,12 @@ export class ConversationModel extends Backbone.Model { }); // we need the return await so that errors are caught in the catch {} await getMessageQueue().sendToGroup(closedGroupVisibleMessage); - await handleMessageReaction(reaction, UserUtils.getOurPubKeyStrFromCache(), true); + await handleMessageReaction({ + reaction, + sender: UserUtils.getOurPubKeyStrFromCache(), + you: true, + isOpenGroup: false, + }); return; } diff --git a/ts/receiver/dataMessage.ts b/ts/receiver/dataMessage.ts index 59c98d431..a207bc219 100644 --- a/ts/receiver/dataMessage.ts +++ b/ts/receiver/dataMessage.ts @@ -322,11 +322,12 @@ async function handleSwarmMessage( // this call has to be made inside the queueJob! // We handle reaction DataMessages separately if (!msgModel.get('isPublic') && rawDataMessage.reaction) { - await handleMessageReaction( - rawDataMessage.reaction, - msgModel.get('source'), - isUsFromCache(msgModel.get('source')) - ); + await handleMessageReaction({ + reaction: rawDataMessage.reaction, + sender: msgModel.get('source'), + you: isUsFromCache(msgModel.get('source')), + isOpenGroup: false, + }); if ( convoToAddMessageTo.isPrivate() && msgModel.get('unread') && diff --git a/ts/session/apis/open_group_api/sogsv3/sogsV3ClearReaction.ts b/ts/session/apis/open_group_api/sogsv3/sogsV3ClearReaction.ts index fefa9537d..03df53a74 100644 --- a/ts/session/apis/open_group_api/sogsv3/sogsV3ClearReaction.ts +++ b/ts/session/apis/open_group_api/sogsv3/sogsV3ClearReaction.ts @@ -1,5 +1,6 @@ import AbortController from 'abort-controller'; import { OpenGroupReactionResponse } from '../../../../types/Reaction'; +import { handleClearReaction } from '../../../../util/reactions'; import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil'; import { batchFirstSubIsSuccess, @@ -25,8 +26,13 @@ export const clearSogsReactionByServerId = async ( serverId: number, roomInfos: OpenGroupRequestCommonType ): Promise => { - const canReact = await hasReactionSupport(serverId); - if (!canReact) { + const { supported, conversation } = await hasReactionSupport(serverId); + if (!supported) { + return false; + } + + if (!conversation) { + window.log.warn(`Conversation for ${reaction} not found in db`); return false; } @@ -44,10 +50,17 @@ export const clearSogsReactionByServerId = async ( addToMutationCache(cacheEntry); + // Since responses can take a long time we immediately update the moderators's UI and if there is a problem it is overwritten by handleOpenGroupMessageReactions later. + await handleClearReaction(serverId, reaction); + const options: Array = [ { type: 'deleteReaction', - deleteReaction: { reaction, messageId: serverId, roomId: roomInfos.roomId }, + deleteReaction: { + reaction, + messageId: serverId, + roomId: roomInfos.roomId, + }, }, ]; const result = await sogsBatchSend( diff --git a/ts/session/apis/open_group_api/sogsv3/sogsV3SendReaction.ts b/ts/session/apis/open_group_api/sogsv3/sogsV3SendReaction.ts index f92980891..1e0a9a3b1 100644 --- a/ts/session/apis/open_group_api/sogsv3/sogsV3SendReaction.ts +++ b/ts/session/apis/open_group_api/sogsv3/sogsV3SendReaction.ts @@ -1,10 +1,13 @@ import { AbortSignal } from 'abort-controller'; import { Data } from '../../../../data/data'; +import { ConversationModel } from '../../../../models/conversation'; import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction'; import { getEmojiDataFromNative } from '../../../../util/emoji'; -import { hitRateLimit } from '../../../../util/reactions'; +import { handleMessageReaction, hitRateLimit } from '../../../../util/reactions'; import { OnionSending } from '../../../onions/onionSend'; +import { UserUtils } from '../../../utils'; import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils'; +import { getUsBlindedInThatServer } from './knownBlindedkeys'; import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll'; import { addToMutationCache, @@ -13,25 +16,27 @@ import { updateMutationCache, } from './sogsV3MutationCache'; -export const hasReactionSupport = async (serverId: number): Promise => { +export const hasReactionSupport = async ( + serverId: number +): Promise<{ supported: boolean; conversation: ConversationModel | null }> => { const found = await Data.getMessageByServerId(serverId); if (!found) { window.log.warn(`Open Group Message ${serverId} not found in db`); - return false; + return { supported: false, conversation: null }; } const conversationModel = found?.getConversation(); if (!conversationModel) { window.log.warn(`Conversation for ${serverId} not found in db`); - return false; + return { supported: false, conversation: null }; } if (!conversationModel.hasReactions()) { window.log.warn("This open group doesn't have reaction support. Server Message ID", serverId); - return false; + return { supported: false, conversation: null }; } - return true; + return { supported: true, conversation: conversationModel }; }; export const sendSogsReactionOnionV4 = async ( @@ -47,8 +52,8 @@ export const sendSogsReactionOnionV4 = async ( throw new Error(`Could not find sogs pubkey of url:${serverUrl}`); } - const canReact = await hasReactionSupport(reaction.id); - if (!canReact) { + const { supported, conversation } = await hasReactionSupport(reaction.id); + if (!supported) { return false; } @@ -56,6 +61,11 @@ export const sendSogsReactionOnionV4 = async ( return false; } + if (!conversation) { + window.log.warn(`Conversation for ${reaction.id} not found in db`); + return false; + } + // The SOGS endpoint supports any text input so we need to make sure we are sending a valid unicode emoji // for an invalid input we use https://emojipedia.org/frame-with-an-x/ as a replacement since it cannot rendered as an emoji but is valid unicode const emoji = getEmojiDataFromNative(reaction.emoji) ? reaction.emoji : '🖾'; @@ -77,6 +87,15 @@ export const sendSogsReactionOnionV4 = async ( addToMutationCache(cacheEntry); + // Since responses can take a long time we immediately update the sender's UI and if there is a problem it is overwritten by handleOpenGroupMessageReactions later. + const me = UserUtils.getOurPubKeyStrFromCache(); + await handleMessageReaction({ + reaction, + sender: blinded ? getUsBlindedInThatServer(conversation) || me : me, + you: true, + isOpenGroup: true, + }); + // reaction endpoint requires an empty dict {} const stringifiedBody = null; const result = await OnionSending.sendJsonViaOnionV4ToSogs({ diff --git a/ts/test/session/unit/reactions/ReactionMessage_test.ts b/ts/test/session/unit/reactions/ReactionMessage_test.ts index 63bfa3748..d7a7bf127 100644 --- a/ts/test/session/unit/reactions/ReactionMessage_test.ts +++ b/ts/test/session/unit/reactions/ReactionMessage_test.ts @@ -52,11 +52,12 @@ describe('ReactionMessage', () => { expect(reaction?.action, 'action should be 0').to.be.equal(0); // Handling reaction - const updatedMessage = await handleMessageReaction( - reaction as SignalService.DataMessage.IReaction, - ourNumber, - true - ); + const updatedMessage = await handleMessageReaction({ + reaction: reaction as SignalService.DataMessage.IReaction, + sender: ourNumber, + you: true, + isOpenGroup: false, + }); expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be .undefined; @@ -84,11 +85,12 @@ describe('ReactionMessage', () => { expect(reaction?.action, 'action should be 1').to.be.equal(1); // Handling reaction - const updatedMessage = await handleMessageReaction( - reaction as SignalService.DataMessage.IReaction, - ourNumber, - true - ); + const updatedMessage = await handleMessageReaction({ + reaction: reaction as SignalService.DataMessage.IReaction, + sender: ourNumber, + you: true, + isOpenGroup: false, + }); expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be .undefined; diff --git a/ts/util/reactions.ts b/ts/util/reactions.ts index 44ad176ea..9511c415d 100644 --- a/ts/util/reactions.ts +++ b/ts/util/reactions.ts @@ -37,23 +37,28 @@ export function hitRateLimit(): boolean { * Retrieves the original message of a reaction */ const getMessageByReaction = async ( - reaction: SignalService.DataMessage.IReaction + reaction: SignalService.DataMessage.IReaction, + isOpenGroup: boolean ): Promise => { let originalMessage = null; const originalMessageId = Number(reaction.id); const originalMessageAuthor = reaction.author; - const collection = await Data.getMessagesBySentAt(originalMessageId); - originalMessage = collection.find((item: MessageModel) => { - const messageTimestamp = item.get('sent_at'); - const author = item.get('source'); - return Boolean( - messageTimestamp && - messageTimestamp === originalMessageId && - author && - author === originalMessageAuthor - ); - }); + if (isOpenGroup) { + originalMessage = await Data.getMessageByServerId(originalMessageId); + } else { + const collection = await Data.getMessagesBySentAt(originalMessageId); + originalMessage = collection.find((item: MessageModel) => { + const messageTimestamp = item.get('sent_at'); + const author = item.get('source'); + return Boolean( + messageTimestamp && + messageTimestamp === originalMessageId && + author && + author === originalMessageAuthor + ); + }); + } if (!originalMessage) { window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`); @@ -140,19 +145,25 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => { /** * Handle reactions on the client by updating the state of the source message - * Do not use for Open Groups + * Used in OpenGroups for sending reactions only, not handling responses */ -export const handleMessageReaction = async ( - reaction: SignalService.DataMessage.IReaction, - sender: string, - you: boolean -) => { +export const handleMessageReaction = async ({ + reaction, + sender, + you, + isOpenGroup, +}: { + reaction: SignalService.DataMessage.IReaction; + sender: string; + you: boolean; + isOpenGroup: boolean; +}) => { if (!reaction.emoji) { window?.log?.warn(`There is no emoji for the reaction ${reaction}.`); return; } - const originalMessage = await getMessageByReaction(reaction); + const originalMessage = await getMessageByReaction(reaction, isOpenGroup); if (!originalMessage) { return; } @@ -163,20 +174,15 @@ export const handleMessageReaction = async ( const senders = details.senders; let count = details.count || 0; - if (originalMessage.get('isPublic')) { - window.log.warn("handleMessageReaction() shouldn't be used in opengroups"); - return; - } else { - if (details.you && senders.includes(sender)) { - if (reaction.action === Action.REACT) { - window.log.warn('Received duplicate message for your reaction. Ignoring it'); - return; - } else { - details.you = false; - } + if (details.you && senders.includes(sender)) { + if (reaction.action === Action.REACT) { + window.log.warn('Received duplicate message for your reaction. Ignoring it'); + return; } else { - details.you = you; + details.you = false; } + } else { + details.you = you; } switch (reaction.action) { @@ -230,7 +236,34 @@ export const handleMessageReaction = async ( }; /** - * Handles all message reaction updates for opengroups + * Handles updating the UI when clearing all reactions for a certain emoji + * Only usable by moderators in opengroups and runs on their client + */ +export const handleClearReaction = async (serverId: number, emoji: string) => { + const originalMessage = await Data.getMessageByServerId(serverId); + if (!originalMessage) { + window?.log?.warn(`Cannot find the original reacted message ${serverId}.`); + return; + } + + const reacts: ReactionList | undefined = originalMessage.get('reacts'); + if (reacts) { + // tslint:disable-next-line: no-dynamic-delete + delete reacts[emoji]; + } + + originalMessage.set({ + reacts: !isEmpty(reacts) ? reacts : undefined, + }); + + await originalMessage.commit(); + + window.log.info(`You cleared all ${emoji} reactions on message ${serverId}`); + return originalMessage; +}; + +/** + * Handles all message reaction updates/responses for opengroups */ export const handleOpenGroupMessageReactions = async ( reactions: OpenGroupReactionList,