Merge pull request #2445 from yougotwill/reactions_fixes

Reactions Updates
pull/2448/head
Audric Ackermann 3 years ago committed by GitHub
commit e5f484f5bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -454,9 +454,13 @@
"hideBanner": "Hide", "hideBanner": "Hide",
"openMessageRequestInboxDescription": "View your Message Request inbox", "openMessageRequestInboxDescription": "View your Message Request inbox",
"clearAllReactions": "Are you sure you want to clear all $emoji$ ?", "clearAllReactions": "Are you sure you want to clear all $emoji$ ?",
"reactionTooltip": "reacted with",
"expandedReactionsText": "Show Less", "expandedReactionsText": "Show Less",
"reactionNotification": "Reacts to a message with $emoji$", "reactionNotification": "Reacts to a message with $emoji$",
"readableListCounterSingular": "other", "otherSingular": "$number$ other",
"readableListCounterPlural": "others" "otherPlural": "$number$ others",
"reactionPopup": "reacted with",
"reactionPopupOne": "$name$",
"reactionPopupTwo": "$name$ & $name2$",
"reactionPopupThree": "$name$, $name2$ & $name3$",
"reactionPopupMany": "$name$, $name2$, $name3$ &"
} }

@ -69,8 +69,8 @@ export const Reaction = (props: ReactionProps): ReactElement => {
handlePopupClick, handlePopupClick,
} = props; } = props;
const reactionsMap = (reactions && Object.fromEntries(reactions)) || {}; const reactionsMap = (reactions && Object.fromEntries(reactions)) || {};
const senders = reactionsMap[emoji].senders ? Object.keys(reactionsMap[emoji].senders) : []; const senders = reactionsMap[emoji]?.senders || [];
const count = reactionsMap[emoji].count; const count = reactionsMap[emoji]?.count;
const showCount = count !== undefined && (count > 1 || inGroup); const showCount = count !== undefined && (count > 1 || inGroup);
const reactionRef = useRef<HTMLDivElement>(null); const reactionRef = useRef<HTMLDivElement>(null);
@ -138,7 +138,8 @@ export const Reaction = (props: ReactionProps): ReactElement => {
<ReactionPopup <ReactionPopup
messageId={messageId} messageId={messageId}
emoji={popupReaction} emoji={popupReaction}
senders={Object.keys(reactionsMap[popupReaction].senders)} count={reactionsMap[popupReaction]?.count}
senders={reactionsMap[popupReaction]?.senders}
tooltipPosition={tooltipPosition} tooltipPosition={tooltipPosition}
onClick={() => { onClick={() => {
if (handlePopupReaction) { if (handlePopupReaction) {

@ -3,7 +3,6 @@ import styled from 'styled-components';
import { Data } from '../../../../data/data'; import { Data } from '../../../../data/data';
import { PubKey } from '../../../../session/types/PubKey'; import { PubKey } from '../../../../session/types/PubKey';
import { nativeEmojiData } from '../../../../util/emoji'; import { nativeEmojiData } from '../../../../util/emoji';
import { readableList } from '../../../../util/readableList';
export type TipPosition = 'center' | 'left' | 'right'; export type TipPosition = 'center' | 'left' | 'right';
@ -59,8 +58,11 @@ const StyledOthers = styled.span`
color: var(--color-accent); color: var(--color-accent);
`; `;
const generateContacts = async (messageId: string, senders: Array<string>) => { const generateContactsString = async (
let results = null; messageId: string,
senders: Array<string>
): Promise<Array<string> | null> => {
let results = [];
const message = await Data.getMessageById(messageId); const message = await Data.getMessageById(messageId);
if (message) { if (message) {
let meIndex = -1; let meIndex = -1;
@ -75,53 +77,71 @@ const generateContacts = async (messageId: string, senders: Array<string>) => {
results.splice(meIndex, 1); results.splice(meIndex, 1);
results = [window.i18n('you'), ...results]; results = [window.i18n('you'), ...results];
} }
if (results && results.length > 0) {
return results;
}
} }
return results; return null;
}; };
const Contacts = (contacts: string) => { const Contacts = (contacts: Array<string>, count: number) => {
if (!contacts) { if (!Boolean(contacts?.length > 0)) {
return; return;
} }
if (contacts.includes('&') && contacts.includes('other')) { const reactors = contacts.length;
const [names, others] = contacts.split('&'); if (reactors === 1 || reactors === 2 || reactors === 3) {
return (
<span>
{window.i18n(
reactors === 1
? 'reactionPopupOne'
: reactors === 2
? 'reactionPopupTwo'
: 'reactionPopupThree',
contacts
)}{' '}
{window.i18n('reactionPopup')}
</span>
);
} else if (reactors > 3) {
return ( return (
<span> <span>
{names} & <StyledOthers>{others}</StyledOthers> {window.i18n('reactionTooltip')} {window.i18n('reactionPopupMany', [contacts[0], contacts[1], contacts[3]])}{' '}
<StyledOthers>
{window.i18n(reactors === 4 ? 'otherSingular' : 'otherPlural', [`${count - 3}`])}
</StyledOthers>{' '}
{window.i18n('reactionPopup')}
</span> </span>
); );
} else {
return null;
} }
return (
<span>
{contacts} {window.i18n('reactionTooltip')}
</span>
);
}; };
type Props = { type Props = {
messageId: string; messageId: string;
emoji: string; emoji: string;
count: number;
senders: Array<string>; senders: Array<string>;
tooltipPosition?: TipPosition; tooltipPosition?: TipPosition;
onClick: (...args: Array<any>) => void; onClick: (...args: Array<any>) => void;
}; };
export const ReactionPopup = (props: Props): ReactElement => { export const ReactionPopup = (props: Props): ReactElement => {
const { messageId, emoji, senders, tooltipPosition = 'center', onClick } = props; const { messageId, emoji, count, senders, tooltipPosition = 'center', onClick } = props;
const [contacts, setContacts] = useState(''); const [contacts, setContacts] = useState<Array<string>>([]);
useEffect(() => { useEffect(() => {
let isCancelled = false; let isCancelled = false;
generateContacts(messageId, senders) generateContactsString(messageId, senders)
.then(async results => { .then(async results => {
if (isCancelled) { if (isCancelled) {
return; return;
} }
if (results && results.length > 0) { if (results) {
setContacts(readableList(results)); setContacts(results);
} }
}) })
.catch(() => { .catch(() => {
@ -133,11 +153,11 @@ export const ReactionPopup = (props: Props): ReactElement => {
return () => { return () => {
isCancelled = true; isCancelled = true;
}; };
}, [generateContacts]); }, [count, messageId, senders]);
return ( return (
<StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}> <StyledPopupContainer tooltipPosition={tooltipPosition} onClick={onClick}>
{Contacts(contacts)} {Contacts(contacts, count)}
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}> <StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
{emoji} {emoji}
</StyledEmoji> </StyledEmoji>

@ -165,7 +165,7 @@ type Props = {
}; };
const handleSenders = (senders: Array<string>, me: string) => { const handleSenders = (senders: Array<string>, me: string) => {
let updatedSenders = senders; let updatedSenders = [...senders];
const blindedMe = updatedSenders.filter(isUsAnySogsFromCache); const blindedMe = updatedSenders.filter(isUsAnySogsFromCache);
let meIndex = -1; let meIndex = -1;
@ -217,7 +217,7 @@ export const ReactListModal = (props: Props): ReactElement => {
let _senders = let _senders =
reactionsMap && reactionsMap[currentReact] && reactionsMap[currentReact].senders reactionsMap && reactionsMap[currentReact] && reactionsMap[currentReact].senders
? Object.keys(reactionsMap[currentReact].senders) ? reactionsMap[currentReact].senders
: null; : null;
if (_senders && !isEqual(senders, _senders)) { if (_senders && !isEqual(senders, _senders)) {

@ -89,12 +89,10 @@ import { addMessagePadding } from '../session/crypto/BufferPadding';
import { getSodiumRenderer } from '../session/crypto'; import { getSodiumRenderer } from '../session/crypto';
import { import {
findCachedOurBlindedPubkeyOrLookItUp, findCachedOurBlindedPubkeyOrLookItUp,
getUsBlindedInThatServer,
isUsAnySogsFromCache, isUsAnySogsFromCache,
} 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';
export class ConversationModel extends Backbone.Model<ConversationAttributes> { export class ConversationModel extends Backbone.Model<ConversationAttributes> {
public updateLastMessage: () => any; public updateLastMessage: () => any;
@ -678,8 +676,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
throw new Error('Only opengroupv2 are supported now'); throw new Error('Only opengroupv2 are supported now');
} }
let sender = UserUtils.getOurPubKeyStrFromCache();
// an OpenGroupV2 message is just a visible message // an OpenGroupV2 message is just a visible message
const chatMessageParams: VisibleMessageParams = { const chatMessageParams: VisibleMessageParams = {
body: '', body: '',
@ -697,7 +693,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
if (this.id.startsWith('15')) { if (this.id.startsWith('15')) {
window.log.info('Sending a blinded message to this user: ', this.id); window.log.info('Sending a blinded message to this user: ', this.id);
// TODO confirm this works with Reacts
await this.sendBlindedMessageRequest(chatMessageParams); await this.sendBlindedMessageRequest(chatMessageParams);
return; return;
} }
@ -725,26 +720,14 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id); const openGroup = OpenGroupData.getV2OpenGroupRoom(this.id);
const blinded = Boolean(roomHasBlindEnabled(openGroup)); const blinded = Boolean(roomHasBlindEnabled(openGroup));
if (blinded) {
const blindedSender = getUsBlindedInThatServer(this);
if (blindedSender) {
sender = blindedSender;
}
}
await handleMessageReaction(reaction, sender, true);
// send with blinding if we need to // send with blinding if we need to
await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos, blinded, []); await getMessageQueue().sendToOpenGroupV2(chatMessageOpenGroupV2, roomInfos, blinded, []);
return; return;
} else {
await handleMessageReaction(reaction, sender, false);
} }
const destinationPubkey = new PubKey(destination); const destinationPubkey = new PubKey(destination);
if (this.isPrivate()) { if (this.isPrivate()) {
// TODO is this still fine without isMe?
const chatMessageMe = new VisibleMessage({ const chatMessageMe = new VisibleMessage({
...chatMessageParams, ...chatMessageParams,
syncTarget: this.id, syncTarget: this.id,

@ -319,17 +319,12 @@ async function handleSwarmMessage(
void convoToAddMessageTo.queueJob(async () => { void convoToAddMessageTo.queueJob(async () => {
// this call has to be made inside the queueJob! // this call has to be made inside the queueJob!
if (!msgModel.get('isPublic') && rawDataMessage.reaction && rawDataMessage.syncTarget) { // We handle reaction DataMessages separately
await handleMessageReaction( if (!msgModel.get('isPublic') && rawDataMessage.reaction) {
rawDataMessage.reaction, await handleMessageReaction(rawDataMessage.reaction, msgModel.get('source'));
msgModel.get('source'),
false,
messageHash
);
confirm(); confirm();
return; return;
} }
const isDuplicate = await isSwarmMessageDuplicate({ const isDuplicate = await isSwarmMessageDuplicate({
source: msgModel.get('source'), source: msgModel.get('source'),
sentAt, sentAt,

@ -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 { handleMessageReaction } from '../util/reactions';
import { Action, Reaction } from '../types/Reaction'; import { Action, Reaction } from '../types/Reaction';
function contentTypeSupported(type: string): boolean { function contentTypeSupported(type: string): boolean {
@ -341,8 +340,6 @@ export async function handleMessageJob(
); );
if (!messageModel.get('isPublic') && regularDataMessage.reaction) { if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
if ( if (
regularDataMessage.reaction.action === Action.REACT && regularDataMessage.reaction.action === Action.REACT &&
conversation.isPrivate() && conversation.isPrivate() &&

@ -8,6 +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';
type BatchFetchRequestOptions = { type BatchFetchRequestOptions = {
method: 'POST' | 'PUT' | 'GET' | 'DELETE'; method: 'POST' | 'PUT' | 'GET' | 'DELETE';
@ -238,10 +239,9 @@ const makeBatchRequestPayload = (
if (options.messages) { if (options.messages) {
return { return {
method: 'GET', method: 'GET',
// TODO Consistency across platforms with fetching reactors
path: isNumber(options.messages.sinceSeqNo) path: isNumber(options.messages.sinceSeqNo)
? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r` ? `/room/${options.messages.roomId}/messages/since/${options.messages.sinceSeqNo}?t=r&reactors=${SOGSReactorsFetchCount}`
: `/room/${options.messages.roomId}/messages/recent`, : `/room/${options.messages.roomId}/messages/recent?reactors=${SOGSReactorsFetchCount}`,
}; };
} }
break; break;

@ -2,6 +2,7 @@ import { AbortSignal } from 'abort-controller';
import { Data } from '../../../../data/data'; import { Data } from '../../../../data/data';
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 { OnionSending } from '../../../onions/onionSend'; import { OnionSending } from '../../../onions/onionSend';
import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils'; import { OpenGroupPollingUtils } from '../opengroupV2/OpenGroupPollingUtils';
import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll'; import { batchGlobalIsSuccess, parseBatchGlobalStatusCode } from './sogsV3BatchPoll';
@ -45,7 +46,12 @@ export const sendSogsReactionOnionV4 = async (
return false; return false;
} }
// for an invalid reaction we use https://emojipedia.org/frame-with-an-x/ as a replacement since it cannot rendered as an emoji if (hitRateLimit()) {
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 : '🖾'; const emoji = getEmojiDataFromNative(reaction.emoji) ? reaction.emoji : '🖾';
const endpoint = `/room/${room}/reaction/${reaction.id}/${emoji}`; const endpoint = `/room/${room}/reaction/${reaction.id}/${emoji}`;
const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE'; const method = reaction.action === Action.REACT ? 'PUT' : 'DELETE';

@ -23,6 +23,8 @@ import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/Ope
import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage'; import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage';
import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage'; import { CallMessage } from '../messages/outgoing/controlMessage/CallMessage';
import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroupMessageV2'; import { OpenGroupMessageV2 } from '../apis/open_group_api/opengroupV2/OpenGroupMessageV2';
import { AbortController } from 'abort-controller';
import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction';
type ClosedGroupMessageType = type ClosedGroupMessageType =
| ClosedGroupVisibleMessage | ClosedGroupVisibleMessage
@ -75,6 +77,18 @@ export class MessageQueue {
// Skipping the queue for Open Groups v2; the message is sent directly // Skipping the queue for Open Groups v2; the message is sent directly
try { try {
// NOTE Reactions are handled separately
if (message.reaction) {
await sendSogsReactionOnionV4(
roomInfos.serverUrl,
roomInfos.roomId,
new AbortController().signal,
message.reaction,
blinded
);
return;
}
const result = await MessageSender.sendToOpenGroupV2( const result = await MessageSender.sendToOpenGroupV2(
message, message,
roomInfos, roomInfos,
@ -82,11 +96,6 @@ export class MessageQueue {
filesToLink filesToLink
); );
// NOTE Reactions are handled in the MessageSender
if (message.reaction) {
return;
}
const { sentTimestamp, serverId } = result as OpenGroupMessageV2; const { sentTimestamp, serverId } = result as OpenGroupMessageV2;
if (!serverId || serverId === -1) { if (!serverId || serverId === -1) {
throw new Error(`Invalid serverId returned by server: ${serverId}`); throw new Error(`Invalid serverId returned by server: ${serverId}`);

@ -26,7 +26,6 @@ import {
sendSogsMessageOnionV4, sendSogsMessageOnionV4,
} from '../apis/open_group_api/sogsv3/sogsV3SendMessage'; } from '../apis/open_group_api/sogsv3/sogsV3SendMessage';
import { AbortController } from 'abort-controller'; import { AbortController } from 'abort-controller';
import { sendSogsReactionOnionV4 } from '../apis/open_group_api/sogsv3/sogsV3SendReaction';
const DEFAULT_CONNECTIONS = 1; const DEFAULT_CONNECTIONS = 1;
@ -288,25 +287,14 @@ export async function sendToOpenGroupV2(
filesToLink, filesToLink,
}); });
if (rawMessage.reaction) { const msg = await sendSogsMessageOnionV4(
const msg = await sendSogsReactionOnionV4( roomInfos.serverUrl,
roomInfos.serverUrl, roomInfos.roomId,
roomInfos.roomId, new AbortController().signal,
new AbortController().signal, v2Message,
rawMessage.reaction, blinded
blinded );
); return msg;
return msg;
} else {
const msg = await sendSogsMessageOnionV4(
roomInfos.serverUrl,
roomInfos.roomId,
new AbortController().signal,
v2Message,
blinded
);
return msg;
}
} }
/** /**

@ -915,6 +915,16 @@ export const getMessageReactsProps = createSelector(getMessagePropsByMessageId,
]); ]);
if (msgProps.reacts) { if (msgProps.reacts) {
// NOTE we don't want to render reactions that have 'senders' as an object this is a deprecated type used during development 25/08/2022
const oldReactions = Object.values(msgProps.reacts).filter(
reaction => !Array.isArray(reaction.senders)
);
if (oldReactions.length > 0) {
msgProps.reacts = undefined;
return msgProps;
}
const sortedReacts = Object.entries(msgProps.reacts).sort((a, b) => { const sortedReacts = Object.entries(msgProps.reacts).sort((a, b) => {
return a[1].index < b[1].index ? -1 : a[1].index > b[1].index ? 1 : 0; return a[1].index < b[1].index ? -1 : a[1].index > b[1].index ? 1 : 0;
}); });

@ -54,9 +54,7 @@ describe('ReactionMessage', () => {
// Handling reaction // Handling reaction
const updatedMessage = await handleMessageReaction( const updatedMessage = await handleMessageReaction(
reaction as SignalService.DataMessage.IReaction, reaction as SignalService.DataMessage.IReaction,
ourNumber, ourNumber
false,
originalMessage.get('id')
); );
expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be expect(updatedMessage?.get('reacts'), 'original message should have reacts').to.not.be
@ -65,7 +63,7 @@ describe('ReactionMessage', () => {
expect(updatedMessage?.get('reacts')!['😄'], 'reacts should have 😄 key').to.not.be.undefined; expect(updatedMessage?.get('reacts')!['😄'], 'reacts should have 😄 key').to.not.be.undefined;
// tslint:disable: no-non-null-assertion // tslint:disable: no-non-null-assertion
expect( expect(
Object.keys(updatedMessage!.get('reacts')!['😄'].senders)[0], updatedMessage!.get('reacts')!['😄'].senders[0],
'sender pubkey should match' 'sender pubkey should match'
).to.be.equal(ourNumber); ).to.be.equal(ourNumber);
expect(updatedMessage!.get('reacts')!['😄'].count, 'count should be 1').to.be.equal(1); expect(updatedMessage!.get('reacts')!['😄'].count, 'count should be 1').to.be.equal(1);
@ -87,9 +85,7 @@ describe('ReactionMessage', () => {
// Handling reaction // Handling reaction
const updatedMessage = await handleMessageReaction( const updatedMessage = await handleMessageReaction(
reaction as SignalService.DataMessage.IReaction, reaction as SignalService.DataMessage.IReaction,
ourNumber, ourNumber
false,
originalMessage.get('id')
); );
expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be expect(updatedMessage?.get('reacts'), 'original message reacts should be undefined').to.be

@ -9,6 +9,7 @@ export type LocalizerKeys =
| 'unblocked' | 'unblocked'
| 'keepDisabled' | 'keepDisabled'
| 'userAddedToModerators' | 'userAddedToModerators'
| 'otherSingular'
| 'to' | 'to'
| 'sent' | 'sent'
| 'requestsPlaceholder' | 'requestsPlaceholder'
@ -39,6 +40,7 @@ export type LocalizerKeys =
| 'autoUpdateLaterButtonLabel' | 'autoUpdateLaterButtonLabel'
| 'maximumAttachments' | 'maximumAttachments'
| 'deviceOnly' | 'deviceOnly'
| 'reactionPopupTwo'
| 'beginYourSession' | 'beginYourSession'
| 'typingIndicatorsSettingDescription' | 'typingIndicatorsSettingDescription'
| 'changePasswordToastDescription' | 'changePasswordToastDescription'
@ -140,7 +142,6 @@ export type LocalizerKeys =
| 'banUser' | 'banUser'
| 'answeredACall' | 'answeredACall'
| 'sendMessage' | 'sendMessage'
| 'readableListCounterSingular'
| 'recoveryPhraseRevealMessage' | 'recoveryPhraseRevealMessage'
| 'showRecoveryPhrase' | 'showRecoveryPhrase'
| 'autoUpdateSettingDescription' | 'autoUpdateSettingDescription'
@ -186,7 +187,6 @@ export type LocalizerKeys =
| 'nameAndMessage' | 'nameAndMessage'
| 'autoUpdateDownloadedMessage' | 'autoUpdateDownloadedMessage'
| 'onionPathIndicatorTitle' | 'onionPathIndicatorTitle'
| 'readableListCounterPlural'
| 'unknown' | 'unknown'
| 'mediaMessage' | 'mediaMessage'
| 'addAsModerator' | 'addAsModerator'
@ -253,6 +253,7 @@ export type LocalizerKeys =
| 'setPassword' | 'setPassword'
| 'editMenuDeleteContact' | 'editMenuDeleteContact'
| 'hideMenuBarTitle' | 'hideMenuBarTitle'
| 'reactionPopupOne'
| 'imageCaptionIconAlt' | 'imageCaptionIconAlt'
| 'sendRecoveryPhraseTitle' | 'sendRecoveryPhraseTitle'
| 'multipleJoinedTheGroup' | 'multipleJoinedTheGroup'
@ -268,6 +269,7 @@ export type LocalizerKeys =
| 'editMenuRedo' | 'editMenuRedo'
| 'hideRequestBanner' | 'hideRequestBanner'
| 'changeNicknameMessage' | 'changeNicknameMessage'
| 'reactionPopupThree'
| 'close' | 'close'
| 'deleteMessageQuestion' | 'deleteMessageQuestion'
| 'newMessage' | 'newMessage'
@ -296,6 +298,7 @@ export type LocalizerKeys =
| 'timerOption_6_hours_abbreviated' | 'timerOption_6_hours_abbreviated'
| 'timerOption_1_week_abbreviated' | 'timerOption_1_week_abbreviated'
| 'timerSetTo' | 'timerSetTo'
| 'otherPlural'
| 'enable' | 'enable'
| 'notificationSubtitle' | 'notificationSubtitle'
| 'youChangedTheTimer' | 'youChangedTheTimer'
@ -307,6 +310,7 @@ export type LocalizerKeys =
| 'noNameOrMessage' | 'noNameOrMessage'
| 'pinConversationLimitTitle' | 'pinConversationLimitTitle'
| 'noSearchResults' | 'noSearchResults'
| 'reactionPopup'
| 'changeNickname' | 'changeNickname'
| 'userUnbanned' | 'userUnbanned'
| 'respondingToRequestWarning' | 'respondingToRequestWarning'
@ -325,7 +329,6 @@ export type LocalizerKeys =
| 'media' | 'media'
| 'noMembersInThisGroup' | 'noMembersInThisGroup'
| 'saveLogToDesktop' | 'saveLogToDesktop'
| 'reactionTooltip'
| 'copyErrorAndQuit' | 'copyErrorAndQuit'
| 'onlyAdminCanRemoveMembers' | 'onlyAdminCanRemoveMembers'
| 'passwordTypeError' | 'passwordTypeError'
@ -433,6 +436,7 @@ export type LocalizerKeys =
| 'settingsHeader' | 'settingsHeader'
| 'autoUpdateNewVersionMessage' | 'autoUpdateNewVersionMessage'
| 'oneNonImageAtATimeToast' | 'oneNonImageAtATimeToast'
| 'reactionPopupMany'
| 'removePasswordTitle' | 'removePasswordTitle'
| 'iAmSure' | 'iAmSure'
| 'selectMessage' | 'selectMessage'

@ -123,21 +123,21 @@ export type ReactionList = Record<
{ {
count: number; count: number;
index: number; // relies on reactsIndex in the message model index: number; // relies on reactsIndex in the message model
senders: Record<string, string>; // <sender pubkey, messageHash or serverId> senders: Array<string>;
you?: boolean; // whether we are in the senders because sometimes we dont have the full list of senders yet.
} }
>; >;
// used when rendering reactions to guarantee sorted order using the index // used when rendering reactions to guarantee sorted order using the index
export type SortedReactionList = Array< export type SortedReactionList = Array<
[string, { count: number; index: number; senders: Record<string, string> }] [string, { count: number; index: number; senders: Array<string>; you?: boolean }]
>; >;
export interface OpenGroupReaction { export interface OpenGroupReaction {
index: number; index: number;
count: number; count: number;
first: number;
reactors: Array<string>;
you: boolean; you: boolean;
reactors: Array<string>;
} }
export type OpenGroupReactionList = Record<string, OpenGroupReaction>; export type OpenGroupReactionList = Record<string, OpenGroupReaction>;

@ -76,7 +76,7 @@ export function initialiseEmojiData(data: any) {
} }
// Synchronous version of Emoji Mart's SearchIndex.search() // Synchronous version of Emoji Mart's SearchIndex.search()
// If you upgrade the package things will probably break // If we upgrade the package things will probably break
export function searchSync(query: string, args?: any): Array<any> { export function searchSync(query: string, args?: any): Array<any> {
if (!nativeEmojiData) { if (!nativeEmojiData) {
window.log.error('No native emoji data found'); window.log.error('No native emoji data found');

@ -2,42 +2,58 @@ import { isEmpty } from 'lodash';
import { Data } from '../data/data'; import { Data } from '../data/data';
import { MessageModel } from '../models/message'; import { MessageModel } from '../models/message';
import { SignalService } from '../protobuf'; import { SignalService } from '../protobuf';
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import {
getUsBlindedInThatServer,
isUsAnySogsFromCache,
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { UserUtils } from '../session/utils'; 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 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 {
const timestamp = Date.now();
latestReactionTimestamps.push(timestamp);
if (latestReactionTimestamps.length > rateCountLimit) {
const firstTimestamp = latestReactionTimestamps[0];
if (timestamp - firstTimestamp < rateTimeLimit) {
latestReactionTimestamps.pop();
window.log.warn('Only 20 reactions are allowed per minute');
return true;
} else {
latestReactionTimestamps.shift();
}
}
return false;
}
/** /**
* 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) { const collection = await Data.getMessagesBySentAt(originalMessageId);
originalMessage = await Data.getMessageByServerId(originalMessageId); originalMessage = collection.find((item: MessageModel) => {
} else { const messageTimestamp = item.get('sent_at');
const collection = await Data.getMessagesBySentAt(originalMessageId); const author = item.get('source');
originalMessage = collection.find((item: MessageModel) => { return Boolean(
const messageTimestamp = item.get('sent_at'); messageTimestamp &&
const author = item.get('source'); messageTimestamp === originalMessageId &&
return Boolean( author &&
messageTimestamp && author === originalMessageAuthor
messageTimestamp === originalMessageId && );
author && });
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}.`);
@ -48,7 +64,7 @@ const getMessageByReaction = async (
}; };
/** /**
* Sends a Reaction Data Message, don't use for OpenGroups * Sends a Reaction Data Message
*/ */
export const sendMessageReaction = async (messageId: string, emoji: string) => { export const sendMessageReaction = async (messageId: string, emoji: string) => {
const found = await Data.getMessageById(messageId); const found = await Data.getMessageById(messageId);
@ -64,34 +80,29 @@ export const sendMessageReaction = async (messageId: string, emoji: string) => {
return; return;
} }
const timestamp = Date.now(); if (hitRateLimit()) {
latestReactionTimestamps.push(timestamp); return;
}
if (latestReactionTimestamps.length > rateCountLimit) { let me = UserUtils.getOurPubKeyStrFromCache();
const firstTimestamp = latestReactionTimestamps[0]; let id = Number(found.get('sent_at'));
if (timestamp - firstTimestamp < rateTimeLimit) {
latestReactionTimestamps.pop(); if (found.get('isPublic')) {
return; if (found.get('serverId')) {
id = found.get('serverId') || id;
me = getUsBlindedInThatServer(conversationModel) || me;
} else { } else {
latestReactionTimestamps.shift(); window.log.warn(`Server Id was not found in message ${messageId} for opengroup reaction`);
return;
} }
} }
const isOpenGroup = Boolean(found?.get('isPublic'));
const id = (isOpenGroup && found.get('serverId')) || Number(found.get('sent_at'));
const me =
(isOpenGroup && getUsBlindedInThatServer(conversationModel)) ||
UserUtils.getOurPubKeyStrFromCache();
const author = found.get('source'); const author = found.get('source');
let action: Action = Action.REACT; let action: Action = Action.REACT;
const reacts = found.get('reacts'); const reacts = found.get('reacts');
if ( if (reacts?.[emoji]?.senders?.includes(me)) {
reacts && window.log.info('Found matching reaction removing it');
Object.keys(reacts).includes(emoji) &&
Object.keys(reacts[emoji].senders).includes(me)
) {
window.log.info('found matching reaction removing it');
action = Action.REMOVE; action = Action.REMOVE;
} else { } else {
const reactions = getRecentReactions(); const reactions = getRecentReactions();
@ -124,27 +135,32 @@ 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
*/ */
export const handleMessageReaction = async ( export const handleMessageReaction = async (
reaction: SignalService.DataMessage.IReaction, reaction: SignalService.DataMessage.IReaction,
sender: string, sender: string
isOpenGroup: boolean,
messageId?: string
) => { ) => {
if (!reaction.emoji) { if (!reaction.emoji) {
window?.log?.warn(`There is no emoji for the reaction ${messageId}.`); window?.log?.warn(`There is no emoji for the reaction ${reaction}.`);
return; return;
} }
const originalMessage = await getMessageByReaction(reaction, isOpenGroup); const originalMessage = await getMessageByReaction(reaction);
if (!originalMessage) { if (!originalMessage) {
return; return;
} }
if (originalMessage.get('isPublic')) {
window.log.warn("handleMessageReaction() shouldn't be used in opengroups");
return;
}
const reacts: ReactionList = originalMessage.get('reacts') ?? {}; const reacts: ReactionList = originalMessage.get('reacts') ?? {};
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: {} }; reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: [] };
const details = reacts[reaction.emoji] ?? {}; const details = reacts[reaction.emoji] ?? {};
const senders = Object.keys(details.senders); const senders = details.senders;
let count = details.count || 0;
window.log.info( window.log.info(
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${ `${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
@ -153,33 +169,30 @@ export const handleMessageReaction = async (
); );
switch (reaction.action) { switch (reaction.action) {
case SignalService.DataMessage.Reaction.Action.REACT: case Action.REACT:
if (senders.includes(sender) && details.senders[sender] !== '') { if (senders.includes(sender)) {
window?.log?.info( window.log.warn('Received duplicate reaction message. Ignoring it', reaction, sender);
'Received duplicate message reaction. Dropping it. id:',
details.senders[sender]
);
return; return;
} }
details.senders[sender] = messageId ?? ''; details.senders.push(sender);
count += 1;
break; break;
case SignalService.DataMessage.Reaction.Action.REMOVE: case Action.REMOVE:
default: default:
if (senders.length > 0) { if (senders?.length > 0) {
if (senders.indexOf(sender) >= 0) { const sendersIndex = senders.indexOf(sender);
// tslint:disable-next-line: no-dynamic-delete if (sendersIndex >= 0) {
delete details.senders[sender]; details.senders.splice(sendersIndex, 1);
count -= 1;
} }
} }
} }
const count = Object.keys(details.senders).length;
if (count > 0) { if (count > 0) {
reacts[reaction.emoji].count = count; reacts[reaction.emoji].count = count;
reacts[reaction.emoji].senders = details.senders; reacts[reaction.emoji].senders = details.senders;
// sorting for open groups convos is handled by SOGS if (details && details.index === undefined) {
if (!isOpenGroup && details && details.index === undefined) {
reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0; reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0;
originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1); originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1);
} }
@ -197,7 +210,7 @@ export const handleMessageReaction = async (
}; };
/** /**
* Handle all updates to messages reactions from the SOGS API * Handles all message reaction updates for opengroups
*/ */
export const handleOpenGroupMessageReactions = async ( export const handleOpenGroupMessageReactions = async (
reactions: OpenGroupReactionList, reactions: OpenGroupReactionList,
@ -209,6 +222,11 @@ export const handleOpenGroupMessageReactions = async (
return; return;
} }
if (!originalMessage.get('isPublic')) {
window.log.warn('handleOpenGroupMessageReactions() should only be used in opengroups');
return;
}
if (isEmpty(reactions)) { if (isEmpty(reactions)) {
if (originalMessage.get('reacts')) { if (originalMessage.get('reacts')) {
originalMessage.set({ originalMessage.set({
@ -219,11 +237,39 @@ export const handleOpenGroupMessageReactions = async (
const reacts: ReactionList = {}; const reacts: ReactionList = {};
Object.keys(reactions).forEach(key => { Object.keys(reactions).forEach(key => {
const emoji = decodeURI(key); const emoji = decodeURI(key);
const senders: Record<string, string> = {}; const you = reactions[key].you || false;
if (you) {
if (reactions[key]?.reactors.length > 0) {
const reactorsWithoutMe = reactions[key].reactors.filter(
reactor => !isUsAnySogsFromCache(reactor)
);
// If we aren't included in the reactors then remove the extra reactor to match with the SOGSReactorsFetchCount.
if (reactorsWithoutMe.length === SOGSReactorsFetchCount) {
reactorsWithoutMe.pop();
}
const conversationModel = originalMessage?.getConversation();
if (conversationModel) {
const me =
getUsBlindedInThatServer(conversationModel) || UserUtils.getOurPubKeyStrFromCache();
reactions[key].reactors = [me, ...reactorsWithoutMe];
}
}
}
const senders: Array<string> = [];
reactions[key].reactors.forEach(reactor => { reactions[key].reactors.forEach(reactor => {
senders[reactor] = String(serverId); senders.push(reactor);
}); });
reacts[emoji] = { count: reactions[key].count, index: reactions[key].index, senders };
reacts[emoji] = {
count: reactions[key].count,
index: reactions[key].index,
senders,
you,
};
}); });
originalMessage.set({ originalMessage.set({

@ -1,41 +0,0 @@
export const readableList = (
arr: Array<string>,
conjunction: string = '&',
limit: number = 3
): string => {
if (arr.length === 0) {
return '';
}
const count = arr.length;
switch (count) {
case 1:
return arr[0];
default:
let result = '';
let others = 0;
for (let i = 0; i < count; i++) {
if (others === 0 && i === count - 1 && i < limit) {
result += ` ${conjunction} `;
} else if (i !== 0 && i < limit) {
result += ', ';
} else if (i >= limit) {
others++;
}
if (others === 0) {
result += arr[i];
}
}
if (others > 0) {
result += ` ${conjunction} ${others} ${
others > 1
? window.i18n('readableListCounterPlural')
: window.i18n('readableListCounterSingular')
}`;
}
return result;
}
};
Loading…
Cancel
Save