Merge pull request #2454 from yougotwill/sogs_poller_conflict_resolver

Opengroup Poller Reaction Conflict Resolution + Fixes
pull/2460/head
Audric Ackermann 3 years ago committed by GitHub
commit 25f54a13ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -463,6 +463,6 @@
"reactionPopupTwo": "$name$ & $name2$", "reactionPopupTwo": "$name$ & $name2$",
"reactionPopupThree": "$name$, $name2$ & $name3$", "reactionPopupThree": "$name$, $name2$ & $name3$",
"reactionPopupMany": "$name$, $name2$, $name3$ &", "reactionPopupMany": "$name$, $name2$, $name3$ &",
"reactionListCountSingular": "And $otherSingular$ has reacted to this message", "reactionListCountSingular": "And $otherSingular$ has reacted <span>$emoji$</span> to this message",
"reactionListCountPlural": "And $otherPlural$ have reacted to this message" "reactionListCountPlural": "And $otherPlural$ have reacted <span>$emoji$</span> to this message"
} }

@ -10,7 +10,7 @@ import {
getMessageContentWithStatusesSelectorProps, getMessageContentWithStatusesSelectorProps,
isMessageSelectionMode, isMessageSelectionMode,
} from '../../../../state/selectors/conversations'; } from '../../../../state/selectors/conversations';
import { sendMessageReaction } from '../../../../util/reactions'; import { Reactions } from '../../../../util/reactions';
import { MessageAuthorText } from './MessageAuthorText'; import { MessageAuthorText } from './MessageAuthorText';
import { MessageContent } from './MessageContent'; import { MessageContent } from './MessageContent';
@ -93,7 +93,7 @@ export const MessageContentWithStatuses = (props: Props) => {
const [popupReaction, setPopupReaction] = useState(''); const [popupReaction, setPopupReaction] = useState('');
const handleMessageReaction = async (emoji: string) => { const handleMessageReaction = async (emoji: string) => {
await sendMessageReaction(messageId, emoji); await Reactions.sendMessageReaction(messageId, emoji);
}; };
const handlePopupClick = () => { const handlePopupClick = () => {

@ -25,7 +25,7 @@ import {
import { StateType } from '../../../../state/reducer'; import { StateType } from '../../../../state/reducer';
import { getMessageContextMenuProps } from '../../../../state/selectors/conversations'; import { getMessageContextMenuProps } from '../../../../state/selectors/conversations';
import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil'; import { saveAttachmentToDisk } from '../../../../util/attachmentsUtil';
import { sendMessageReaction } from '../../../../util/reactions'; import { Reactions } from '../../../../util/reactions';
import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel'; import { SessionEmojiPanel, StyledEmojiPanel } from '../../SessionEmojiPanel';
import { MessageReactBar } from './MessageReactBar'; import { MessageReactBar } from './MessageReactBar';
@ -241,7 +241,7 @@ export const MessageContextMenu = (props: Props) => {
const onEmojiClick = async (args: any) => { const onEmojiClick = async (args: any) => {
const emoji = args.native ?? args; const emoji = args.native ?? args;
onCloseEmoji(); onCloseEmoji();
await sendMessageReaction(messageId, emoji); await Reactions.sendMessageReaction(messageId, emoji);
}; };
const onEmojiKeyDown = (event: any) => { const onEmojiKeyDown = (event: any) => {

@ -3,9 +3,8 @@ import React, { ReactElement, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { Data } from '../../data/data'; import { Data } from '../../data/data';
import { useMessageReactsPropsById } from '../../hooks/useParamSelector'; import { useMessageReactsPropsById, useWeAreModerator } from '../../hooks/useParamSelector';
import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { getConversationController } from '../../session/conversations';
import { UserUtils } from '../../session/utils'; import { UserUtils } from '../../session/utils';
import { import {
updateReactClearAllModal, updateReactClearAllModal,
@ -14,9 +13,10 @@ import {
} from '../../state/ducks/modalDialog'; } from '../../state/ducks/modalDialog';
import { SortedReactionList } from '../../types/Reaction'; import { SortedReactionList } from '../../types/Reaction';
import { nativeEmojiData } from '../../util/emoji'; import { nativeEmojiData } from '../../util/emoji';
import { sendMessageReaction, SOGSReactorsFetchCount } from '../../util/reactions'; import { Reactions } from '../../util/reactions';
import { Avatar, AvatarSize } from '../avatar/Avatar'; import { Avatar, AvatarSize } from '../avatar/Avatar';
import { Flex } from '../basic/Flex'; import { Flex } from '../basic/Flex';
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
import { ContactName } from '../conversation/ContactName'; import { ContactName } from '../conversation/ContactName';
import { MessageReactions } from '../conversation/message/message-content/MessageReactions'; import { MessageReactions } from '../conversation/message/message-content/MessageReactions';
import { SessionIconButton } from '../icon'; import { SessionIconButton } from '../icon';
@ -110,7 +110,7 @@ const ReactionSenders = (props: ReactionSendersProps) => {
}; };
const handleRemoveReaction = async () => { const handleRemoveReaction = async () => {
await sendMessageReaction(messageId, currentReact); await Reactions.sendMessageReaction(messageId, currentReact);
if (senders.length <= 1) { if (senders.length <= 1) {
dispatch(updateReactListModal(null)); dispatch(updateReactListModal(null));
@ -163,18 +163,28 @@ const StyledCountText = styled.p`
color: var(--color-text-subtle); color: var(--color-text-subtle);
text-align: center; text-align: center;
margin: 16px auto 0; margin: 16px auto 0;
span {
color: var(--color-text);
}
`; `;
const CountText = ({ count }: { count: number }) => { const CountText = ({ count, emoji }: { count: number; emoji: string }) => {
return ( return (
<StyledCountText> <StyledCountText>
{count > SOGSReactorsFetchCount + 1 <SessionHtmlRenderer
html={
count > Reactions.SOGSReactorsFetchCount + 1
? window.i18n('reactionListCountPlural', [ ? window.i18n('reactionListCountPlural', [
window.i18n('otherPlural', [String(count - SOGSReactorsFetchCount)]), window.i18n('otherPlural', [String(count - Reactions.SOGSReactorsFetchCount)]),
emoji,
]) ])
: window.i18n('reactionListCountSingular', [ : window.i18n('reactionListCountSingular', [
window.i18n('otherSingular', [String(count - SOGSReactorsFetchCount)]), window.i18n('otherSingular', [String(count - Reactions.SOGSReactorsFetchCount)]),
])} emoji,
])
}
/>
</StyledCountText> </StyledCountText>
); );
}; };
@ -206,15 +216,17 @@ const handleSenders = (senders: Array<string>, me: string) => {
export const ReactListModal = (props: Props): ReactElement => { export const ReactListModal = (props: Props): ReactElement => {
const { reaction, messageId } = props; const { reaction, messageId } = props;
const dispatch = useDispatch();
const [reactions, setReactions] = useState<SortedReactionList>([]); const [reactions, setReactions] = useState<SortedReactionList>([]);
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {}; const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
const [currentReact, setCurrentReact] = useState(''); const [currentReact, setCurrentReact] = useState('');
const [reactAriaLabel, setReactAriaLabel] = useState<string | undefined>(); const [reactAriaLabel, setReactAriaLabel] = useState<string | undefined>();
const [count, setCount] = useState<number | null>(null); const [count, setCount] = useState<number | null>(null);
const [senders, setSenders] = useState<Array<string>>([]); const [senders, setSenders] = useState<Array<string>>([]);
const me = UserUtils.getOurPubKeyStrFromCache();
const msgProps = useMessageReactsPropsById(messageId); const msgProps = useMessageReactsPropsById(messageId);
const weAreModerator = useWeAreModerator(msgProps?.convoId);
const me = UserUtils.getOurPubKeyStrFromCache();
// tslint:disable: cyclomatic-complexity // tslint:disable: cyclomatic-complexity
useEffect(() => { useEffect(() => {
@ -271,12 +283,7 @@ export const ReactListModal = (props: Props): ReactElement => {
return <></>; return <></>;
} }
const dispatch = useDispatch(); const { isPublic } = msgProps;
const { convoId, isPublic } = msgProps;
const convo = getConversationController().get(convoId);
const weAreModerator = convo.getConversationModelProps().weAreModerator;
const handleSelectedReaction = (emoji: string): boolean => { const handleSelectedReaction = (emoji: string): boolean => {
return currentReact === emoji; return currentReact === emoji;
@ -355,7 +362,9 @@ export const ReactListModal = (props: Props): ReactElement => {
handleClose={handleClose} handleClose={handleClose}
/> />
)} )}
{isPublic && count && count > SOGSReactorsFetchCount && <CountText count={count} />} {isPublic && currentReact && count && count > Reactions.SOGSReactorsFetchCount && (
<CountText count={count} emoji={currentReact} />
)}
</StyledSendersContainer> </StyledSendersContainer>
)} )}
</StyledReactListContainer> </StyledReactListContainer>

@ -93,7 +93,7 @@ import {
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile'; import { sogsV3FetchPreviewAndSaveIt } from '../session/apis/open_group_api/sogsv3/sogsV3FetchFile';
import { Reaction } from '../types/Reaction'; import { Reaction } from '../types/Reaction';
import { handleMessageReaction } from '../util/reactions'; import { Reactions } from '../util/reactions';
export class ConversationModel extends Backbone.Model<ConversationAttributes> { export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public updateLastMessage: () => any; public updateLastMessage: () => any;
@ -193,7 +193,8 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
} }
if (this.isPublic()) { if (this.isPublic()) {
return `opengroup(${this.id})`; const opengroup = this.toOpenGroupV2();
return `${opengroup.serverUrl}/${opengroup.roomId}`;
} }
return `group(${ed25519Str(this.id)})`; return `group(${ed25519Str(this.id)})`;
@ -737,7 +738,12 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const chatMessagePrivate = new VisibleMessage(chatMessageParams); const chatMessagePrivate = new VisibleMessage(chatMessageParams);
await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate); await getMessageQueue().sendToPubKey(destinationPubkey, chatMessagePrivate);
await handleMessageReaction(reaction, UserUtils.getOurPubKeyStrFromCache(), true); await Reactions.handleMessageReaction({
reaction,
sender: UserUtils.getOurPubKeyStrFromCache(),
you: true,
isOpenGroup: false,
});
return; return;
} }
@ -749,7 +755,12 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}); });
// we need the return await so that errors are caught in the catch {} // we need the return await so that errors are caught in the catch {}
await getMessageQueue().sendToGroup(closedGroupVisibleMessage); await getMessageQueue().sendToGroup(closedGroupVisibleMessage);
await handleMessageReaction(reaction, UserUtils.getOurPubKeyStrFromCache(), true); await Reactions.handleMessageReaction({
reaction,
sender: UserUtils.getOurPubKeyStrFromCache(),
you: true,
isOpenGroup: false,
});
return; return;
} }

@ -21,7 +21,8 @@ import { isUsFromCache } from '../session/utils/User';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates'; import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
import { toLogFormat } from '../types/attachments/Errors'; import { toLogFormat } from '../types/attachments/Errors';
import { ConversationTypeEnum } from '../models/conversationAttributes'; import { ConversationTypeEnum } from '../models/conversationAttributes';
import { handleMessageReaction } from '../util/reactions'; import { Reactions } from '../util/reactions';
import { Action, Reaction } from '../types/Reaction';
function cleanAttachment(attachment: any) { function cleanAttachment(attachment: any) {
return { return {
@ -321,11 +322,21 @@ async function handleSwarmMessage(
// this call has to be made inside the queueJob! // this call has to be made inside the queueJob!
// We handle reaction DataMessages separately // We handle reaction DataMessages separately
if (!msgModel.get('isPublic') && rawDataMessage.reaction) { if (!msgModel.get('isPublic') && rawDataMessage.reaction) {
await handleMessageReaction( await Reactions.handleMessageReaction({
rawDataMessage.reaction, reaction: rawDataMessage.reaction,
msgModel.get('source'), sender: msgModel.get('source'),
isUsFromCache(msgModel.get('source')) you: isUsFromCache(msgModel.get('source')),
); isOpenGroup: false,
});
if (
convoToAddMessageTo.isPrivate() &&
msgModel.get('unread') &&
rawDataMessage.reaction.action === Action.REACT
) {
msgModel.set('reaction', rawDataMessage.reaction as Reaction);
convoToAddMessageTo.throttledNotify(msgModel);
}
confirm(); confirm();
return; return;
} }

@ -16,7 +16,6 @@ import { GoogleChrome } from '../util';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates'; import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
import { ConversationTypeEnum } from '../models/conversationAttributes'; import { ConversationTypeEnum } from '../models/conversationAttributes';
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { Action, Reaction } from '../types/Reaction';
function contentTypeSupported(type: string): boolean { function contentTypeSupported(type: string): boolean {
const Chrome = GoogleChrome; const Chrome = GoogleChrome;
@ -339,18 +338,6 @@ export async function handleMessageJob(
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}` ) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
); );
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
if (
regularDataMessage.reaction.action === Action.REACT &&
conversation.isPrivate() &&
messageModel.get('unread')
) {
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
conversation.throttledNotify(messageModel);
}
confirm?.();
} else {
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait( const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
source, source,
ConversationTypeEnum.PRIVATE ConversationTypeEnum.PRIVATE
@ -450,5 +437,4 @@ export async function handleMessageJob(
const errorForLog = error && error.stack ? error.stack : error; const errorForLog = error && error.stack ? error.stack : error;
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog); window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
} }
}
} }

@ -35,6 +35,11 @@ export type OpenGroupMessageV4 = {
reactions: Record<string, OpenGroupReaction>; reactions: Record<string, OpenGroupReaction>;
}; };
// seqno is not set for SOGS < 1.3.4
export type OpenGroupReactionMessageV4 = Omit<OpenGroupMessageV4, 'seqno'> & {
seqno: number | undefined;
};
const pollForEverythingInterval = DURATION.SECONDS * 10; const pollForEverythingInterval = DURATION.SECONDS * 10;
export const invalidAuthRequiresBlinding = export const invalidAuthRequiresBlinding =

@ -34,7 +34,7 @@ import { handleOutboxMessageModel } from '../../../../receiver/dataMessage';
import { ConversationTypeEnum } from '../../../../models/conversationAttributes'; import { ConversationTypeEnum } from '../../../../models/conversationAttributes';
import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory'; import { createSwarmMessageSentFromUs } from '../../../../models/messageFactory';
import { Data } from '../../../../data/data'; import { Data } from '../../../../data/data';
import { handleOpenGroupMessageReactions } from '../../../../util/reactions'; import { processMessagesUsingCache } from './sogsV3MutationCache';
/** /**
* Get the convo matching those criteria and make sure it is an opengroup convo, or return null. * Get the convo matching those criteria and make sure it is an opengroup convo, or return null.
@ -312,7 +312,7 @@ const handleMessagesResponseV4 = async (
if (groupConvo && groupConvo.isOpenGroupV2()) { if (groupConvo && groupConvo.isOpenGroupV2()) {
for (const message of messagesWithReactions) { for (const message of messagesWithReactions) {
void groupConvo.queueJob(async () => { void groupConvo.queueJob(async () => {
await handleOpenGroupMessageReactions(message.reactions, message.id); await processMessagesUsingCache(serverUrl, roomId, message);
}); });
} }
} }

@ -8,7 +8,7 @@ import {
import { addJsonContentTypeToHeaders } from './sogsV3SendMessage'; import { addJsonContentTypeToHeaders } from './sogsV3SendMessage';
import { AbortSignal } from 'abort-controller'; import { AbortSignal } from 'abort-controller';
import { roomHasBlindEnabled } from './sogsV3Capabilities'; import { roomHasBlindEnabled } from './sogsV3Capabilities';
import { SOGSReactorsFetchCount } from '../../../../util/reactions'; import { Reactions } from '../../../../util/reactions';
type BatchFetchRequestOptions = { type BatchFetchRequestOptions = {
method: 'POST' | 'PUT' | 'GET' | 'DELETE'; method: 'POST' | 'PUT' | 'GET' | 'DELETE';
@ -240,8 +240,8 @@ const makeBatchRequestPayload = (
return { return {
method: 'GET', method: 'GET',
path: isNumber(options.messages.sinceSeqNo) path: isNumber(options.messages.sinceSeqNo)
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r&reactors=${SOGSReactorsFetchCount}` ? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r&reactors=${Reactions.SOGSReactorsFetchCount}`
: `/room/${options.messages.roomId}/messages/recent?reactors=${SOGSReactorsFetchCount}`, : `/room/${options.messages.roomId}/messages/recent?reactors=${Reactions.SOGSReactorsFetchCount}`,
}; };
} }
break; break;

@ -1,4 +1,6 @@
import AbortController from 'abort-controller'; import AbortController from 'abort-controller';
import { OpenGroupReactionResponse } from '../../../../types/Reaction';
import { Reactions } from '../../../../util/reactions';
import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil'; import { OpenGroupRequestCommonType } from '../opengroupV2/ApiUtil';
import { import {
batchFirstSubIsSuccess, batchFirstSubIsSuccess,
@ -6,6 +8,12 @@ import {
OpenGroupBatchRow, OpenGroupBatchRow,
sogsBatchSend, sogsBatchSend,
} from './sogsV3BatchPoll'; } from './sogsV3BatchPoll';
import {
addToMutationCache,
ChangeType,
SogsV3Mutation,
updateMutationCache,
} from './sogsV3MutationCache';
import { hasReactionSupport } from './sogsV3SendReaction'; import { hasReactionSupport } from './sogsV3SendReaction';
/** /**
@ -18,15 +26,41 @@ export const clearSogsReactionByServerId = async (
serverId: number, serverId: number,
roomInfos: OpenGroupRequestCommonType roomInfos: OpenGroupRequestCommonType
): Promise<boolean> => { ): Promise<boolean> => {
const canReact = await hasReactionSupport(serverId); const { supported, conversation } = await hasReactionSupport(serverId);
if (!canReact) { if (!supported) {
return false;
}
if (!conversation) {
window.log.warn(`Conversation for ${reaction} not found in db`);
return false; return false;
} }
const cacheEntry: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: null,
metadata: {
messageId: serverId,
emoji: reaction,
action: 'CLEAR',
},
};
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 Reactions.handleClearReaction(serverId, reaction);
const options: Array<OpenGroupBatchRow> = [ const options: Array<OpenGroupBatchRow> = [
{ {
type: 'deleteReaction', type: 'deleteReaction',
deleteReaction: { reaction, messageId: serverId, roomId: roomInfos.roomId }, deleteReaction: {
reaction,
messageId: serverId,
roomId: roomInfos.roomId,
},
}, },
]; ];
const result = await sogsBatchSend( const result = await sogsBatchSend(
@ -37,8 +71,22 @@ export const clearSogsReactionByServerId = async (
'batch' 'batch'
); );
if (!result) {
throw new Error('Could not deleteReaction, res is invalid');
}
const rawMessage = (result.body && (result.body[0].body as OpenGroupReactionResponse)) || null;
if (!rawMessage) {
throw new Error('deleteReaction parsing failed');
}
try { try {
return batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result); if (batchGlobalIsSuccess(result) && batchFirstSubIsSuccess(result)) {
updateMutationCache(cacheEntry, rawMessage.seqno);
return true;
} else {
return false;
}
} catch (e) { } catch (e) {
window?.log?.error("clearSogsReactionByServerId Can't decode JSON body"); window?.log?.error("clearSogsReactionByServerId Can't decode JSON body");
} }

@ -0,0 +1,135 @@
/**
* This is strictly use to resolve conflicts between local state and the opengroup poll updates
* Currently only supports message reactions 26/08/2022
*/
import { filter, findIndex, remove } from 'lodash';
import { Reactions } from '../../../../util/reactions';
import { OpenGroupReactionMessageV4 } from '../opengroupV2/OpenGroupServerPoller';
export enum ChangeType {
REACTIONS = 0,
}
type ReactionAction = 'ADD' | 'REMOVE' | 'CLEAR';
type ReactionChange = {
messageId: number; // will be serverId of the reacted message
emoji: string;
action: ReactionAction;
};
export type SogsV3Mutation = {
seqno: number | null; // null until mutating API request returns
server: string; // serverUrl
room: string; // roomId
changeType: ChangeType;
metadata: ReactionChange; // For now we only support message reactions
};
// we don't want to export this, we want to export functions that manipulate it
const sogsMutationCache: Array<SogsV3Mutation> = [];
// for testing purposes only
export function getMutationCache() {
return sogsMutationCache;
}
function verifyEntry(entry: SogsV3Mutation): boolean {
return Boolean(
entry.server &&
entry.room &&
entry.changeType === ChangeType.REACTIONS &&
entry.metadata.messageId &&
entry.metadata.emoji &&
(entry.metadata.action === 'ADD' ||
entry.metadata.action === 'REMOVE' ||
entry.metadata.action === 'CLEAR')
);
}
export function addToMutationCache(entry: SogsV3Mutation) {
if (!verifyEntry(entry)) {
window.log.error('SOGS Mutation Cache: Entry verification on add failed!', entry);
} else {
sogsMutationCache.push(entry);
window.log.info('SOGS Mutation Cache: Entry added!', entry);
}
}
export function updateMutationCache(entry: SogsV3Mutation, seqno: number) {
if (!verifyEntry(entry)) {
window.log.error('SOGS Mutation Cache: Entry verification on update failed!', entry);
} else {
const entryIndex = findIndex(sogsMutationCache, entry);
if (entryIndex >= 0) {
sogsMutationCache[entryIndex].seqno = seqno;
window.log.info('SOGS Mutation Cache: Entry updated!', sogsMutationCache[entryIndex]);
} else {
window.log.error('SOGS Mutation Cache: Updated failed! Cannot find entry', entry);
}
}
}
// return is for testing purposes only
export async function processMessagesUsingCache(
server: string,
room: string,
message: OpenGroupReactionMessageV4
): Promise<OpenGroupReactionMessageV4> {
const updatedReactions = message.reactions;
const roomMatches: Array<SogsV3Mutation> = filter(sogsMutationCache, { server, room });
for (let i = 0; i < roomMatches.length; i++) {
const matchSeqno = roomMatches[i].seqno;
if (message.seqno && matchSeqno && matchSeqno <= message.seqno) {
const removedEntry = roomMatches.splice(i, 1)[0];
window.log.info('SOGS Mutation Cache: Entry ignored and removed!', removedEntry);
remove(sogsMutationCache, removedEntry);
}
}
for (const reaction of Object.keys(message.reactions)) {
const reactionMatches = filter(roomMatches, {
server,
room,
changeType: ChangeType.REACTIONS,
metadata: {
messageId: message.id,
emoji: reaction,
},
});
for (const reactionMatch of reactionMatches) {
switch (reactionMatch.metadata.action) {
case 'ADD':
updatedReactions[reaction].you = true;
updatedReactions[reaction].count += 1;
window.log.info(
'SOGS Mutation Cache: Added our reaction based on the cache',
updatedReactions[reaction]
);
break;
case 'REMOVE':
updatedReactions[reaction].you = false;
updatedReactions[reaction].count -= 1;
window.log.info(
'SOGS Mutation Cache: Removed our reaction based on the cache',
updatedReactions[reaction]
);
break;
default:
window.log.warn(
'SOGS Mutation Cache: Unsupported metadata action in OpenGroupMessageV4',
reactionMatch
);
}
const removedEntry = remove(sogsMutationCache, reactionMatch);
window.log.info('SOGS Mutation Cache: Entry removed!', removedEntry);
}
}
message.reactions = updatedReactions;
await Reactions.handleOpenGroupMessageReactions(message.reactions, message.id);
return message;
}

@ -1,36 +1,47 @@
import { AbortSignal } from 'abort-controller'; import { AbortSignal } from 'abort-controller';
import { Data } from '../../../../data/data'; import { Data } from '../../../../data/data';
import { ConversationModel } from '../../../../models/conversation';
import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction'; import { Action, OpenGroupReactionResponse, Reaction } from '../../../../types/Reaction';
import { getEmojiDataFromNative } from '../../../../util/emoji'; import { getEmojiDataFromNative } from '../../../../util/emoji';
import { hitRateLimit } from '../../../../util/reactions'; import { Reactions } from '../../../../util/reactions';
import { OnionSending } from '../../../onions/onionSend'; import { OnionSending } from '../../../onions/onionSend';
import { UserUtils } from '../../../utils';
import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils'; import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils';
import { getUsBlindedInThatServer } from './knownBlindedkeys';
import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll'; import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll';
import {
addToMutationCache,
ChangeType,
SogsV3Mutation,
updateMutationCache,
} from './sogsV3MutationCache';
export const hasReactionSupport = async (serverId: number): Promise<boolean> => { export const hasReactionSupport = async (
serverId: number
): Promise<{ supported: boolean; conversation: ConversationModel | null }> => {
const found = await Data.getMessageByServerId(serverId); const found = await Data.getMessageByServerId(serverId);
if (!found) { if (!found) {
window.log.warn(`Open Group Message ${serverId} not found in db`); window.log.warn(`Open Group Message ${serverId} not found in db`);
return false; return { supported: false, conversation: null };
} }
const conversationModel = found?.getConversation(); const conversationModel = found?.getConversation();
if (!conversationModel) { if (!conversationModel) {
window.log.warn(`Conversation for ${serverId} not found in db`); window.log.warn(`Conversation for ${serverId} not found in db`);
return false; return { supported: false, conversation: null };
} }
if (!conversationModel.hasReactions()) { if (!conversationModel.hasReactions()) {
window.log.warn("This open group doesn't have reaction support. Server Message ID", serverId); 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 ( export const sendSogsReactionOnionV4 = async (
serverUrl: string, serverUrl: string,
room: string, room: string, // this is the roomId
abortSignal: AbortSignal, abortSignal: AbortSignal,
reaction: Reaction, reaction: Reaction,
blinded: boolean blinded: boolean
@ -41,12 +52,17 @@ export const sendSogsReactionOnionV4 = async (
throw new Error(`Could not find sogs pubkey of url:${serverUrl}`); throw new Error(`Could not find sogs pubkey of url:${serverUrl}`);
} }
const canReact = await hasReactionSupport(reaction.id); const { supported, conversation } = await hasReactionSupport(reaction.id);
if (!canReact) { if (!supported) {
return false; return false;
} }
if (hitRateLimit()) { if (Reactions.hitRateLimit()) {
return false;
}
if (!conversation) {
window.log.warn(`Conversation for ${reaction.id} not found in db`);
return false; return false;
} }
@ -57,6 +73,29 @@ export const sendSogsReactionOnionV4 = async (
const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE'; const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE';
const serverPubkey = allValidRoomInfos[0].serverPublicKey; const serverPubkey = allValidRoomInfos[0].serverPublicKey;
const cacheEntry: SogsV3Mutation = {
server: serverUrl,
room: room,
changeType: ChangeType.REACTIONS,
seqno: null,
metadata: {
messageId: reaction.id,
emoji,
action: reaction.action === Action.REACT ? 'ADD' : 'REMOVE',
},
};
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 Reactions.handleMessageReaction({
reaction,
sender: blinded ? getUsBlindedInThatServer(conversation) || me : me,
you: true,
isOpenGroup: true,
});
// reaction endpoint requires an empty dict {} // reaction endpoint requires an empty dict {}
const stringifiedBody = null; const stringifiedBody = null;
const result = await OnionSending.sendJsonViaOnionV4ToSogs({ const result = await OnionSending.sendJsonViaOnionV4ToSogs({
@ -87,11 +126,11 @@ export const sendSogsReactionOnionV4 = async (
throw new Error('putReaction parsing failed'); throw new Error('putReaction parsing failed');
} }
window.log.info(
`You ${reaction.action === Action.REACT ? 'added' : 'removed'} a`,
reaction.emoji,
`reaction on ${serverUrl}/${room}`
);
const success = Boolean(reaction.action === Action.REACT ? rawMessage.added : rawMessage.removed); const success = Boolean(reaction.action === Action.REACT ? rawMessage.added : rawMessage.removed);
if (success) {
updateMutationCache(cacheEntry, rawMessage.seqno);
}
return success; return success;
}; };

@ -1,6 +1,6 @@
import chai, { expect } from 'chai'; import chai, { expect } from 'chai';
import Sinon, { useFakeTimers } from 'sinon'; import Sinon, { useFakeTimers } from 'sinon';
import { handleMessageReaction, sendMessageReaction } from '../../../../util/reactions'; import { Reactions } from '../../../../util/reactions';
import { Data } from '../../../../data/data'; import { Data } from '../../../../data/data';
import * as Storage from '../../../../util/storage'; import * as Storage from '../../../../util/storage';
import { generateFakeIncomingPrivateMessage, stubWindowLog } from '../../../test-utils/utils'; import { generateFakeIncomingPrivateMessage, stubWindowLog } from '../../../test-utils/utils';
@ -40,7 +40,7 @@ describe('ReactionMessage', () => {
it('can react to a message', async () => { it('can react to a message', async () => {
// Send reaction // Send reaction
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄'); const reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '😄');
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal( expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
Number(originalMessage.get('sent_at')) Number(originalMessage.get('sent_at'))
@ -52,11 +52,12 @@ describe('ReactionMessage', () => {
expect(reaction?.action, 'action should be 0').to.be.equal(0); expect(reaction?.action, 'action should be 0').to.be.equal(0);
// Handling reaction // Handling reaction
const updatedMessage = await handleMessageReaction( const updatedMessage = await Reactions.handleMessageReaction({
reaction as SignalService.DataMessage.IReaction, reaction: reaction as SignalService.DataMessage.IReaction,
ourNumber, sender: ourNumber,
true you: true,
); isOpenGroup: false,
});
expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be
.undefined; .undefined;
@ -72,7 +73,7 @@ describe('ReactionMessage', () => {
it('can remove a reaction from a message', async () => { it('can remove a reaction from a message', async () => {
// Send reaction // Send reaction
const reaction = await sendMessageReaction(originalMessage.get('id'), '😄'); const reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '😄');
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal( expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
Number(originalMessage.get('sent_at')) Number(originalMessage.get('sent_at'))
@ -84,11 +85,12 @@ describe('ReactionMessage', () => {
expect(reaction?.action, 'action should be 1').to.be.equal(1); expect(reaction?.action, 'action should be 1').to.be.equal(1);
// Handling reaction // Handling reaction
const updatedMessage = await handleMessageReaction( const updatedMessage = await Reactions.handleMessageReaction({
reaction as SignalService.DataMessage.IReaction, reaction: reaction as SignalService.DataMessage.IReaction,
ourNumber, sender: ourNumber,
true you: true,
); isOpenGroup: false,
});
expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be
.undefined; .undefined;
@ -98,10 +100,10 @@ describe('ReactionMessage', () => {
// we have already sent 2 messages when this test runs // we have already sent 2 messages when this test runs
for (let i = 0; i < 18; i++) { for (let i = 0; i < 18; i++) {
// Send reaction // Send reaction
await sendMessageReaction(originalMessage.get('id'), '👍'); await Reactions.sendMessageReaction(originalMessage.get('id'), '👍');
} }
let reaction = await sendMessageReaction(originalMessage.get('id'), '👎'); let reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '👎');
expect(reaction, 'no reaction should be returned since we are over the rate limit').to.be expect(reaction, 'no reaction should be returned since we are over the rate limit').to.be
.undefined; .undefined;
@ -111,7 +113,7 @@ describe('ReactionMessage', () => {
// Wait a miniute for the rate limit to clear // Wait a miniute for the rate limit to clear
clock.tick(1 * 60 * 1000); clock.tick(1 * 60 * 1000);
reaction = await sendMessageReaction(originalMessage.get('id'), '👋'); reaction = await Reactions.sendMessageReaction(originalMessage.get('id'), '👋');
expect(reaction?.id, 'id should match the original message timestamp').to.be.equal( expect(reaction?.id, 'id should match the original message timestamp').to.be.equal(
Number(originalMessage.get('sent_at')) Number(originalMessage.get('sent_at'))

@ -93,20 +93,6 @@ describe('filterDuplicatesFromDbAndIncoming', () => {
expect(filtered.length).to.be.eq(1); expect(filtered.length).to.be.eq(1);
expect(filtered[0]).to.be.deep.eq(msg1); expect(filtered[0]).to.be.deep.eq(msg1);
}); });
it('three duplicates in the same poll', async () => {
const msg1 = TestUtils.generateOpenGroupMessageV2();
const msg2 = TestUtils.generateOpenGroupMessageV2();
const msg3 = TestUtils.generateOpenGroupMessageV2();
msg2.sentTimestamp = msg1.sentTimestamp;
msg2.sender = msg1.sender;
msg3.sentTimestamp = msg1.sentTimestamp;
msg3.sender = msg1.sender;
const filtered = await filterDuplicatesFromDbAndIncoming([msg1, msg2, msg3]);
expect(filtered.length).to.be.eq(1);
expect(filtered[0]).to.be.deep.eq(msg1);
});
}); });
describe('filters duplicated message from database', () => { describe('filters duplicated message from database', () => {

@ -0,0 +1,365 @@
import { expect } from 'chai';
import Sinon from 'sinon';
import {
addToMutationCache,
ChangeType,
getMutationCache,
processMessagesUsingCache,
SogsV3Mutation,
updateMutationCache,
} from '../../../../session/apis/open_group_api/sogsv3/sogsV3MutationCache';
import { TestUtils } from '../../../test-utils';
import { Reactions } from '../../../../util/reactions';
import {
OpenGroupMessageV4,
OpenGroupReactionMessageV4,
} from '../../../../session/apis/open_group_api/opengroupV2/OpenGroupServerPoller';
// tslint:disable: chai-vague-errors
describe('mutationCache', () => {
TestUtils.stubWindowLog();
const roomInfos = TestUtils.generateOpenGroupV2RoomInfos();
const originalMessage = TestUtils.generateOpenGroupMessageV2WithServerId(111);
const originalMessage2 = TestUtils.generateOpenGroupMessageV2WithServerId(112);
const reactor1 = TestUtils.generateFakePubKey().key;
const reactor2 = TestUtils.generateFakePubKey().key;
beforeEach(() => {
// stubs
Sinon.stub(Reactions, 'handleOpenGroupMessageReactions').resolves();
});
afterEach(Sinon.restore);
describe('add entry to cache', () => {
it('add entry to cache that is valid', () => {
const entry: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: null,
metadata: {
messageId: originalMessage.serverId,
emoji: '😄',
action: 'ADD',
},
};
addToMutationCache(entry);
const cache = getMutationCache();
expect(cache, 'should not empty').to.not.equal([]);
expect(cache.length, 'should have one entry').to.be.equal(1);
expect(cache[0], 'the entry should match the input').to.be.deep.equal(entry);
});
it('add entry to cache that is invalid and fail', () => {
const entry: SogsV3Mutation = {
server: '', // this is invalid
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: 100,
metadata: {
messageId: originalMessage.serverId,
emoji: '😄',
action: 'ADD',
},
};
addToMutationCache(entry);
const cache = getMutationCache();
expect(cache, 'should not empty').to.not.equal([]);
expect(cache.length, 'should have one entry').to.be.equal(1);
});
});
describe('update entry in cache', () => {
it('update entry in cache with a valid source entry', () => {
const entry: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: null, // mutation before we have received a response
metadata: {
messageId: originalMessage.serverId,
emoji: '😄',
action: 'ADD',
},
};
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
id: originalMessage.serverId,
seqno: 200,
reactions: {
'😄': {
index: 0,
count: 1,
you: false,
reactors: [reactor1],
},
'❤️': {
index: 1,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor1],
},
'😈': {
index: 2,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor2],
},
},
}) as OpenGroupMessageV4;
updateMutationCache(entry, (messageResponse as OpenGroupMessageV4).seqno);
const cache = getMutationCache();
expect(cache, 'should not empty').to.not.equal([]);
expect(cache.length, 'should have one entry').to.be.equal(1);
expect(
cache[0].seqno,
'should have an entry with a matching seqno to the message response'
).to.be.equal(messageResponse.seqno);
});
it('update entry in cache with an invalid source entry', () => {
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
id: originalMessage.serverId,
seqno: 200,
reactions: {
'😄': {
index: 0,
count: 1,
you: false,
reactors: [reactor1],
},
'❤️': {
index: 1,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor1],
},
'😈': {
index: 2,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor2],
},
},
}) as OpenGroupMessageV4;
const entry: SogsV3Mutation = {
server: '',
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: 100,
metadata: {
messageId: originalMessage.serverId,
emoji: '😄',
action: 'ADD',
},
};
updateMutationCache(entry, (messageResponse as OpenGroupMessageV4).seqno);
const cache = getMutationCache();
expect(cache, 'should not empty').to.not.equal([]);
expect(cache.length, 'should have one entry').to.be.equal(1);
expect(
cache[0].seqno,
'should have an entry with a matching seqno to the message response'
).to.be.equal(messageResponse.seqno);
});
it('update entry in cache with a valid source entry but its not stored in the cache', () => {
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
id: originalMessage.serverId,
seqno: 200,
reactions: {
'😄': {
index: 0,
count: 1,
you: false,
reactors: [reactor1],
},
'❤️': {
index: 1,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor1],
},
'😈': {
index: 2,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor2],
},
},
}) as OpenGroupMessageV4;
const entry: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: 400,
metadata: {
messageId: originalMessage.serverId,
emoji: '😄',
action: 'ADD',
},
};
updateMutationCache(entry, (messageResponse as OpenGroupMessageV4).seqno);
const cache = getMutationCache();
expect(cache, 'should not empty').to.not.equal([]);
expect(cache.length, 'should have one entry').to.be.equal(1);
expect(
cache[0].seqno,
'should have an entry with a matching seqno to the message response'
).to.be.equal(messageResponse.seqno);
});
});
describe('process opengroup messages using the cache', () => {
it('processing a message with valid serverUrl, roomId and message should return the same message response', async () => {
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
id: originalMessage.serverId,
seqno: 200,
reactions: {
'😄': {
index: 0,
count: 1,
you: false,
reactors: [reactor1],
},
'❤️': {
index: 1,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor1],
},
'😈': {
index: 2,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor2],
},
},
}) as OpenGroupMessageV4;
const message = await processMessagesUsingCache(
roomInfos.serverUrl,
roomInfos.roomId,
messageResponse
);
const cache = getMutationCache();
expect(cache, 'cache should be empty').to.be.empty;
expect(message, 'message response should match').to.be.deep.equal(messageResponse);
});
it('processing a message with valid serverUrl, roomId and message (from SOGS < 1.3.4) should return the same message response', async () => {
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
id: originalMessage2.serverId,
// in version less than 1.3.4 there is no a seqno set
reactions: {
'🤣': {
index: 0,
count: 3,
you: true,
reactors: [reactor1, reactor2, originalMessage2.sender],
},
'😈': {
index: 0,
count: 1,
you: false,
reactors: [reactor2],
},
},
}) as OpenGroupReactionMessageV4;
const message = await processMessagesUsingCache(
roomInfos.serverUrl,
roomInfos.roomId,
messageResponse
);
const cache = getMutationCache();
expect(cache, 'cache should be empty').to.be.empty;
expect(message, 'message response should match').to.be.deep.equal(messageResponse);
});
it('processing a message with valid entries in the cache should calculate the optimistic state if there is no message seqo or the cached entry seqno is larger than the message seqno', async () => {
const messageResponse = TestUtils.generateFakeIncomingOpenGroupMessageV4({
id: originalMessage.serverId,
seqno: 200,
reactions: {
'😄': {
index: 0,
count: 1,
you: false,
reactors: [reactor1],
},
'❤️': {
index: 1,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor1],
},
'😈': {
index: 2,
count: 2,
you: true,
reactors: [originalMessage.sender, reactor2],
},
},
}) as OpenGroupMessageV4;
const entry: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: 100, // less than response messageResponse seqno should be ignored
metadata: {
messageId: originalMessage.serverId,
emoji: '❤️',
action: 'ADD',
},
};
const entry2: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: 300, // greater than response messageResponse seqno should be procesed
metadata: {
messageId: originalMessage.serverId,
emoji: '😄',
action: 'ADD',
},
};
const entry3: SogsV3Mutation = {
server: roomInfos.serverUrl,
room: roomInfos.roomId,
changeType: ChangeType.REACTIONS,
seqno: 301, //// greater than response messageResponse seqno should be procesed
metadata: {
messageId: originalMessage.serverId,
emoji: '😈',
action: 'REMOVE',
},
};
addToMutationCache(entry);
addToMutationCache(entry2);
addToMutationCache(entry3);
const message = await processMessagesUsingCache(
roomInfos.serverUrl,
roomInfos.roomId,
messageResponse
);
const cache = getMutationCache();
expect(cache, 'cache should be empty').to.be.empty;
expect(
message.reactions['❤️'].count,
'message response reaction count for ❤️ should be unchanged with 2'
).to.equal(2);
expect(
message.reactions['😄'].count,
'message response reaction count for 😄 should be 2'
).to.equal(2);
expect(
message.reactions['😄'].you,
'message response reaction for 😄 should have you = true'
).to.equal(true);
expect(
message.reactions['😈'].count,
'message response reaction count for 😈 should be 1'
).to.equal(1);
expect(
message.reactions['😈'].you,
'message response reaction for 😈 should have you = false'
).to.equal(false);
});
});
});

@ -7,6 +7,11 @@ import { TestUtils } from '..';
import { OpenGroupRequestCommonType } from '../../../session/apis/open_group_api/opengroupV2/ApiUtil'; import { OpenGroupRequestCommonType } from '../../../session/apis/open_group_api/opengroupV2/ApiUtil';
import { OpenGroupVisibleMessage } from '../../../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; import { OpenGroupVisibleMessage } from '../../../session/messages/outgoing/visibleMessage/OpenGroupVisibleMessage';
import { MessageModel } from '../../../models/message'; import { MessageModel } from '../../../models/message';
import {
OpenGroupMessageV4,
OpenGroupReactionMessageV4,
} from '../../../session/apis/open_group_api/opengroupV2/OpenGroupServerPoller';
import { OpenGroupReaction } from '../../../types/Reaction';
export function generateVisibleMessage({ export function generateVisibleMessage({
identifier, identifier,
@ -35,6 +40,23 @@ export function generateOpenGroupMessageV2(): OpenGroupMessageV2 {
}); });
} }
// this is for test purposes only
type OpenGroupMessageV2WithServerId = Omit<OpenGroupMessageV2, 'sender' | 'serverId'> & {
sender: string;
serverId: number;
};
export function generateOpenGroupMessageV2WithServerId(
serverId: number
): OpenGroupMessageV2WithServerId {
return new OpenGroupMessageV2({
serverId,
sentTimestamp: Date.now(),
sender: TestUtils.generateFakePubKey().key,
base64EncodedData: 'whatever',
}) as OpenGroupMessageV2WithServerId;
}
export function generateOpenGroupVisibleMessage(): OpenGroupVisibleMessage { export function generateOpenGroupVisibleMessage(): OpenGroupVisibleMessage {
return new OpenGroupVisibleMessage({ return new OpenGroupVisibleMessage({
timestamp: Date.now(), timestamp: Date.now(),
@ -62,3 +84,23 @@ export function generateFakeIncomingPrivateMessage(): MessageModel {
type: 'incoming', type: 'incoming',
}); });
} }
export function generateFakeIncomingOpenGroupMessageV4({
id,
reactions,
seqno,
}: {
id: number;
seqno?: number;
reactions?: Record<string, OpenGroupReaction>;
}): OpenGroupMessageV4 | OpenGroupReactionMessageV4 {
return {
id, // serverId
seqno: seqno ?? undefined,
/** base64 */
signature: 'whatever',
/** timestamp number with decimal */
posted: Date.now(),
reactions: reactions ?? {},
};
}

@ -145,4 +145,5 @@ export type OpenGroupReactionList = Record<string, OpenGroupReaction>;
export interface OpenGroupReactionResponse { export interface OpenGroupReactionResponse {
added?: boolean; added?: boolean;
removed?: boolean; removed?: boolean;
seqno: number;
} }

@ -11,12 +11,12 @@ import { UserUtils } from '../session/utils';
import { Action, OpenGroupReactionList, ReactionList, RecentReactions } from '../types/Reaction'; import { Action, OpenGroupReactionList, ReactionList, RecentReactions } from '../types/Reaction';
import { getRecentReactions, saveRecentReations } from '../util/storage'; import { getRecentReactions, saveRecentReations } from '../util/storage';
export const SOGSReactorsFetchCount = 5; const SOGSReactorsFetchCount = 5;
const rateCountLimit = 20; const rateCountLimit = 20;
const rateTimeLimit = 60 * 1000; const rateTimeLimit = 60 * 1000;
const latestReactionTimestamps: Array<number> = []; const latestReactionTimestamps: Array<number> = [];
export function hitRateLimit(): boolean { function hitRateLimit(): boolean {
const timestamp = Date.now(); const timestamp = Date.now();
latestReactionTimestamps.push(timestamp); latestReactionTimestamps.push(timestamp);
@ -37,12 +37,16 @@ export function hitRateLimit(): boolean {
* Retrieves the original message of a reaction * Retrieves the original message of a reaction
*/ */
const getMessageByReaction = async ( const getMessageByReaction = async (
reaction: SignalService.DataMessage.IReaction reaction: SignalService.DataMessage.IReaction,
isOpenGroup: boolean
): Promise<MessageModel | null> => { ): Promise<MessageModel | null> => {
let originalMessage = null; let originalMessage = null;
const originalMessageId = Number(reaction.id); const originalMessageId = Number(reaction.id);
const originalMessageAuthor = reaction.author; const originalMessageAuthor = reaction.author;
if (isOpenGroup) {
originalMessage = await Data.getMessageByServerId(originalMessageId);
} else {
const collection = await Data.getMessagesBySentAt(originalMessageId); const collection = await Data.getMessagesBySentAt(originalMessageId);
originalMessage = collection.find((item: MessageModel) => { originalMessage = collection.find((item: MessageModel) => {
const messageTimestamp = item.get('sent_at'); const messageTimestamp = item.get('sent_at');
@ -54,6 +58,7 @@ const getMessageByReaction = async (
author === originalMessageAuthor author === originalMessageAuthor
); );
}); });
}
if (!originalMessage) { if (!originalMessage) {
window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`); window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`);
@ -66,7 +71,7 @@ const getMessageByReaction = async (
/** /**
* Sends a Reaction Data Message * Sends a Reaction Data Message
*/ */
export const sendMessageReaction = async (messageId: string, emoji: string) => { const sendMessageReaction = async (messageId: string, emoji: string) => {
const found = await Data.getMessageById(messageId); const found = await Data.getMessageById(messageId);
if (found) { if (found) {
const conversationModel = found?.getConversation(); const conversationModel = found?.getConversation();
@ -124,7 +129,12 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => {
`You ${action === Action.REACT ? 'added' : 'removed'} a`, `You ${action === Action.REACT ? 'added' : 'removed'} a`,
emoji, emoji,
'reaction for message', 'reaction for message',
id id,
found.get('isPublic')
? `on ${conversationModel.toOpenGroupV2().serverUrl}/${
conversationModel.toOpenGroupV2().roomId
}`
: ''
); );
return reaction; return reaction;
} else { } else {
@ -135,19 +145,25 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => {
/** /**
* Handle reactions on the client by updating the state of the source message * 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 ( const handleMessageReaction = async ({
reaction: SignalService.DataMessage.IReaction, reaction,
sender: string, sender,
you: boolean you,
) => { isOpenGroup,
}: {
reaction: SignalService.DataMessage.IReaction;
sender: string;
you: boolean;
isOpenGroup: boolean;
}) => {
if (!reaction.emoji) { if (!reaction.emoji) {
window?.log?.warn(`There is no emoji for the reaction ${reaction}.`); window?.log?.warn(`There is no emoji for the reaction ${reaction}.`);
return; return;
} }
const originalMessage = await getMessageByReaction(reaction); const originalMessage = await getMessageByReaction(reaction, isOpenGroup);
if (!originalMessage) { if (!originalMessage) {
return; return;
} }
@ -158,10 +174,6 @@ export const handleMessageReaction = async (
const senders = details.senders; const senders = details.senders;
let count = details.count || 0; 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 (details.you && senders.includes(sender)) {
if (reaction.action === Action.REACT) { if (reaction.action === Action.REACT) {
window.log.warn('Received duplicate message for your reaction. Ignoring it'); window.log.warn('Received duplicate message for your reaction. Ignoring it');
@ -172,7 +184,6 @@ export const handleMessageReaction = async (
} else { } else {
details.you = you; details.you = you;
} }
}
switch (reaction.action) { switch (reaction.action) {
case Action.REACT: case Action.REACT:
@ -225,9 +236,36 @@ 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 handleOpenGroupMessageReactions = async ( 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
*/
const handleOpenGroupMessageReactions = async (
reactions: OpenGroupReactionList, reactions: OpenGroupReactionList,
serverId: number serverId: number
) => { ) => {
@ -279,12 +317,17 @@ export const handleOpenGroupMessageReactions = async (
senders.push(reactor); senders.push(reactor);
}); });
if (reactions[key].count > 0) {
reacts[emoji] = { reacts[emoji] = {
count: reactions[key].count, count: reactions[key].count,
index: reactions[key].index, index: reactions[key].index,
senders, senders,
you, you,
}; };
} else {
// tslint:disable-next-line: no-dynamic-delete
delete reacts[key];
}
}); });
originalMessage.set({ originalMessage.set({
@ -296,7 +339,7 @@ export const handleOpenGroupMessageReactions = async (
return originalMessage; return originalMessage;
}; };
export const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => { const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => {
window?.log?.info('updating recent reactions with', newReaction); window?.log?.info('updating recent reactions with', newReaction);
const recentReactions = new RecentReactions(reactions); const recentReactions = new RecentReactions(reactions);
const foundIndex = recentReactions.items.indexOf(newReaction); const foundIndex = recentReactions.items.indexOf(newReaction);
@ -310,3 +353,14 @@ export const updateRecentReactions = async (reactions: Array<string>, newReactio
} }
await saveRecentReations(recentReactions.items); await saveRecentReations(recentReactions.items);
}; };
// exported for testing purposes
export const Reactions = {
SOGSReactorsFetchCount,
hitRateLimit,
sendMessageReaction,
handleMessageReaction,
handleClearReaction,
handleOpenGroupMessageReactions,
updateRecentReactions,
};

Loading…
Cancel
Save