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.
		
		
		
		
		
			
		
			
				
	
	
		
			362 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			362 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
import { isEmpty } from 'lodash';
 | 
						|
import { Data } from '../data/data';
 | 
						|
import { MessageModel } from '../models/message';
 | 
						|
import { SignalService } from '../protobuf';
 | 
						|
import { 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 './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;
 | 
						|
    }
 | 
						|
    latestReactionTimestamps.shift();
 | 
						|
  }
 | 
						|
  return false;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Retrieves the original message of a reaction
 | 
						|
 */
 | 
						|
const getMessageByReaction = async (
 | 
						|
  reaction: SignalService.DataMessage.IReaction,
 | 
						|
  openGroupConversationId?: string
 | 
						|
): Promise<MessageModel | null> => {
 | 
						|
  let originalMessage = null;
 | 
						|
  const originalMessageId = Number(reaction.id);
 | 
						|
  const originalMessageAuthor = reaction.author;
 | 
						|
 | 
						|
  if (openGroupConversationId && !isEmpty(openGroupConversationId)) {
 | 
						|
    originalMessage = await Data.getMessageByServerId(openGroupConversationId, 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?.debug(`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 undefined;
 | 
						|
    }
 | 
						|
 | 
						|
    if (!conversationModel.hasReactions()) {
 | 
						|
      window.log.warn("This conversation doesn't have reaction support");
 | 
						|
      return undefined;
 | 
						|
    }
 | 
						|
 | 
						|
    if (hitRateLimit()) {
 | 
						|
      ToastUtils.pushRateLimitHitReactions();
 | 
						|
      return undefined;
 | 
						|
    }
 | 
						|
 | 
						|
    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 = conversationModel.getUsInThatConversation();
 | 
						|
      } else {
 | 
						|
        window.log.warn(`Server Id was not found in message ${messageId} for opengroup reaction`);
 | 
						|
        return undefined;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    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.id}` : ''
 | 
						|
    );
 | 
						|
    return reaction;
 | 
						|
  }
 | 
						|
  window.log.warn(`Message ${messageId} not found in db`);
 | 
						|
  return undefined;
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * 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,
 | 
						|
  openGroupConversationId,
 | 
						|
}: {
 | 
						|
  reaction: SignalService.DataMessage.IReaction;
 | 
						|
  sender: string;
 | 
						|
  you: boolean;
 | 
						|
  openGroupConversationId?: string;
 | 
						|
}) => {
 | 
						|
  if (!reaction.emoji) {
 | 
						|
    window?.log?.warn(`There is no emoji for the reaction ${reaction}.`);
 | 
						|
    return undefined;
 | 
						|
  }
 | 
						|
 | 
						|
  const originalMessage = await getMessageByReaction(reaction, openGroupConversationId);
 | 
						|
  if (!originalMessage) {
 | 
						|
    return undefined;
 | 
						|
  }
 | 
						|
 | 
						|
  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 undefined;
 | 
						|
    }
 | 
						|
    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 undefined;
 | 
						|
      }
 | 
						|
      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 {
 | 
						|
    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 (conversationId: string, serverId: number, emoji: string) => {
 | 
						|
  const originalMessage = await Data.getMessageByServerId(conversationId, serverId);
 | 
						|
  if (!originalMessage) {
 | 
						|
    window?.log?.debug(
 | 
						|
      `Cannot find the original reacted message ${serverId} in conversation ${conversationId}.`
 | 
						|
    );
 | 
						|
    return undefined;
 | 
						|
  }
 | 
						|
 | 
						|
  const reacts: ReactionList | undefined = originalMessage.get('reacts');
 | 
						|
  if (reacts) {
 | 
						|
    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
 | 
						|
 * serverIds are not unique so we need the conversationId
 | 
						|
 */
 | 
						|
const handleOpenGroupMessageReactions = async (
 | 
						|
  conversationId: string,
 | 
						|
  serverId: number,
 | 
						|
  reactions: OpenGroupReactionList
 | 
						|
) => {
 | 
						|
  const originalMessage = await Data.getMessageByServerId(conversationId, serverId);
 | 
						|
  if (!originalMessage) {
 | 
						|
    window?.log?.debug(
 | 
						|
      `Cannot find the original reacted message ${serverId} in conversation ${conversationId}.`
 | 
						|
    );
 | 
						|
    return undefined;
 | 
						|
  }
 | 
						|
 | 
						|
  if (!originalMessage.get('isPublic')) {
 | 
						|
    window.log.warn('handleOpenGroupMessageReactions() should only be used in opengroups');
 | 
						|
    return undefined;
 | 
						|
  }
 | 
						|
 | 
						|
  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 =
 | 
						|
              conversationModel.getUsInThatConversation() || UserUtils.getOurPubKeyStrFromCache();
 | 
						|
            // eslint-disable-next-line no-param-reassign
 | 
						|
            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 {
 | 
						|
        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,
 | 
						|
};
 |