Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/libs/EmojiTrie.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import emojis, {importEmojiLocale, localeEmojis} from '@assets/emojis';
import type {Emoji, HeaderEmoji} from '@assets/emojis/types';
import type {Emoji} from '@assets/emojis/types';

import CONST from '@src/CONST';
import {FULLY_SUPPORTED_LOCALES} from '@src/CONST/LOCALES';
Expand All @@ -12,7 +12,7 @@ import Trie from './Trie';
type EmojiMetaData = {
suggestions?: Emoji[];
code?: string;
types?: string[];
types?: readonly string[];
name?: string;
hexcode?: string;
};
Expand Down Expand Up @@ -64,17 +64,17 @@ function getNameParts(name: string): string[] {
}

function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie<EmojiMetaData> {
const trie = new Trie();
const trie = new Trie<EmojiMetaData>();
const langEmojis = localeEmojis[lang];
const defaultLangEmojis = localeEmojis[CONST.LOCALES.DEFAULT];
const isDefaultLocale = lang === CONST.LOCALES.DEFAULT;

for (const pickerEmoji of emojis) {
if ((pickerEmoji as HeaderEmoji).header) {
if ('header' in pickerEmoji) {
continue;
}

const emoji = pickerEmoji as Emoji;
const emoji = pickerEmoji;

const englishName = emoji.name;
const localeName = langEmojis?.[emoji.code]?.name ?? englishName;
Expand All @@ -84,7 +84,7 @@ function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie<Em
if (isNew) {
node.metaData = {code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode, suggestions: []};
} else {
node.metaData = {suggestions: [...((node.metaData.suggestions as Emoji[] | undefined) ?? [])], code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode};
node.metaData = {suggestions: [...(node.metaData.suggestions ?? [])], code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode};
}
if (normalizedName !== localeName) {
const {node: normNode} = trie.getOrCreate(normalizedName);
Expand All @@ -100,7 +100,7 @@ function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie<Em
aliasNode.metaData = {code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode, suggestions: []};
} else {
aliasNode.metaData = {
suggestions: [...((aliasNode.metaData.suggestions as Emoji[] | undefined) ?? [])],
suggestions: [...(aliasNode.metaData.suggestions ?? [])],
code: emoji.code,
types: emoji.types,
name: localeName,
Expand Down
91 changes: 60 additions & 31 deletions src/libs/SelectionScraper/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import CONST from '@src/CONST';

import type {ChildNode} from 'domhandler';
import type {ChildNode, ParentNode} from 'domhandler';

import render from 'dom-serializer';
import {DataNode, Element} from 'domhandler';
Expand All @@ -12,6 +12,35 @@ import type GetCurrentSelection from './types';
const markdownElements = new Set(['h1', 'strong', 'em', 'del', 'blockquote', 'q', 'code', 'pre', 'a', 'br', 'li', 'ul', 'ol', 'b', 'i', 's', 'mention-user']);
const tagAttribute = 'data-testid';

const installTransformedChildren = (parent: ParentNode, children: ChildNode[]) => {
const transformedParent = parent;
transformedParent.children = children;

for (const [index, child] of children.entries()) {
child.parent = transformedParent;
child.prev = index > 0 ? (children.at(index - 1) ?? null) : null;
child.next = children.at(index + 1) ?? null;
}
};

const installForeignObjectRenderCompatibility = (parent: ParentNode, isWithinSVG = false) => {
for (const child of parent.children) {
if (!(child instanceof Element)) {
continue;
}

const childIsWithinSVG = isWithinSVG || child.name === 'svg';
if (childIsWithinSVG && child.name === 'foreignobject') {
const lowercaseForeignObject = child.cloneNode();
for (const foreignObjectChild of child.children) {
foreignObjectChild.parent = lowercaseForeignObject;
}
}

installForeignObjectRenderCompatibility(child, childIsWithinSVG);
}
};

/**
* Reads html of selection. If browser doesn't support Selection API, returns empty string.
* @returns HTML of selection as String
Expand Down Expand Up @@ -53,7 +82,7 @@ const getHTMLOfSelection = (): string => {
// If clonedSelection has no text content this data has no meaning to us.
if (clonedSelection.textContent) {
let parent: globalThis.Element | null = null;
let child = clonedSelection;
let child: globalThis.Node = clonedSelection;

// If selection starts and ends within same text node we use its parentNode. This is because we can't
// use closest function on a [Text](https://developer.mozilla.org/en-US/docs/Web/API/Text) node.
Expand All @@ -73,16 +102,16 @@ const getHTMLOfSelection = (): string => {
if (range.commonAncestorContainer instanceof HTMLElement) {
parent = range.commonAncestorContainer.closest(`[${tagAttribute}]`);
} else {
parent = (range.commonAncestorContainer.parentNode as HTMLElement | null)?.closest(`[${tagAttribute}]`) ?? null;
parent = range.commonAncestorContainer.parentElement?.closest(`[${tagAttribute}]`) ?? null;
}

// Keep traversing up to clone all parents with 'data-testid' attribute.
while (parent) {
const cloned = parent.cloneNode();
cloned.appendChild(child);
child = cloned as DocumentFragment;
child = cloned;

parent = (parent.parentNode as HTMLElement | null)?.closest(`[${tagAttribute}]`) ?? null;
parent = parent.parentElement?.closest(`[${tagAttribute}]`) ?? null;
}

div.appendChild(child);
Expand All @@ -107,24 +136,25 @@ const getHTMLOfSelection = (): string => {
* @param dom - dom htmlparser2 dom representation
*/
const replaceNodes = (dom: ChildNode, isChildOfEditorElement: boolean): ChildNode => {
let domName;
let domChildren: ChildNode[] = [];
const domAttribs: Element['attribs'] = {};
let data = '';

// Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method.
if (dom.type.toString() === 'text' && dom instanceof DataNode) {
data = Str.htmlEncode(dom.data);
const clonedDom = dom.cloneNode();
clonedDom.data = Str.htmlEncode(dom.data);
if (dom.parent instanceof Element && dom.parent?.attribs?.[tagAttribute] === 'email-with-break-opportunities') {
data = data.replaceAll('\u200b', '');
clonedDom.data = clonedDom.data.replaceAll('\u200b', '');
}
} else if (dom instanceof Element) {
domName = dom.name;

return clonedDom;
}

if (dom instanceof Element) {
const clonedDom = dom.cloneNode();
clonedDom.attribs = {};
const child = dom.children.at(0);
if (dom.attribs?.[tagAttribute]) {
// If it's a markdown element, rename it according to the value of data-testid, so ExpensiMark can parse it
if (markdownElements.has(dom.attribs[tagAttribute])) {
domName = dom.attribs[tagAttribute];
clonedDom.name = dom.attribs[tagAttribute];
}
} else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement && child) {
// We are excluding divs that are children of our editor element and have only one child to prevent
Expand All @@ -134,36 +164,35 @@ const replaceNodes = (dom: ChildNode, isChildOfEditorElement: boolean): ChildNod

// We need to preserve href attribute in order to copy links.
if (dom.attribs?.href) {
domAttribs.href = dom.attribs.href;
clonedDom.attribs.href = dom.attribs.href;
}

if (dom.children) {
domChildren = dom.children.map((c) => replaceNodes(c, isChildOfEditorElement || !!dom.attribs?.[tagAttribute]));
}
} else {
throw new Error(`Unknown dom type: ${dom.type}`);
const transformedChildren = dom.children.map((c) => replaceNodes(c, isChildOfEditorElement || !!dom.attribs?.[tagAttribute]));
installTransformedChildren(clonedDom, transformedChildren);
return clonedDom;
}

return {
...dom,
data,
name: domName,
attribs: domAttribs,
children: domChildren,
} as Element & DataNode;
throw new Error(`Unknown dom type: ${dom.type}`);
};

/**
* Resolves the current selection to values and produces clean HTML.
*/
const getCurrentSelection: GetCurrentSelection = () => {
const domRepresentation = parseDocument(getHTMLOfSelection());
domRepresentation.children = domRepresentation.children.map((item) => replaceNodes(item, false));
const parsedDom = parseDocument(getHTMLOfSelection());
const domRepresentation = parsedDom.cloneNode();
installTransformedChildren(
domRepresentation,
parsedDom.children.map((item) => replaceNodes(item, false)),
);

const renderView = domRepresentation.cloneNode(true);
installForeignObjectRenderCompatibility(renderView);

// Newline characters need to be removed here because the HTML could contain both newlines and <br> tags, and when
// <br> tags are converted later to markdown, it creates duplicate newline characters. This means that when the content
// is pasted, there are extra newlines in the content that we want to avoid.
const newHtml = render(domRepresentation).replaceAll('<br>\n', '<br>');
const newHtml = render(renderView).replaceAll('<br>\n', '<br>');
return newHtml || '';
};

Expand Down
13 changes: 9 additions & 4 deletions src/libs/Sound/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import attentionSound from '@assets/sounds/attention.mp3';
import doneSound from '@assets/sounds/done.mp3';
import receiveSound from '@assets/sounds/receive.mp3';
import successSound from '@assets/sounds/success.mp3';

import type {AudioSource} from 'expo-audio';
import type {ValueOf} from 'type-fest';

Expand All @@ -7,10 +12,10 @@ import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound';

// Sound assets must be required at compile time
const SOUND_ASSETS: Record<ValueOf<typeof SOUNDS>, AudioSource> = {
[SOUNDS.DONE]: require('@assets/sounds/done.mp3') as AudioSource,
[SOUNDS.SUCCESS]: require('@assets/sounds/success.mp3') as AudioSource,
[SOUNDS.ATTENTION]: require('@assets/sounds/attention.mp3') as AudioSource,
[SOUNDS.RECEIVE]: require('@assets/sounds/receive.mp3') as AudioSource,
[SOUNDS.DONE]: doneSound,
[SOUNDS.SUCCESS]: successSound,
[SOUNDS.ATTENTION]: attentionSound,
[SOUNDS.RECEIVE]: receiveSound,
};

// Configure audio mode for in-app notification sounds:
Expand Down
5 changes: 5 additions & 0 deletions src/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ declare module '*.jpg' {
export default value;
}

declare module '*.mp3' {
const value: number;
export default value;
}

declare module '*.svg' {
import type React from 'react';
import type {SvgProps} from 'react-native-svg';
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/SelectionScraperTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type * as SelectionScraperModule from '@libs/SelectionScraper/index.native';

import {Document, Element} from 'domhandler';

// Selection scraping only exists in the web implementation; the native variant always returns an empty string.
const SelectionScraper = jest.requireActual<typeof SelectionScraperModule>('@libs/SelectionScraper/index.ts').default;

const fixtures: HTMLElement[] = [];

const selectFixture = (html: string) => {
const fixture = document.createElement('div');
fixture.innerHTML = html;
document.body.append(fixture);
fixtures.push(fixture);

const range = document.createRange();
range.selectNodeContents(fixture);
const selection = window.getSelection();
if (!selection) {
throw new Error('Selection API is unavailable');
}
selection.removeAllRanges();
selection.addRange(range);
};

describe('SelectionScraper', () => {
afterEach(() => {
window.getSelection()?.removeAllRanges();
for (const fixture of fixtures) {
fixture.remove();
}
fixtures.length = 0;
jest.restoreAllMocks();
});

it('preserves pinned SVG foreignObject serialization', () => {
selectFixture('<svg><foreignObject><div></div></foreignObject></svg><span>selected</span>');

expect(SelectionScraper.getCurrentSelection()).toBe('<svg><foreignObject><div/></foreignObject></svg><span>selected</span>');
});

it('retains coherent transformed MathML relationships', () => {
const documentCloneSpy = jest.spyOn(Document.prototype, 'cloneNode');
selectFixture('<math><mtext><div>first</div><span>middle</span><p>last</p></mtext></math>');

expect(SelectionScraper.getCurrentSelection()).toBe('<math><mtext><div>first</div><span>middle</span><p>last</p></mtext></math>');

const firstCloneResult = documentCloneSpy.mock.results.at(0);
if (!firstCloneResult || firstCloneResult.type !== 'return' || !(firstCloneResult.value instanceof Document)) {
throw new Error('SelectionScraper did not clone the parsed document');
}
const transformedDocument = firstCloneResult.value;

const math = transformedDocument.children.at(0);
if (!(math instanceof Element)) {
throw new Error('Transformed MathML root is not an element');
}
const mtext = math.children.at(0);
if (!(mtext instanceof Element)) {
throw new Error('Transformed mtext is not an element');
}

const [first, middle, last] = mtext.children;
if (!first || !middle || !last || mtext.children.length !== 3) {
throw new Error('Transformed mtext does not own the expected children');
}
expect(first.parent).toBe(mtext);
expect(first.prev).toBeNull();
expect(first.next).toBe(middle);
expect(middle.parent).toBe(mtext);
expect(middle.prev).toBe(first);
expect(middle.next).toBe(last);
expect(last.parent).toBe(mtext);
expect(last.prev).toBe(middle);
expect(last.next).toBeNull();
});

it('preserves ordinary HTML transformations', () => {
selectFixture(
'<span data-testid="strong" class="discarded">bold &amp; <a href="https://example.com" class="discarded">link</a><br>\n</span>' +
'<div data-testid="editor"><div><span>nested</span></div><span data-testid="email-with-break-opportunities">a\u200bb</span></div>',
);

expect(SelectionScraper.getCurrentSelection()).toBe('<strong>bold &amp; <a href="https://example.com">link</a><br></strong><div><span>nested</span><span>ab</span></div>');
});
});
Loading