diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index 3394a2172d7f..357b42a371fd 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -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'; @@ -12,7 +12,7 @@ import Trie from './Trie'; type EmojiMetaData = { suggestions?: Emoji[]; code?: string; - types?: string[]; + types?: readonly string[]; name?: string; hexcode?: string; }; @@ -64,17 +64,17 @@ function getNameParts(name: string): string[] { } function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie { - const trie = new Trie(); + const trie = new Trie(); 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; @@ -84,7 +84,7 @@ function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie { + 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 @@ -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. @@ -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); @@ -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 @@ -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
tags, and when //
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('
\n', '
'); + const newHtml = render(renderView).replaceAll('
\n', '
'); return newHtml || ''; }; diff --git a/src/libs/Sound/index.native.ts b/src/libs/Sound/index.native.ts index f39edfd32b59..a8c19c28d1aa 100644 --- a/src/libs/Sound/index.native.ts +++ b/src/libs/Sound/index.native.ts @@ -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'; @@ -7,10 +12,10 @@ import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound'; // Sound assets must be required at compile time const SOUND_ASSETS: Record, 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: diff --git a/src/types/global.d.ts b/src/types/global.d.ts index b14faad96b42..0b658c22085d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -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'; diff --git a/tests/unit/SelectionScraperTest.ts b/tests/unit/SelectionScraperTest.ts new file mode 100644 index 000000000000..0bb7570b9bf5 --- /dev/null +++ b/tests/unit/SelectionScraperTest.ts @@ -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('@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('
selected'); + + expect(SelectionScraper.getCurrentSelection()).toBe('
selected'); + }); + + it('retains coherent transformed MathML relationships', () => { + const documentCloneSpy = jest.spyOn(Document.prototype, 'cloneNode'); + selectFixture('
first
middle

last

'); + + expect(SelectionScraper.getCurrentSelection()).toBe('
first
middle

last

'); + + 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( + 'bold & link
\n
' + + '
nested
a\u200bb
', + ); + + expect(SelectionScraper.getCurrentSelection()).toBe('bold & link
nestedab
'); + }); +});