Skip to content

Commit 725c368

Browse files
committed
Fix unsafe type assertions in cleanup targets
1 parent e69182c commit 725c368

5 files changed

Lines changed: 167 additions & 42 deletions

File tree

src/libs/EmojiTrie.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import emojis, {importEmojiLocale, localeEmojis} from '@assets/emojis';
2-
import type {Emoji, HeaderEmoji} from '@assets/emojis/types';
2+
import type {Emoji} from '@assets/emojis/types';
33

44
import CONST from '@src/CONST';
55
import {FULLY_SUPPORTED_LOCALES} from '@src/CONST/LOCALES';
@@ -12,7 +12,7 @@ import Trie from './Trie';
1212
type EmojiMetaData = {
1313
suggestions?: Emoji[];
1414
code?: string;
15-
types?: string[];
15+
types?: readonly string[];
1616
name?: string;
1717
hexcode?: string;
1818
};
@@ -64,17 +64,17 @@ function getNameParts(name: string): string[] {
6464
}
6565

6666
function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie<EmojiMetaData> {
67-
const trie = new Trie();
67+
const trie = new Trie<EmojiMetaData>();
6868
const langEmojis = localeEmojis[lang];
6969
const defaultLangEmojis = localeEmojis[CONST.LOCALES.DEFAULT];
7070
const isDefaultLocale = lang === CONST.LOCALES.DEFAULT;
7171

7272
for (const pickerEmoji of emojis) {
73-
if ((pickerEmoji as HeaderEmoji).header) {
73+
if ('header' in pickerEmoji) {
7474
continue;
7575
}
7676

77-
const emoji = pickerEmoji as Emoji;
77+
const emoji = pickerEmoji;
7878

7979
const englishName = emoji.name;
8080
const localeName = langEmojis?.[emoji.code]?.name ?? englishName;
@@ -84,7 +84,7 @@ function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie<Em
8484
if (isNew) {
8585
node.metaData = {code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode, suggestions: []};
8686
} else {
87-
node.metaData = {suggestions: [...((node.metaData.suggestions as Emoji[] | undefined) ?? [])], code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode};
87+
node.metaData = {suggestions: [...(node.metaData.suggestions ?? [])], code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode};
8888
}
8989
if (normalizedName !== localeName) {
9090
const {node: normNode} = trie.getOrCreate(normalizedName);
@@ -100,7 +100,7 @@ function createTrie(lang: FullySupportedLocale = CONST.LOCALES.DEFAULT): Trie<Em
100100
aliasNode.metaData = {code: emoji.code, types: emoji.types, name: localeName, hexcode: emoji.hexcode, suggestions: []};
101101
} else {
102102
aliasNode.metaData = {
103-
suggestions: [...((aliasNode.metaData.suggestions as Emoji[] | undefined) ?? [])],
103+
suggestions: [...(aliasNode.metaData.suggestions ?? [])],
104104
code: emoji.code,
105105
types: emoji.types,
106106
name: localeName,

src/libs/SelectionScraper/index.ts

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import CONST from '@src/CONST';
22

3-
import type {ChildNode} from 'domhandler';
3+
import type {ChildNode, ParentNode} from 'domhandler';
44

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

15+
const installTransformedChildren = (parent: ParentNode, children: ChildNode[]) => {
16+
const transformedParent = parent;
17+
transformedParent.children = children;
18+
19+
for (const [index, child] of children.entries()) {
20+
child.parent = transformedParent;
21+
child.prev = index > 0 ? (children.at(index - 1) ?? null) : null;
22+
child.next = children.at(index + 1) ?? null;
23+
}
24+
};
25+
26+
const installForeignObjectRenderCompatibility = (parent: ParentNode, isWithinSVG = false) => {
27+
for (const child of parent.children) {
28+
if (!(child instanceof Element)) {
29+
continue;
30+
}
31+
32+
const childIsWithinSVG = isWithinSVG || child.name === 'svg';
33+
if (childIsWithinSVG && child.name === 'foreignobject') {
34+
const lowercaseForeignObject = child.cloneNode();
35+
for (const foreignObjectChild of child.children) {
36+
foreignObjectChild.parent = lowercaseForeignObject;
37+
}
38+
}
39+
40+
installForeignObjectRenderCompatibility(child, childIsWithinSVG);
41+
}
42+
};
43+
1544
/**
1645
* Reads html of selection. If browser doesn't support Selection API, returns empty string.
1746
* @returns HTML of selection as String
@@ -53,7 +82,7 @@ const getHTMLOfSelection = (): string => {
5382
// If clonedSelection has no text content this data has no meaning to us.
5483
if (clonedSelection.textContent) {
5584
let parent: globalThis.Element | null = null;
56-
let child = clonedSelection;
85+
let child: globalThis.Node = clonedSelection;
5786

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

79108
// Keep traversing up to clone all parents with 'data-testid' attribute.
80109
while (parent) {
81110
const cloned = parent.cloneNode();
82111
cloned.appendChild(child);
83-
child = cloned as DocumentFragment;
112+
child = cloned;
84113

85-
parent = (parent.parentNode as HTMLElement | null)?.closest(`[${tagAttribute}]`) ?? null;
114+
parent = parent.parentElement?.closest(`[${tagAttribute}]`) ?? null;
86115
}
87116

88117
div.appendChild(child);
@@ -107,24 +136,25 @@ const getHTMLOfSelection = (): string => {
107136
* @param dom - dom htmlparser2 dom representation
108137
*/
109138
const replaceNodes = (dom: ChildNode, isChildOfEditorElement: boolean): ChildNode => {
110-
let domName;
111-
let domChildren: ChildNode[] = [];
112-
const domAttribs: Element['attribs'] = {};
113-
let data = '';
114-
115139
// Encoding HTML chars '< >' in the text, because any HTML will be removed in stripHTML method.
116140
if (dom.type.toString() === 'text' && dom instanceof DataNode) {
117-
data = Str.htmlEncode(dom.data);
141+
const clonedDom = dom.cloneNode();
142+
clonedDom.data = Str.htmlEncode(dom.data);
118143
if (dom.parent instanceof Element && dom.parent?.attribs?.[tagAttribute] === 'email-with-break-opportunities') {
119-
data = data.replaceAll('\u200b', '');
144+
clonedDom.data = clonedDom.data.replaceAll('\u200b', '');
120145
}
121-
} else if (dom instanceof Element) {
122-
domName = dom.name;
146+
147+
return clonedDom;
148+
}
149+
150+
if (dom instanceof Element) {
151+
const clonedDom = dom.cloneNode();
152+
clonedDom.attribs = {};
123153
const child = dom.children.at(0);
124154
if (dom.attribs?.[tagAttribute]) {
125155
// If it's a markdown element, rename it according to the value of data-testid, so ExpensiMark can parse it
126156
if (markdownElements.has(dom.attribs[tagAttribute])) {
127-
domName = dom.attribs[tagAttribute];
157+
clonedDom.name = dom.attribs[tagAttribute];
128158
}
129159
} else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement && child) {
130160
// 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
134164

135165
// We need to preserve href attribute in order to copy links.
136166
if (dom.attribs?.href) {
137-
domAttribs.href = dom.attribs.href;
167+
clonedDom.attribs.href = dom.attribs.href;
138168
}
139169

140-
if (dom.children) {
141-
domChildren = dom.children.map((c) => replaceNodes(c, isChildOfEditorElement || !!dom.attribs?.[tagAttribute]));
142-
}
143-
} else {
144-
throw new Error(`Unknown dom type: ${dom.type}`);
170+
const transformedChildren = dom.children.map((c) => replaceNodes(c, isChildOfEditorElement || !!dom.attribs?.[tagAttribute]));
171+
installTransformedChildren(clonedDom, transformedChildren);
172+
return clonedDom;
145173
}
146174

147-
return {
148-
...dom,
149-
data,
150-
name: domName,
151-
attribs: domAttribs,
152-
children: domChildren,
153-
} as Element & DataNode;
175+
throw new Error(`Unknown dom type: ${dom.type}`);
154176
};
155177

156178
/**
157179
* Resolves the current selection to values and produces clean HTML.
158180
*/
159181
const getCurrentSelection: GetCurrentSelection = () => {
160-
const domRepresentation = parseDocument(getHTMLOfSelection());
161-
domRepresentation.children = domRepresentation.children.map((item) => replaceNodes(item, false));
182+
const parsedDom = parseDocument(getHTMLOfSelection());
183+
const domRepresentation = parsedDom.cloneNode();
184+
installTransformedChildren(
185+
domRepresentation,
186+
parsedDom.children.map((item) => replaceNodes(item, false)),
187+
);
188+
189+
const renderView = domRepresentation.cloneNode(true);
190+
installForeignObjectRenderCompatibility(renderView);
162191

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

src/libs/Sound/index.native.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import attentionSound from '@assets/sounds/attention.mp3';
2+
import doneSound from '@assets/sounds/done.mp3';
3+
import receiveSound from '@assets/sounds/receive.mp3';
4+
import successSound from '@assets/sounds/success.mp3';
5+
16
import type {AudioSource} from 'expo-audio';
27
import type {ValueOf} from 'type-fest';
38

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

813
// Sound assets must be required at compile time
914
const SOUND_ASSETS: Record<ValueOf<typeof SOUNDS>, AudioSource> = {
10-
[SOUNDS.DONE]: require('@assets/sounds/done.mp3') as AudioSource,
11-
[SOUNDS.SUCCESS]: require('@assets/sounds/success.mp3') as AudioSource,
12-
[SOUNDS.ATTENTION]: require('@assets/sounds/attention.mp3') as AudioSource,
13-
[SOUNDS.RECEIVE]: require('@assets/sounds/receive.mp3') as AudioSource,
15+
[SOUNDS.DONE]: doneSound,
16+
[SOUNDS.SUCCESS]: successSound,
17+
[SOUNDS.ATTENTION]: attentionSound,
18+
[SOUNDS.RECEIVE]: receiveSound,
1419
};
1520

1621
// Configure audio mode for in-app notification sounds:

src/types/global.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ declare module '*.jpg' {
1212
export default value;
1313
}
1414

15+
declare module '*.mp3' {
16+
const value: number;
17+
export default value;
18+
}
19+
1520
declare module '*.svg' {
1621
import type React from 'react';
1722
import type {SvgProps} from 'react-native-svg';

tests/unit/SelectionScraperTest.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type * as SelectionScraperModule from '@libs/SelectionScraper/index.native';
2+
3+
import {Document, Element} from 'domhandler';
4+
5+
// Selection scraping only exists in the web implementation; the native variant always returns an empty string.
6+
const SelectionScraper = jest.requireActual<typeof SelectionScraperModule>('@libs/SelectionScraper/index.ts').default;
7+
8+
const fixtures: HTMLElement[] = [];
9+
10+
const selectFixture = (html: string) => {
11+
const fixture = document.createElement('div');
12+
fixture.innerHTML = html;
13+
document.body.append(fixture);
14+
fixtures.push(fixture);
15+
16+
const range = document.createRange();
17+
range.selectNodeContents(fixture);
18+
const selection = window.getSelection();
19+
if (!selection) {
20+
throw new Error('Selection API is unavailable');
21+
}
22+
selection.removeAllRanges();
23+
selection.addRange(range);
24+
};
25+
26+
describe('SelectionScraper', () => {
27+
afterEach(() => {
28+
window.getSelection()?.removeAllRanges();
29+
for (const fixture of fixtures) {
30+
fixture.remove();
31+
}
32+
fixtures.length = 0;
33+
jest.restoreAllMocks();
34+
});
35+
36+
it('preserves pinned SVG foreignObject serialization', () => {
37+
selectFixture('<svg><foreignObject><div></div></foreignObject></svg><span>selected</span>');
38+
39+
expect(SelectionScraper.getCurrentSelection()).toBe('<svg><foreignObject><div/></foreignObject></svg><span>selected</span>');
40+
});
41+
42+
it('retains coherent transformed MathML relationships', () => {
43+
const documentCloneSpy = jest.spyOn(Document.prototype, 'cloneNode');
44+
selectFixture('<math><mtext><div>first</div><span>middle</span><p>last</p></mtext></math>');
45+
46+
expect(SelectionScraper.getCurrentSelection()).toBe('<math><mtext><div>first</div><span>middle</span><p>last</p></mtext></math>');
47+
48+
const firstCloneResult = documentCloneSpy.mock.results.at(0);
49+
if (!firstCloneResult || firstCloneResult.type !== 'return' || !(firstCloneResult.value instanceof Document)) {
50+
throw new Error('SelectionScraper did not clone the parsed document');
51+
}
52+
const transformedDocument = firstCloneResult.value;
53+
54+
const math = transformedDocument.children.at(0);
55+
if (!(math instanceof Element)) {
56+
throw new Error('Transformed MathML root is not an element');
57+
}
58+
const mtext = math.children.at(0);
59+
if (!(mtext instanceof Element)) {
60+
throw new Error('Transformed mtext is not an element');
61+
}
62+
63+
const [first, middle, last] = mtext.children;
64+
if (!first || !middle || !last || mtext.children.length !== 3) {
65+
throw new Error('Transformed mtext does not own the expected children');
66+
}
67+
expect(first.parent).toBe(mtext);
68+
expect(first.prev).toBeNull();
69+
expect(first.next).toBe(middle);
70+
expect(middle.parent).toBe(mtext);
71+
expect(middle.prev).toBe(first);
72+
expect(middle.next).toBe(last);
73+
expect(last.parent).toBe(mtext);
74+
expect(last.prev).toBe(middle);
75+
expect(last.next).toBeNull();
76+
});
77+
78+
it('preserves ordinary HTML transformations', () => {
79+
selectFixture(
80+
'<span data-testid="strong" class="discarded">bold &amp; <a href="https://example.com" class="discarded">link</a><br>\n</span>' +
81+
'<div data-testid="editor"><div><span>nested</span></div><span data-testid="email-with-break-opportunities">a\u200bb</span></div>',
82+
);
83+
84+
expect(SelectionScraper.getCurrentSelection()).toBe('<strong>bold &amp; <a href="https://example.com">link</a><br></strong><div><span>nested</span><span>ab</span></div>');
85+
});
86+
});

0 commit comments

Comments
 (0)