You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
import { isEmpty } from 'lodash';
|
|
import { Data } from '../data/data';
|
|
import { MessageModel } from '../models/message';
|
|
import { SignalService } from '../protobuf';
|
|
import {
|
|
getUsBlindedInThatServer,
|
|
isUsAnySogsFromCache,
|
|
} from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
|
|
import { ToastUtils, UserUtils } from '../session/utils';
|
|
|
|
import { Action, OpenGroupReactionList, ReactionList, RecentReactions } from '../types/Reaction';
|
|
import { getRecentReactions, saveRecentReations } from '../util/storage';
|
|
|
|
const SOGSReactorsFetchCount = 5;
|
|
const rateCountLimit = 20;
|
|
const rateTimeLimit = 60 * 1000;
|
|
const latestReactionTimestamps: Array<number> = [];
|
|
|
|
function hitRateLimit(): boolean {
|
|
const now = Date.now();
|
|
latestReactionTimestamps.push(now);
|
|
|
|
if (latestReactionTimestamps.length > rateCountLimit) {
|
|
const firstTimestamp = latestReactionTimestamps[0];
|
|
if (now - firstTimestamp < rateTimeLimit) {
|
|
latestReactionTimestamps.pop();
|
|
window.log.warn(`Only ${rateCountLimit} reactions are allowed per minute`);
|
|
return true;
|
|
} else {
|
|
latestReactionTimestamps.shift();
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the original message of a reaction
|
|
*/
|
|
const getMessageByReaction = async (
|
|
reaction: SignalService.DataMessage.IReaction,
|
|
isOpenGroup: boolean
|
|
): Promise<MessageModel | null> => {
|
|
let originalMessage = null;
|
|
const originalMessageId = Number(reaction.id);
|
|
const originalMessageAuthor = reaction.author;
|
|
|
|
if (isOpenGroup) {
|
|
originalMessage = await Data.getMessageByServerId(originalMessageId);
|
|
} else {
|
|
const collection = await Data.getMessagesBySentAt(originalMessageId);
|
|
originalMessage = collection.find((item: MessageModel) => {
|
|
const messageTimestamp = item.get('sent_at');
|
|
const author = item.get('source');
|
|
return Boolean(
|
|
messageTimestamp &&
|
|
messageTimestamp === originalMessageId &&
|
|
author &&
|
|
author === originalMessageAuthor
|
|
);
|
|
});
|
|
}
|
|
|
|
if (!originalMessage) {
|
|
window?.log?.warn(`Cannot find the original reacted message ${originalMessageId}.`);
|
|
return null;
|
|
}
|
|
|
|
return originalMessage;
|
|
};
|
|
|
|
/**
|
|
* Sends a Reaction Data Message
|
|
*/
|
|
const sendMessageReaction = async (messageId: string, emoji: string) => {
|
|
const found = await Data.getMessageById(messageId);
|
|
if (found) {
|
|
const conversationModel = found?.getConversation();
|
|
if (!conversationModel) {
|
|
window.log.warn(`Conversation for ${messageId} not found in db`);
|
|
return;
|
|
}
|
|
|
|
if (!conversationModel.hasReactions()) {
|
|
window.log.warn("This conversation doesn't have reaction support");
|
|
return;
|
|
}
|
|
|
|
if (hitRateLimit()) {
|
|
ToastUtils.pushRateLimitHitReactions();
|
|
return;
|
|
}
|
|
|
|
let me = UserUtils.getOurPubKeyStrFromCache();
|
|
let id = Number(found.get('sent_at'));
|
|
|
|
if (found.get('isPublic')) {
|
|
if (found.get('serverId')) {
|
|
id = found.get('serverId') || id;
|
|
me = getUsBlindedInThatServer(conversationModel) || me;
|
|
} else {
|
|
window.log.warn(`Server Id was not found in message ${messageId} for opengroup reaction`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const author = found.get('source');
|
|
let action: Action = Action.REACT;
|
|
|
|
const reacts = found.get('reacts');
|
|
if (reacts?.[emoji]?.senders?.includes(me)) {
|
|
window.log.info('Found matching reaction removing it');
|
|
action = Action.REMOVE;
|
|
} else {
|
|
const reactions = getRecentReactions();
|
|
if (reactions) {
|
|
await updateRecentReactions(reactions, emoji);
|
|
}
|
|
}
|
|
|
|
const reaction = {
|
|
id,
|
|
author,
|
|
emoji,
|
|
action,
|
|
};
|
|
|
|
await conversationModel.sendReaction(messageId, reaction);
|
|
|
|
window.log.info(
|
|
`You ${action === Action.REACT ? 'added' : 'removed'} a`,
|
|
emoji,
|
|
'reaction for message',
|
|
id,
|
|
found.get('isPublic')
|
|
? `on ${conversationModel.toOpenGroupV2().serverUrl}/${
|
|
conversationModel.toOpenGroupV2().roomId
|
|
}`
|
|
: ''
|
|
);
|
|
return reaction;
|
|
} else {
|
|
window.log.warn(`Message ${messageId} not found in db`);
|
|
return;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle reactions on the client by updating the state of the source message
|
|
* Used in OpenGroups for sending reactions only, not handling responses
|
|
*/
|
|
const handleMessageReaction = async ({
|
|
reaction,
|
|
sender,
|
|
you,
|
|
isOpenGroup,
|
|
}: {
|
|
reaction: SignalService.DataMessage.IReaction;
|
|
sender: string;
|
|
you: boolean;
|
|
isOpenGroup: boolean;
|
|
}) => {
|
|
if (!reaction.emoji) {
|
|
window?.log?.warn(`There is no emoji for the reaction ${reaction}.`);
|
|
return;
|
|
}
|
|
|
|
const originalMessage = await getMessageByReaction(reaction, isOpenGroup);
|
|
if (!originalMessage) {
|
|
return;
|
|
}
|
|
|
|
const reacts: ReactionList = originalMessage.get('reacts') ?? {};
|
|
reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: [] };
|
|
const details = reacts[reaction.emoji] ?? {};
|
|
const senders = details.senders;
|
|
let count = details.count || 0;
|
|
|
|
if (details.you && senders.includes(sender)) {
|
|
if (reaction.action === Action.REACT) {
|
|
window.log.warn('Received duplicate message for your reaction. Ignoring it');
|
|
return;
|
|
} else {
|
|
details.you = false;
|
|
}
|
|
} else {
|
|
details.you = you;
|
|
}
|
|
|
|
switch (reaction.action) {
|
|
case Action.REACT:
|
|
if (senders.includes(sender)) {
|
|
window.log.warn('Received duplicate reaction message. Ignoring it', reaction, sender);
|
|
return;
|
|
}
|
|
details.senders.push(sender);
|
|
count += 1;
|
|
break;
|
|
case Action.REMOVE:
|
|
default:
|
|
if (senders?.length > 0) {
|
|
const sendersIndex = senders.indexOf(sender);
|
|
if (sendersIndex >= 0) {
|
|
details.senders.splice(sendersIndex, 1);
|
|
count -= 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (count > 0) {
|
|
reacts[reaction.emoji].count = count;
|
|
reacts[reaction.emoji].senders = details.senders;
|
|
reacts[reaction.emoji].you = details.you;
|
|
|
|
if (details && details.index === undefined) {
|
|
reacts[reaction.emoji].index = originalMessage.get('reactsIndex') ?? 0;
|
|
originalMessage.set('reactsIndex', (originalMessage.get('reactsIndex') ?? 0) + 1);
|
|
}
|
|
} else {
|
|
// tslint:disable-next-line: no-dynamic-delete
|
|
delete reacts[reaction.emoji];
|
|
}
|
|
|
|
originalMessage.set({
|
|
reacts: !isEmpty(reacts) ? reacts : undefined,
|
|
});
|
|
|
|
await originalMessage.commit();
|
|
|
|
if (!you) {
|
|
window.log.info(
|
|
`${sender} ${reaction.action === Action.REACT ? 'added' : 'removed'} a ${
|
|
reaction.emoji
|
|
} reaction`
|
|
);
|
|
}
|
|
return originalMessage;
|
|
};
|
|
|
|
/**
|
|
* Handles updating the UI when clearing all reactions for a certain emoji
|
|
* Only usable by moderators in opengroups and runs on their client
|
|
*/
|
|
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,
|
|
serverId: number
|
|
) => {
|
|
const originalMessage = await Data.getMessageByServerId(serverId);
|
|
if (!originalMessage) {
|
|
window?.log?.warn(`Cannot find the original reacted message ${serverId}.`);
|
|
return;
|
|
}
|
|
|
|
if (!originalMessage.get('isPublic')) {
|
|
window.log.warn('handleOpenGroupMessageReactions() should only be used in opengroups');
|
|
return;
|
|
}
|
|
|
|
if (isEmpty(reactions)) {
|
|
if (originalMessage.get('reacts')) {
|
|
originalMessage.set({
|
|
reacts: undefined,
|
|
});
|
|
}
|
|
} else {
|
|
const reacts: ReactionList = {};
|
|
Object.keys(reactions).forEach(key => {
|
|
const emoji = decodeURI(key);
|
|
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 => {
|
|
senders.push(reactor);
|
|
});
|
|
|
|
if (reactions[key].count > 0) {
|
|
reacts[emoji] = {
|
|
count: reactions[key].count,
|
|
index: reactions[key].index,
|
|
senders,
|
|
you,
|
|
};
|
|
} else {
|
|
// tslint:disable-next-line: no-dynamic-delete
|
|
delete reacts[key];
|
|
}
|
|
});
|
|
|
|
originalMessage.set({
|
|
reacts,
|
|
});
|
|
}
|
|
|
|
await originalMessage.commit();
|
|
return originalMessage;
|
|
};
|
|
|
|
const updateRecentReactions = async (reactions: Array<string>, newReaction: string) => {
|
|
window?.log?.info('updating recent reactions with', newReaction);
|
|
const recentReactions = new RecentReactions(reactions);
|
|
const foundIndex = recentReactions.items.indexOf(newReaction);
|
|
if (foundIndex === 0) {
|
|
return;
|
|
}
|
|
if (foundIndex > 0) {
|
|
recentReactions.swap(foundIndex);
|
|
} else {
|
|
recentReactions.push(newReaction);
|
|
}
|
|
await saveRecentReations(recentReactions.items);
|
|
};
|
|
|
|
// exported for testing purposes
|
|
export const Reactions = {
|
|
SOGSReactorsFetchCount,
|
|
hitRateLimit,
|
|
sendMessageReaction,
|
|
handleMessageReaction,
|
|
handleClearReaction,
|
|
handleOpenGroupMessageReactions,
|
|
updateRecentReactions,
|
|
};
|