From 906f7679de2d34b9e84404dc40d685e7802f1ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= <39538890+Skalakid@users.noreply.github.com> Date: Tue, 7 Jan 2025 03:49:48 -0800 Subject: [PATCH] Prevent italic or strikethrough emojis on Android (#591) --- src/__tests__/splitRangesOnEmojis.test.ts | 163 ++++++++++++++++++++++ src/parseExpensiMark.ts | 9 +- src/rangeUtils.ts | 60 +++++++- 3 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/splitRangesOnEmojis.test.ts diff --git a/src/__tests__/splitRangesOnEmojis.test.ts b/src/__tests__/splitRangesOnEmojis.test.ts new file mode 100644 index 00000000..a9eb11c3 --- /dev/null +++ b/src/__tests__/splitRangesOnEmojis.test.ts @@ -0,0 +1,163 @@ +import type {MarkdownRange} from '../commonTypes'; +import {splitRangesOnEmojis} from '../rangeUtils'; + +const sortRanges = (ranges: MarkdownRange[]) => { + return ranges.sort((a, b) => a.start - b.start); +}; + +test('no overlap', () => { + const markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 12, length: 2}, + ]; + + const splittedRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + expect(splittedRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 12, length: 2}, + ]); +}); + +test('overlap different type', () => { + const markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 3, length: 4}, + ]; + + const splittedRanges = splitRangesOnEmojis(markdownRanges, 'italic'); + expect(splittedRanges).toEqual(markdownRanges); +}); + +describe('single overlap', () => { + test('emoji at the beginning', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 0, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'emoji', start: 0, length: 2}, + {type: 'strikethrough', start: 2, length: 8}, + ]); + }); + + test('emoji in the middle', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 3, length: 4}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 3}, + {type: 'emoji', start: 3, length: 4}, + {type: 'strikethrough', start: 7, length: 3}, + ]); + }); + + test('emoji at the end', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 8, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 8}, + {type: 'emoji', start: 8, length: 2}, + ]); + }); + + test('multiple emojis in the middle', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 10}, + {type: 'emoji', start: 3, length: 2}, + {type: 'emoji', start: 5, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'strikethrough', start: 0, length: 3}, + {type: 'emoji', start: 3, length: 2}, + {type: 'emoji', start: 5, length: 2}, + {type: 'strikethrough', start: 7, length: 3}, + ]); + }); + + test('just emojis', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'strikethrough', start: 0, length: 6}, + {type: 'emoji', start: 0, length: 2}, + {type: 'emoji', start: 2, length: 2}, + {type: 'emoji', start: 4, length: 2}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + + expect(markdownRanges).toEqual([ + {type: 'emoji', start: 0, length: 2}, + {type: 'emoji', start: 2, length: 2}, + {type: 'emoji', start: 4, length: 2}, + ]); + }); +}); + +describe('multiple overlaps', () => { + test('splitting on one type', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'italic', start: 0, length: 20}, + {type: 'strikethrough', start: 2, length: 12}, + {type: 'emoji', start: 3, length: 1}, + {type: 'emoji', start: 8, length: 2}, + {type: 'strikethrough', start: 22, length: 5}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'italic', start: 0, length: 20}, + {type: 'strikethrough', start: 2, length: 1}, + {type: 'emoji', start: 3, length: 1}, + {type: 'strikethrough', start: 4, length: 4}, + {type: 'emoji', start: 8, length: 2}, + {type: 'strikethrough', start: 10, length: 4}, + {type: 'strikethrough', start: 22, length: 5}, + ]); + }); + + test('splitting on two types', () => { + let markdownRanges: MarkdownRange[] = [ + {type: 'italic', start: 0, length: 20}, + {type: 'strikethrough', start: 2, length: 12}, + {type: 'emoji', start: 3, length: 1}, + {type: 'emoji', start: 8, length: 2}, + {type: 'strikethrough', start: 22, length: 5}, + ]; + + markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); + sortRanges(markdownRanges); + + expect(markdownRanges).toEqual([ + {type: 'italic', start: 0, length: 3}, + {type: 'strikethrough', start: 2, length: 1}, + {type: 'emoji', start: 3, length: 1}, + {type: 'italic', start: 4, length: 4}, + {type: 'strikethrough', start: 4, length: 4}, + {type: 'emoji', start: 8, length: 2}, + {type: 'italic', start: 10, length: 10}, + {type: 'strikethrough', start: 10, length: 4}, + {type: 'strikethrough', start: 22, length: 5}, + ]); + }); +}); diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index 7073d349..ecdbbd20 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -6,7 +6,7 @@ import {unescapeText} from 'expensify-common/dist/utils'; import {decode} from 'html-entities'; import type {WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; import type {MarkdownType, MarkdownRange} from './commonTypes'; -import {groupRanges, sortRanges} from './rangeUtils'; +import {groupRanges, sortRanges, splitRangesOnEmojis} from './rangeUtils'; function isWeb() { return Platform.OS === 'web'; @@ -250,8 +250,13 @@ function parseExpensiMark(markdown: string): MarkdownRange[] { ); return []; } - const sortedRanges = sortRanges(ranges); + + let splittedRanges = splitRangesOnEmojis(ranges, 'italic'); + splittedRanges = splitRangesOnEmojis(splittedRanges, 'strikethrough'); + + const sortedRanges = sortRanges(splittedRanges); const groupedRanges = groupRanges(sortedRanges); + return groupedRanges; } diff --git a/src/rangeUtils.ts b/src/rangeUtils.ts index dcfb913f..f47c4aaa 100644 --- a/src/rangeUtils.ts +++ b/src/rangeUtils.ts @@ -1,3 +1,5 @@ +'worklet'; + import type {MarkdownRange, MarkdownType} from './commonTypes'; // getTagPriority returns a priority for a tag, higher priority means the tag should be processed first @@ -53,4 +55,60 @@ function ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { return ungroupedRanges; } -export {sortRanges, groupRanges, ungroupRanges}; +function splitRangesOnEmojis(ranges: MarkdownRange[], type: MarkdownType): MarkdownRange[] { + const emojiRanges: MarkdownRange[] = ranges.filter((range) => range.type === 'emoji'); + const newRanges: MarkdownRange[] = []; + + let i = 0; + let j = 0; + while (i < ranges.length) { + const currentRange = ranges[i]; + if (!currentRange) { + break; + } + + if (currentRange.type !== type) { + newRanges.push(currentRange); + i++; + } else { + // Iterate through all emoji ranges before the end of the current range, splitting the current range at each intersection. + while (j < emojiRanges.length) { + const emojiRange = emojiRanges[j]; + if (!emojiRange || emojiRange.start > currentRange.start + currentRange.length) { + break; + } + + const currentStart: number = currentRange.start; + const currentEnd: number = currentRange.start + currentRange.length; + const emojiStart: number = emojiRange.start; + const emojiEnd: number = emojiRange.start + emojiRange.length; + + if (emojiStart >= currentStart && emojiEnd <= currentEnd) { + // Intersection + const newRange: MarkdownRange = { + type: currentRange.type, + start: currentStart, + length: emojiStart - currentStart, + ...(currentRange?.depth && {depth: currentRange?.depth}), + }; + + currentRange.start = emojiEnd; + currentRange.length = currentEnd - emojiEnd; + + if (newRange.length > 0) { + newRanges.push(newRange); + } + } + j++; + } + + if (currentRange.length > 0) { + newRanges.push(currentRange); + } + i++; + } + } + return newRanges; +} + +export {sortRanges, groupRanges, ungroupRanges, splitRangesOnEmojis};