dappros/ethora

View on GitHub
client-reactnative/src/helpers/chat/inputUtils.ts

Summary

Maintainability
D
2 days
Test Coverage
import {diffChars} from 'diff';
import {StyleProp, TextStyle} from 'react-native';
// @ts-ignore the lib do not have TS declarations yet
import matchAll from 'string.prototype.matchall';
import {
  CharactersDiffChange,
  MentionData,
  MentionPartType,
  Part,
  PartType,
  Position,
  RegexMatchResult,
  Suggestion,
} from './inputTypes';

/**
 * RegEx grouped results. Example - "@[Full Name](123abc)"
 * We have 4 groups here:
 * - The whole original string - "@[Full Name](123abc)"
 * - Mention trigger - "@"
 * - Name - "Full Name"
 * - Id - "123abc"
 */
const mentionRegEx = /((.)\[([^[]*)]\(([^(^)]*)\))/gi;

const defaultMentionTextStyle: StyleProp<TextStyle> = {
  fontWeight: 'bold',
  color: 'blue',
};

const defaultPlainStringGenerator = (
  {trigger}: MentionPartType,
  {name}: MentionData,
) => `${trigger}${name}`;

const isMentionPartType = (partType: PartType): partType is MentionPartType => {
  return (partType as MentionPartType).trigger != null;
};

const getPartIndexByCursor = (
  parts: Part[],
  cursor: number,
  isIncludeEnd?: boolean,
) => {
  return parts.findIndex(one =>
    cursor >= one.position.start && isIncludeEnd
      ? cursor <= one.position.end
      : cursor < one.position.end,
  );
};

/**
 * The method for getting parts between two cursor positions.
 * ```
 * | part1 |   part2   |   part3   |
 *  a b c|d e f g h i j h k|l m n o
 *  ```
 *  We will get 3 parts here:
 *  1. Part included 'd'
 *  2. Part included 'efghij'
 *  3. Part included 'hk'
 *  Cursor will move to position after 'k'
 *
 * @param parts full part list
 * @param cursor current cursor position
 * @param count count of characters that didn't change
 */
const getPartsInterval = (
  parts: Part[],
  cursor: number,
  count: number,
): Part[] => {
  const newCursor = cursor + count;

  const currentPartIndex = getPartIndexByCursor(parts, cursor);
  const currentPart = parts[currentPartIndex];

  const newPartIndex = getPartIndexByCursor(parts, newCursor, true);
  const newPart = parts[newPartIndex];

  let partsInterval: Part[] = [];

  if (!currentPart || !newPart) {
    return partsInterval;
  }

  // Push whole first affected part or sub-part of the first affected part
  if (
    currentPart.position.start === cursor &&
    currentPart.position.end <= newCursor
  ) {
    partsInterval.push(currentPart);
  } else {
    partsInterval.push(
      generatePlainTextPart(
        currentPart.text.substr(cursor - currentPart.position.start, count),
      ),
    );
  }

  if (newPartIndex > currentPartIndex) {
    // Concat fully included parts
    partsInterval = partsInterval.concat(
      parts.slice(currentPartIndex + 1, newPartIndex),
    );

    // Push whole last affected part or sub-part of the last affected part
    if (
      newPart.position.end === newCursor &&
      newPart.position.start >= cursor
    ) {
      partsInterval.push(newPart);
    } else {
      partsInterval.push(
        generatePlainTextPart(
          newPart.text.substr(0, newCursor - newPart.position.start),
        ),
      );
    }
  }

  return partsInterval;
};

/**
 * Function for getting object with keyword for each mention part type
 *
 * If keyword is undefined then we don't tracking mention typing and shouldn't show suggestions.
 * If keyword is not undefined (even empty string '') then we are tracking mention typing.
 *
 * Examples where @name is just plain text yet, not mention:
 * '|abc @name dfg' - keyword is undefined
 * 'abc @| dfg' - keyword is ''
 * 'abc @name| dfg' - keyword is 'name'
 * 'abc @na|me dfg' - keyword is 'na'
 * 'abc @|name dfg' - keyword is against ''
 * 'abc @name |dfg' - keyword is 'name '
 * 'abc @name dfg|' - keyword is 'name dfg'
 * 'abc @name dfg |' - keyword is undefined (we have more than one space)
 * 'abc @name dfg he|' - keyword is undefined (we have more than one space)
 */
const getMentionPartSuggestionKeywords = (
  parts: Part[],
  plainText: string,
  selection: Position,
  partTypes: PartType[],
): {[trigger: string]: string | undefined} => {
  const keywordByTrigger: {[trigger: string]: string | undefined} = {};

  partTypes
    .filter(isMentionPartType)
    .forEach(({trigger, allowedSpacesCount = 1}) => {
      keywordByTrigger[trigger] = undefined;

      // Check if we don't have selection range
      if (selection.end != selection.start) {
        return;
      }

      // Find the part with the cursor
      const part = parts.find(
        one =>
          selection.end >= one.position.start &&
          selection.end <= one.position.end,
      );

      // Check if the cursor is not in mention type part
      if (part == null || part.data != null) {
        return;
      }

      const triggerIndex = plainText.lastIndexOf(trigger, selection.end);
      // Return undefined in case when:
      if (
        // - the trigger index is not event found
        triggerIndex == -1 ||
        // - the trigger index is out of found part with selection cursor
        triggerIndex < part.position.start ||
        // - the trigger is not at the beginning and we don't have space or new line before trigger
        (triggerIndex > 0 && !/[\s\n]/gi.test(plainText[triggerIndex - 1]))
      ) {
        return;
      }

      // Looking for break lines and spaces between the current cursor and trigger
      let spacesCount = 0;
      for (
        let cursor = selection.end - 1;
        cursor >= triggerIndex;
        cursor -= 1
      ) {
        // Mention cannot have new line
        if (plainText[cursor] === '\n') {
          return;
        }

        // Incrementing space counter if the next symbol is space
        if (plainText[cursor] === ' ') {
          spacesCount += 1;

          // Check maximum allowed spaces in trigger word
          if (spacesCount > allowedSpacesCount) {
            return;
          }
        }
      }

      keywordByTrigger[trigger] = plainText.substring(
        triggerIndex + 1,
        selection.end,
      );
    });

  return keywordByTrigger;
};

/**
 * Generates new value when we changing text.
 *
 * @param parts full parts list
 * @param originalText original plain text
 * @param changedText changed plain text
 */
const generateValueFromPartsAndChangedText = (
  parts: Part[],
  originalText: string,
  changedText: string,
) => {
  const changes = diffChars(
    originalText,
    changedText,
  ) as CharactersDiffChange[];

  let newParts: Part[] = [];

  let cursor = 0;

  changes.forEach(change => {
    switch (true) {
      /**
       * We should:
       * - Move cursor forward on the changed text length
       */
      case change.removed: {
        cursor += change.count;

        break;
      }

      /**
       * We should:
       * - Push new part to the parts with that new text
       */
      case change.added: {
        newParts.push(generatePlainTextPart(change.value));

        break;
      }

      /**
       * We should concat parts that didn't change.
       * - In case when we have only one affected part we should push only that one sub-part
       * - In case we have two affected parts we should push first
       */
      default: {
        if (change.count !== 0) {
          newParts = newParts.concat(
            getPartsInterval(parts, cursor, change.count),
          );

          cursor += change.count;
        }

        break;
      }
    }
  });

  return getValueFromParts(newParts);
};

/**
 * Method for adding suggestion to the parts and generating value. We should:
 * - Find part with plain text where we were tracking mention typing using selection state
 * - Split the part to next parts:
 * -* Before new mention
 * -* With new mention
 * -* After mention with space at the beginning
 * - Generate new parts array and convert it to value
 *
 * @param parts - full part list
 * @param mentionType - actually the mention type
 * @param plainText - current plain text
 * @param selection - current selection
 * @param suggestion - suggestion that should be added
 */
const generateValueWithAddedSuggestion = (
  parts: Part[],
  mentionType: MentionPartType,
  plainText: string,
  selection: Position,
  suggestion: Suggestion,
): string | undefined => {
  const currentPartIndex = parts.findIndex(
    one =>
      selection.end >= one.position.start && selection.end <= one.position.end,
  );
  const currentPart = parts[currentPartIndex];

  if (!currentPart) {
    return;
  }

  const triggerPartIndex = currentPart.text.lastIndexOf(
    mentionType.trigger,
    selection.end - currentPart.position.start,
  );

  const newMentionPartPosition: Position = {
    start: triggerPartIndex,
    end: selection.end - currentPart.position.start,
  };

  const isInsertSpaceToNextPart =
    mentionType.isInsertSpaceAfterMention &&
    // Cursor is at the very end of parts or text row
    (plainText.length === selection.end ||
      parts[currentPartIndex]?.text.startsWith(
        '\n',
        newMentionPartPosition.end,
      ));

  return getValueFromParts([
    ...parts.slice(0, currentPartIndex),

    // Create part with string before mention
    generatePlainTextPart(
      currentPart.text.substring(0, newMentionPartPosition.start),
    ),
    generateMentionPart(mentionType, {
      original: getMentionValue(mentionType.trigger, suggestion),
      trigger: mentionType.trigger,
      ...suggestion,
    }),

    // Create part with rest of string after mention and add a space if needed
    generatePlainTextPart(
      `${isInsertSpaceToNextPart ? ' ' : ''}${currentPart.text.substring(
        newMentionPartPosition.end,
      )}`,
    ),

    ...parts.slice(currentPartIndex + 1),
  ]);
};

/**
 * Method for generating part for plain text
 *
 * @param text - plain text that will be added to the part
 * @param positionOffset - position offset from the very beginning of text
 */
const generatePlainTextPart = (text: string, positionOffset = 0): Part => ({
  text,
  position: {
    start: positionOffset,
    end: positionOffset + text.length,
  },
});

/**
 * Method for generating part for mention
 *
 * @param mentionPartType
 * @param mention - mention data
 * @param positionOffset - position offset from the very beginning of text
 */
const generateMentionPart = (
  mentionPartType: MentionPartType,
  mention: MentionData,
  positionOffset = 0,
): Part => {
  const text = mentionPartType.getPlainString
    ? mentionPartType.getPlainString(mention)
    : defaultPlainStringGenerator(mentionPartType, mention);

  return {
    text,
    position: {
      start: positionOffset,
      end: positionOffset + text.length,
    },
    partType: mentionPartType,
    data: mention,
  };
};

/**
 * Generates part for matched regex result
 *
 * @param partType - current part type (pattern or mention)
 * @param result - matched regex result
 * @param positionOffset - position offset from the very beginning of text
 */
const generateRegexResultPart = (
  partType: PartType,
  result: RegexMatchResult,
  positionOffset = 0,
): Part => ({
  text: result[0],
  position: {
    start: positionOffset,
    end: positionOffset + result[0].length,
  },
  partType,
});

/**
 * Method for generation mention value that accepts mention regex
 *
 * @param trigger
 * @param suggestion
 */
const getMentionValue = (trigger: string, suggestion: Suggestion) =>
  // `${trigger}${suggestion.name}`;

  `${trigger}[${suggestion.name}](${suggestion.id})`;

const getMentionDataFromRegExMatchResult = ([
  ,
  original,
  trigger,
  name,
  id,
]: RegexMatchResult): MentionData => ({
  original,
  trigger,
  name,
  id,
});

/**
 * Recursive function for deep parse MentionInput's value and get plainText with parts
 *
 * @param value - the MentionInput's value
 * @param partTypes - All provided part types
 * @param positionOffset - offset from the very beginning of plain text
 */
const parseValue = (
  value: string,
  partTypes: PartType[],
  positionOffset = 0,
): {plainText: string; parts: Part[]} => {
  if (value == null) {
    value = '';
  }

  let plainText = '';
  let parts: Part[] = [];

  // We don't have any part types so adding just plain text part
  if (partTypes.length === 0) {
    plainText += value;
    parts.push(generatePlainTextPart(value, positionOffset));
  } else {
    const [partType, ...restPartTypes] = partTypes;

    const regex = isMentionPartType(partType) ? mentionRegEx : partType.pattern;

    const matches: RegexMatchResult[] = Array.from(
      matchAll(value ?? '', regex),
    );

    // In case when we didn't get any matches continue parsing value with rest part types
    if (matches.length === 0) {
      return parseValue(value, restPartTypes, positionOffset);
    }

    // In case when we have some text before matched part parsing the text with rest part types
    if (matches[0].index != 0) {
      const text = value.substr(0, matches[0].index);

      const plainTextAndParts = parseValue(text, restPartTypes, positionOffset);
      parts = parts.concat(plainTextAndParts.parts);
      plainText += plainTextAndParts.plainText;
    }

    // Iterating over all found pattern matches
    for (let i = 0; i < matches.length; i++) {
      const result = matches[i];

      if (isMentionPartType(partType)) {
        const mentionData = getMentionDataFromRegExMatchResult(result);

        // Matched pattern is a mention and the mention doesn't match current mention type
        // We should parse the mention with rest part types
        if (mentionData.trigger !== partType.trigger) {
          const plainTextAndParts = parseValue(
            mentionData.original,
            restPartTypes,
            positionOffset + plainText.length,
          );
          parts = parts.concat(plainTextAndParts.parts);
          plainText += plainTextAndParts.plainText;
        } else {
          const part = generateMentionPart(
            partType,
            mentionData,
            positionOffset + plainText.length,
          );

          parts.push(part);

          plainText += part.text;
        }
      } else {
        const part = generateRegexResultPart(
          partType,
          result,
          positionOffset + plainText.length,
        );

        parts.push(part);

        plainText += part.text;
      }

      // Check if the result is not at the end of whole value so we have a text after matched part
      // We should parse the text with rest part types
      if (result.index + result[0].length !== value.length) {
        // Check if it is the last result
        const isLastResult = i === matches.length - 1;

        // So we should to add the last substring of value after matched mention
        const text = value.slice(
          result.index + result[0].length,
          isLastResult ? undefined : matches[i + 1].index,
        );

        const plainTextAndParts = parseValue(
          text,
          restPartTypes,
          positionOffset + plainText.length,
        );
        parts = parts.concat(plainTextAndParts.parts);
        plainText += plainTextAndParts.plainText;
      }
    }
  }

  // Exiting from parseValue
  return {
    plainText,
    parts,
  };
};

/**
 * Function for generation value from parts array
 *
 * @param parts
 */
const getValueFromParts = (parts: Part[]) =>
  parts.map(item => (item.data ? item.data.original : item.text)).join('');

/**
 * Replace all mention values in value to some specified format
 *
 * @param value - value that is generated by MentionInput component
 * @param replacer - function that takes mention object as parameter and returns string
 */
const replaceMentionValues = (
  value: string,
  replacer: (mention: MentionData) => string,
) =>
  value.replace(mentionRegEx, (fullMatch, original, trigger, name, id) =>
    replacer({
      original,
      trigger,
      name,
      id,
    }),
  );

export {
  mentionRegEx,
  defaultMentionTextStyle,
  isMentionPartType,
  getMentionPartSuggestionKeywords,
  generateValueFromPartsAndChangedText,
  generateValueWithAddedSuggestion,
  generatePlainTextPart,
  generateMentionPart,
  getMentionValue,
  parseValue,
  getValueFromParts,
  replaceMentionValues,
};