This one was mostly written by Copilot so it's not great. For posts containing bridgyOriginalText or fullText it will render the HTML in place of the bridged RichText. The sanitizer was directly translated from Mastodon's at https://github.com/mastodon/mastodon/blob/main/lib/sanitize_ext/sanitize_config.rb. Ruby text is unimplemented as I have no idea what is a good way to do that in React Native.
REVERTED
src/screens/PostThread/components/ThreadItemAnchor.tsx
REVERTED
src/screens/PostThread/components/ThreadItemAnchor.tsx
···
56
56
import {PostAlerts} from '#/components/moderation/PostAlerts'
57
57
import {type AppModerationCause} from '#/components/Pills'
58
58
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
59
-
import {MastodonHtmlContent, useHasMastodonHtmlContent} from '#/components/Post/MastodonHtmlContent'
60
59
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
61
60
import {useFormatPostStatCount} from '#/components/PostControls/util'
62
61
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
···
194
193
const moderation = item.moderation
195
194
const authorShadow = useProfileShadow(post.author)
196
195
const {isActive: live} = useActorStatus(post.author)
197
-
const hasMastodonHtml = useHasMastodonHtmlContent(record)
198
196
const richText = useMemo(
199
197
() =>
200
198
new RichTextAPI({
···
400
398
style={[a.pb_sm]}
401
399
additionalCauses={additionalPostAlerts}
402
400
/>
401
+
{richText?.text ? (
402
+
<RichText
403
+
enableTags
404
+
selectable
405
+
value={richText}
406
+
style={[a.flex_1, a.text_lg]}
407
+
authorHandle={post.author.handle}
408
+
shouldProxyLinks={true}
409
+
/>
410
+
) : undefined}
411
+
{post.embed && (
412
+
<View style={[a.py_xs]}>
413
+
<Embed
414
+
embed={post.embed}
415
+
moderation={moderation}
416
+
viewContext={PostEmbedViewContext.ThreadHighlighted}
417
+
onOpen={onOpenEmbed}
403
-
{hasMastodonHtml ? (
404
-
<>
405
-
<MastodonHtmlContent
406
-
record={record}
407
-
style={[a.flex_1]}
408
-
textStyle={[a.text_lg]}
409
418
/>
419
+
</View>
410
-
{post.embed && (
411
-
<View style={[a.py_xs]}>
412
-
<Embed
413
-
embed={post.embed}
414
-
moderation={moderation}
415
-
viewContext={PostEmbedViewContext.ThreadHighlighted}
416
-
onOpen={onOpenEmbed}
417
-
/>
418
-
</View>
419
-
)}
420
-
</>
421
-
) : (
422
-
<>
423
-
{richText?.text ? (
424
-
<RichText
425
-
enableTags
426
-
selectable
427
-
value={richText}
428
-
style={[a.flex_1, a.text_lg]}
429
-
authorHandle={post.author.handle}
430
-
shouldProxyLinks={true}
431
-
/>
432
-
) : undefined}
433
-
{post.embed && (
434
-
<View style={[a.py_xs]}>
435
-
<Embed
436
-
embed={post.embed}
437
-
moderation={moderation}
438
-
viewContext={PostEmbedViewContext.ThreadHighlighted}
439
-
onOpen={onOpenEmbed}
440
-
/>
441
-
</View>
442
-
)}
443
-
</>
444
420
)}
445
421
</ContentHider>
446
422
<ExpandedPostDetails
REVERTED
src/screens/PostThread/components/ThreadItemPost.tsx
REVERTED
src/screens/PostThread/components/ThreadItemPost.tsx
···
38
38
import {PostHider} from '#/components/moderation/PostHider'
39
39
import {type AppModerationCause} from '#/components/Pills'
40
40
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
41
-
import {
42
-
MastodonHtmlContent,
43
-
useHasMastodonHtmlContent,
44
-
} from '#/components/Post/MastodonHtmlContent'
45
41
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
46
42
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
47
43
import {RichText} from '#/components/RichText'
···
195
191
const post = item.value.post
196
192
const record = item.value.post.record
197
193
const moderation = item.moderation
198
-
const hasMastodonHtml = useHasMastodonHtmlContent(post.record)
199
194
const richText = useMemo(
200
195
() =>
201
196
new RichTextAPI({
···
306
301
style={[a.pb_2xs]}
307
302
additionalCauses={additionalPostAlerts}
308
303
/>
304
+
{richText?.text ? (
309
-
{hasMastodonHtml ? (
310
305
<>
306
+
<RichText
307
+
enableTags
308
+
value={richText}
311
-
<MastodonHtmlContent
312
-
record={post.record}
313
309
style={[a.flex_1, a.text_md]}
314
310
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
311
+
authorHandle={post.author.handle}
312
+
shouldProxyLinks={true}
315
313
/>
314
+
{limitLines && (
315
+
<ShowMoreTextButton
316
+
style={[a.text_md]}
317
+
onPress={onPressShowMore}
318
+
/>
316
-
{post.embed && (
317
-
<View style={[a.pb_xs]}>
318
-
<Embed
319
-
embed={post.embed}
320
-
moderation={moderation}
321
-
viewContext={PostEmbedViewContext.Feed}
322
-
/>
323
-
</View>
324
-
)}
325
-
</>
326
-
) : (
327
-
<>
328
-
{richText?.text ? (
329
-
<>
330
-
<RichText
331
-
enableTags
332
-
value={richText}
333
-
style={[a.flex_1, a.text_md]}
334
-
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
335
-
authorHandle={post.author.handle}
336
-
shouldProxyLinks={true}
337
-
/>
338
-
{limitLines && (
339
-
<ShowMoreTextButton
340
-
style={[a.text_md]}
341
-
onPress={onPressShowMore}
342
-
/>
343
-
)}
344
-
</>
345
-
) : undefined}
346
-
{post.embed && (
347
-
<View style={[a.pb_xs]}>
348
-
<Embed
349
-
embed={post.embed}
350
-
moderation={moderation}
351
-
viewContext={PostEmbedViewContext.Feed}
352
-
/>
353
-
</View>
354
319
)}
355
320
</>
321
+
) : undefined}
322
+
{post.embed && (
323
+
<View style={[a.pb_xs]}>
324
+
<Embed
325
+
embed={post.embed}
326
+
moderation={moderation}
327
+
viewContext={PostEmbedViewContext.Feed}
328
+
/>
329
+
</View>
356
330
)}
357
331
<PostControls
358
332
post={postShadow}
REVERTED
src/screens/Settings/DeerSettings.tsx
REVERTED
src/screens/Settings/DeerSettings.tsx
···
101
101
useNoDiscoverFallback,
102
102
useSetNoDiscoverFallback,
103
103
} from '#/state/preferences/no-discover-fallback'
104
-
import {
105
-
useRenderMastodonHtml,
106
-
useSetRenderMastodonHtml,
107
-
} from '#/state/preferences/render-mastodon-html'
108
104
import {
109
105
useRepostCarouselEnabled,
110
106
useSetRepostCarouselEnabled,
···
447
443
const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
448
444
const setHideSimilarAccountsRecomm = useSetHideSimilarAccountsRecomm()
449
445
450
-
const renderMastodonHtml = useRenderMastodonHtml()
451
-
const setRenderMastodonHtml = useSetRenderMastodonHtml()
452
-
453
446
const disableVerifyEmailReminder = useDisableVerifyEmailReminder()
454
447
const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder()
455
448
···
749
742
<Toggle.Platform />
750
743
</Toggle.Item>
751
744
752
-
<Toggle.Item
753
-
754
-
<Toggle.Item
755
-
name="render_mastodon_html"
756
-
label={_(msg`Render Mastodon HTML from bridged posts`)}
757
-
value={renderMastodonHtml}
758
-
onChange={value => setRenderMastodonHtml(value)}
759
-
style={[a.w_full]}>
760
-
<Toggle.LabelText style={[a.flex_1]}>
761
-
<Trans>Render Mastodon HTML from bridged posts</Trans>
762
-
</Toggle.LabelText>
763
-
<Toggle.Platform />
764
-
</Toggle.Item>
765
-
<Admonition type="info" style={[a.flex_1]}>
766
-
<Trans>
767
-
When enabled, posts bridged from Mastodon will display their
768
-
original HTML formatting instead of the plain text version.
769
-
</Trans>
770
-
</Admonition>
771
-
772
745
<Toggle.Item
773
746
name="disable_verify_email_reminder"
774
747
label={_(msg`Disable verify email reminder`)}
REVERTED
src/state/persisted/schema.ts
REVERTED
src/state/persisted/schema.ts
···
166
166
})
167
167
.optional(),
168
168
highQualityImages: z.boolean().optional(),
169
-
renderMastodonHtml: z.boolean().optional(),
170
169
171
170
showExternalShareButtons: z.boolean().optional(),
172
171
···
270
269
],
271
270
},
272
271
highQualityImages: false,
273
-
renderMastodonHtml: false,
274
272
showExternalShareButtons: false,
275
273
}
276
274
REVERTED
src/state/preferences/index.tsx
REVERTED
src/state/preferences/index.tsx
···
33
33
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
34
34
import {Provider as NoAppLabelersProvider} from './no-app-labelers'
35
35
import {Provider as NoDiscoverProvider} from './no-discover-fallback'
36
-
import {Provider as RenderMastodonHtmlProvider} from './render-mastodon-html'
37
36
import {Provider as RepostCarouselProvider} from './repost-carousel-enabled'
38
37
import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle'
39
38
import {Provider as SubtitlesProvider} from './subtitles'
···
97
96
<DisableFollowedByMetricsProvider>
98
97
<DisablePostsMetricsProvider>
99
98
<HideSimilarAccountsRecommProvider>
99
+
<EnableSquareAvatarsProvider>
100
+
<EnableSquareButtonsProvider>
101
+
<DisableVerifyEmailReminderProvider>
102
+
{children}
103
+
</DisableVerifyEmailReminderProvider>
104
+
</EnableSquareButtonsProvider>
105
+
</EnableSquareAvatarsProvider>
100
-
<RenderMastodonHtmlProvider>
101
-
<EnableSquareAvatarsProvider>
102
-
<EnableSquareButtonsProvider>
103
-
<DisableVerifyEmailReminderProvider>
104
-
{children}
105
-
</DisableVerifyEmailReminderProvider>
106
-
</EnableSquareButtonsProvider>
107
-
</EnableSquareAvatarsProvider>
108
-
</RenderMastodonHtmlProvider>
109
106
</HideSimilarAccountsRecommProvider>
110
107
</DisablePostsMetricsProvider>
111
108
</DisableFollowedByMetricsProvider>
REVERTED
src/state/preferences/render-mastodon-html.tsx
REVERTED
src/state/preferences/render-mastodon-html.tsx
···
1
-
import React from 'react'
2
-
3
-
import * as persisted from '#/state/persisted'
4
-
5
-
type StateContext = persisted.Schema['renderMastodonHtml']
6
-
type SetContext = (v: persisted.Schema['renderMastodonHtml']) => void
7
-
8
-
const stateContext = React.createContext<StateContext>(
9
-
persisted.defaults.renderMastodonHtml,
10
-
)
11
-
const setContext = React.createContext<SetContext>(
12
-
(_: persisted.Schema['renderMastodonHtml']) => {},
13
-
)
14
-
15
-
export function Provider({children}: React.PropsWithChildren<{}>) {
16
-
const [state, setState] = React.useState(persisted.get('renderMastodonHtml'))
17
-
18
-
const setStateWrapped = React.useCallback(
19
-
(renderMastodonHtml: persisted.Schema['renderMastodonHtml']) => {
20
-
setState(renderMastodonHtml)
21
-
persisted.write('renderMastodonHtml', renderMastodonHtml)
22
-
},
23
-
[setState],
24
-
)
25
-
26
-
React.useEffect(() => {
27
-
return persisted.onUpdate('renderMastodonHtml', nextValue => {
28
-
setState(nextValue)
29
-
})
30
-
}, [setStateWrapped])
31
-
32
-
return (
33
-
<stateContext.Provider value={state}>
34
-
<setContext.Provider value={setStateWrapped}>
35
-
{children}
36
-
</setContext.Provider>
37
-
</stateContext.Provider>
38
-
)
39
-
}
40
-
41
-
export function useRenderMastodonHtml() {
42
-
return (
43
-
React.useContext(stateContext) ?? persisted.defaults.renderMastodonHtml
44
-
)
45
-
}
46
-
47
-
export function useSetRenderMastodonHtml() {
48
-
return React.useContext(setContext)
49
-
}
REVERTED
src/view/com/posts/PostFeedItem.tsx
REVERTED
src/view/com/posts/PostFeedItem.tsx
···
44
44
import {type AppModerationCause} from '#/components/Pills'
45
45
import {Embed} from '#/components/Post/Embed'
46
46
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
47
-
import {
48
-
MastodonHtmlContent,
49
-
useHasMastodonHtmlContent,
50
-
} from '#/components/Post/MastodonHtmlContent'
51
47
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
52
48
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
53
49
import {PostControls} from '#/components/PostControls'
···
422
418
threadgateRecord?: AppBskyFeedThreadgate.Record
423
419
}): React.ReactNode => {
424
420
const {currentAccount} = useSession()
425
-
const hasMastodonHtml = useHasMastodonHtmlContent(
426
-
post.record as AppBskyFeedPost.Record,
427
-
)
428
421
const [limitLines, setLimitLines] = useState(
429
422
() => countLines(richText.text) >= MAX_POST_LINES,
430
423
)
···
467
460
style={[a.pb_xs]}
468
461
additionalCauses={additionalPostAlerts}
469
462
/>
463
+
{richText.text ? (
470
-
{hasMastodonHtml ? (
471
464
<>
465
+
<RichText
466
+
enableTags
467
+
testID="postText"
468
+
value={richText}
469
+
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
472
-
<MastodonHtmlContent
473
-
record={post.record as AppBskyFeedPost.Record}
474
470
style={[a.flex_1, a.text_md]}
471
+
authorHandle={postAuthor.handle}
472
+
shouldProxyLinks={true}
475
-
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
476
473
/>
474
+
{limitLines && (
475
+
<ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
476
+
)}
477
-
{postEmbed ? (
478
-
<View style={[a.pb_xs]}>
479
-
<Embed
480
-
embed={postEmbed}
481
-
moderation={moderation}
482
-
onOpen={onOpenEmbed}
483
-
viewContext={PostEmbedViewContext.Feed}
484
-
/>
485
-
</View>
486
-
) : null}
487
477
</>
478
+
) : undefined}
479
+
{postEmbed ? (
480
+
<View style={[a.pb_xs]}>
481
+
<Embed
482
+
embed={postEmbed}
483
+
moderation={moderation}
484
+
onOpen={onOpenEmbed}
485
+
viewContext={PostEmbedViewContext.Feed}
486
+
/>
487
+
</View>
488
+
) : null}
488
-
) : (
489
-
<>
490
-
{richText.text ? (
491
-
<>
492
-
<RichText
493
-
enableTags
494
-
testID="postText"
495
-
value={richText}
496
-
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
497
-
style={[a.flex_1, a.text_md]}
498
-
authorHandle={postAuthor.handle}
499
-
shouldProxyLinks={true}
500
-
/>
501
-
{limitLines && (
502
-
<ShowMoreTextButton
503
-
style={[a.text_md]}
504
-
onPress={onPressShowMore}
505
-
/>
506
-
)}
507
-
</>
508
-
) : undefined}
509
-
{postEmbed ? (
510
-
<View style={[a.pb_xs]}>
511
-
<Embed
512
-
embed={postEmbed}
513
-
moderation={moderation}
514
-
onOpen={onOpenEmbed}
515
-
viewContext={PostEmbedViewContext.Feed}
516
-
/>
517
-
</View>
518
-
) : null}
519
-
</>
520
-
)}
521
489
</ContentHider>
522
490
)
523
491
}
NEW
src/lib/strings/html-sanitizer.ts
NEW
src/lib/strings/html-sanitizer.ts
···
1
-
/**
2
-
* HTML sanitizer inspired by Mastodon's Sanitize::Config
3
-
* Sanitizes HTML content to prevent XSS while preserving safe formatting
4
-
*/
5
-
6
-
const HTTP_PROTOCOLS = ['http', 'https']
7
-
8
-
const LINK_PROTOCOLS = [
9
-
'http',
10
-
'https',
11
-
'dat',
12
-
'dweb',
13
-
'ipfs',
14
-
'ipns',
15
-
'ssb',
16
-
'gopher',
17
-
'xmpp',
18
-
'magnet',
19
-
'gemini',
20
-
]
21
-
22
-
const PROTOCOL_REGEX = /^([a-z][a-z0-9.+-]*):\/\//i
23
-
24
-
interface SanitizeOptions {
25
-
allowOembed?: boolean
26
-
}
27
-
28
-
/**
29
-
* Sanitizes HTML content following Mastodon's strict rules
30
-
*/
31
-
export function sanitizeHtml(
32
-
html: string,
33
-
options: SanitizeOptions = {},
34
-
): string {
35
-
if (typeof DOMParser === 'undefined') {
36
-
// Fallback for environments without DOMParser
37
-
return sanitizeTextOnly(html)
38
-
}
39
-
40
-
const parser = new DOMParser()
41
-
const doc = parser.parseFromString(html, 'text/html')
42
-
const body = doc.body
43
-
44
-
sanitizeNode(body, options)
45
-
46
-
return body.innerHTML
47
-
}
48
-
49
-
function sanitizeNode(node: Node, options: SanitizeOptions): void {
50
-
const childNodes = Array.from(node.childNodes)
51
-
52
-
for (const child of childNodes) {
53
-
if (child.nodeType === Node.ELEMENT_NODE) {
54
-
const element = child as HTMLElement
55
-
const tagName = element.tagName.toLowerCase()
56
-
57
-
// Define allowed elements
58
-
const allowedElements = options.allowOembed
59
-
? [
60
-
'p',
61
-
'br',
62
-
'span',
63
-
'a',
64
-
'del',
65
-
's',
66
-
'pre',
67
-
'blockquote',
68
-
'code',
69
-
'b',
70
-
'strong',
71
-
'u',
72
-
'i',
73
-
'em',
74
-
'ul',
75
-
'ol',
76
-
'li',
77
-
'ruby',
78
-
'rt',
79
-
'rp',
80
-
'audio',
81
-
'iframe',
82
-
'source',
83
-
'video',
84
-
]
85
-
: [
86
-
'p',
87
-
'br',
88
-
'span',
89
-
'a',
90
-
'del',
91
-
's',
92
-
'pre',
93
-
'blockquote',
94
-
'code',
95
-
'b',
96
-
'strong',
97
-
'u',
98
-
'i',
99
-
'em',
100
-
'ul',
101
-
'ol',
102
-
'li',
103
-
'ruby',
104
-
'rt',
105
-
'rp',
106
-
]
107
-
108
-
// Handle unsupported elements (h1-h6) - convert to <strong> wrapped in <p>
109
-
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
110
-
const strong = element.ownerDocument!.createElement('strong')
111
-
while (element.firstChild) {
112
-
strong.appendChild(element.firstChild)
113
-
}
114
-
const p = element.ownerDocument!.createElement('p')
115
-
p.appendChild(strong)
116
-
element.replaceWith(p)
117
-
sanitizeNode(p, options)
118
-
continue
119
-
}
120
-
121
-
// Handle math elements - extract annotation text
122
-
if (tagName === 'math') {
123
-
const mathText = extractMathAnnotation(element)
124
-
if (mathText) {
125
-
const textNode = element.ownerDocument!.createTextNode(mathText)
126
-
element.replaceWith(textNode)
127
-
} else {
128
-
element.remove()
129
-
}
130
-
continue
131
-
}
132
-
133
-
if (tagName === 'li') {
134
-
// Keep li elements but sanitize their children
135
-
sanitizeNode(element, options)
136
-
continue
137
-
}
138
-
139
-
// Remove elements not in allowlist
140
-
if (!allowedElements.includes(tagName)) {
141
-
// Replace with text content
142
-
const textNode = element.ownerDocument!.createTextNode(
143
-
element.textContent || '',
144
-
)
145
-
element.replaceWith(textNode)
146
-
continue
147
-
}
148
-
149
-
// Sanitize attributes
150
-
sanitizeAttributes(element, options)
151
-
152
-
// Recursively sanitize children
153
-
sanitizeNode(element, options)
154
-
}
155
-
}
156
-
}
157
-
158
-
function sanitizeAttributes(
159
-
element: HTMLElement,
160
-
options: SanitizeOptions,
161
-
): void {
162
-
const tagName = element.tagName.toLowerCase()
163
-
const allowedAttrs: Record<string, string[]> = {
164
-
a: ['href', 'rel', 'class', 'translate'],
165
-
span: ['class', 'translate'],
166
-
ol: ['start', 'reversed'],
167
-
li: ['value'],
168
-
p: ['class'],
169
-
}
170
-
171
-
if (options.allowOembed) {
172
-
allowedAttrs.audio = ['controls']
173
-
allowedAttrs.iframe = [
174
-
'allowfullscreen',
175
-
'frameborder',
176
-
'height',
177
-
'scrolling',
178
-
'src',
179
-
'width',
180
-
]
181
-
allowedAttrs.source = ['src', 'type']
182
-
allowedAttrs.video = ['controls', 'height', 'loop', 'width']
183
-
}
184
-
185
-
const allowed = allowedAttrs[tagName] || []
186
-
const attrs = Array.from(element.attributes)
187
-
188
-
// Remove non-allowed attributes
189
-
for (const attr of attrs) {
190
-
const attrName = attr.name.toLowerCase()
191
-
const isAllowed = allowed.some(a => {
192
-
if (a.endsWith('*')) {
193
-
return attrName.startsWith(a.slice(0, -1))
194
-
}
195
-
return a === attrName
196
-
})
197
-
198
-
if (!isAllowed) {
199
-
element.removeAttribute(attr.name)
200
-
}
201
-
}
202
-
203
-
// Process specific attributes
204
-
if (tagName === 'a') {
205
-
processAnchorElement(element)
206
-
}
207
-
208
-
// Process class whitelist
209
-
if (element.hasAttribute('class')) {
210
-
processClassWhitelist(element)
211
-
}
212
-
213
-
// Process translate attribute - remove unless it's "no"
214
-
if (element.hasAttribute('translate')) {
215
-
const translate = element.getAttribute('translate')
216
-
if (translate !== 'no') {
217
-
element.removeAttribute('translate')
218
-
}
219
-
}
220
-
221
-
// Validate protocols for elements with src/href
222
-
if (element.hasAttribute('href') || element.hasAttribute('src')) {
223
-
validateProtocols(element, options)
224
-
}
225
-
}
226
-
227
-
function processAnchorElement(element: HTMLElement): void {
228
-
// Add required attributes
229
-
element.setAttribute('rel', 'nofollow noopener')
230
-
element.setAttribute('target', '_blank')
231
-
232
-
// Check if href has unsupported protocol
233
-
const href = element.getAttribute('href')
234
-
if (href) {
235
-
const scheme = getScheme(href)
236
-
if (scheme !== null && scheme !== 'relative' && !LINK_PROTOCOLS.includes(scheme)) {
237
-
// Replace element with its text content
238
-
const textNode = element.ownerDocument!.createTextNode(
239
-
element.textContent || '',
240
-
)
241
-
element.replaceWith(textNode)
242
-
}
243
-
}
244
-
}
245
-
246
-
function processClassWhitelist(element: HTMLElement): void {
247
-
const classList = element.className.split(/[\t\n\f\r ]+/).filter(Boolean)
248
-
const whitelisted = classList.filter(className => {
249
-
// microformats classes
250
-
if (/^[hpuedt]-/.test(className)) return true
251
-
// semantic classes
252
-
if (/^(mention|hashtag)$/.test(className)) return true
253
-
// link formatting classes
254
-
if (/^(ellipsis|invisible)$/.test(className)) return true
255
-
// quote inline class
256
-
if (className === 'quote-inline') return true
257
-
return false
258
-
})
259
-
260
-
if (whitelisted.length > 0) {
261
-
element.className = whitelisted.join(' ')
262
-
} else {
263
-
element.removeAttribute('class')
264
-
}
265
-
}
266
-
267
-
function validateProtocols(
268
-
element: HTMLElement,
269
-
options: SanitizeOptions,
270
-
): void {
271
-
const tagName = element.tagName.toLowerCase()
272
-
const src = element.getAttribute('src')
273
-
const href = element.getAttribute('href')
274
-
const url = src || href
275
-
276
-
if (!url) return
277
-
278
-
const scheme = getScheme(url)
279
-
280
-
// For oembed elements, only allow HTTP protocols for src
281
-
if (
282
-
options.allowOembed &&
283
-
src &&
284
-
['iframe', 'source'].includes(tagName)
285
-
) {
286
-
if (scheme !== null && !HTTP_PROTOCOLS.includes(scheme)) {
287
-
element.removeAttribute('src')
288
-
}
289
-
// Add sandbox attribute to iframes
290
-
if (tagName === 'iframe') {
291
-
element.setAttribute(
292
-
'sandbox',
293
-
'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms',
294
-
)
295
-
}
296
-
}
297
-
}
298
-
299
-
function getScheme(url: string): string | null {
300
-
const match = url.match(PROTOCOL_REGEX)
301
-
if (match) {
302
-
return match[1].toLowerCase()
303
-
}
304
-
// Check if it's a relative URL
305
-
if (url.startsWith('/') || url.startsWith('.')) {
306
-
return 'relative'
307
-
}
308
-
return null
309
-
}
310
-
311
-
/**
312
-
* Extract math annotation from MathML element
313
-
* Follows FEP-dc88 spec for math element representation
314
-
*/
315
-
function extractMathAnnotation(mathElement: HTMLElement): string | null {
316
-
const semantics = Array.from(mathElement.children).find(
317
-
child => child.tagName.toLowerCase() === 'semantics',
318
-
) as HTMLElement | undefined
319
-
320
-
if (!semantics) return null
321
-
322
-
// Look for LaTeX annotation (application/x-tex)
323
-
const latexAnnotation = Array.from(semantics.children).find(child => {
324
-
return (
325
-
child.tagName.toLowerCase() === 'annotation' &&
326
-
child.getAttribute('encoding') === 'application/x-tex'
327
-
)
328
-
})
329
-
330
-
if (latexAnnotation) {
331
-
const display = mathElement.getAttribute('display')
332
-
const text = latexAnnotation.textContent || ''
333
-
return display === 'block' ? `$$${text}$$` : `$${text}$`
334
-
}
335
-
336
-
// Look for plain text annotation
337
-
const plainAnnotation = Array.from(semantics.children).find(child => {
338
-
return (
339
-
child.tagName.toLowerCase() === 'annotation' &&
340
-
child.getAttribute('encoding') === 'text/plain'
341
-
)
342
-
})
343
-
344
-
if (plainAnnotation) {
345
-
return plainAnnotation.textContent || null
346
-
}
347
-
348
-
return null
349
-
}
350
-
351
-
/**
352
-
* Fallback sanitizer that strips all HTML tags
353
-
*/
354
-
function sanitizeTextOnly(html: string): string {
355
-
return html.replace(/<[^>]*>/g, '')
356
-
}
NEW
src/components/Post/MastodonHtmlContent.tsx
NEW
src/components/Post/MastodonHtmlContent.tsx
···
1
-
import {useMemo} from 'react'
2
-
import {type StyleProp, type TextStyle, View, type ViewStyle} from 'react-native'
1
+
import {useMemo, useState} from 'react'
2
+
import {
3
+
type LayoutChangeEvent,
4
+
type StyleProp,
5
+
type TextStyle,
6
+
View,
7
+
type ViewStyle,
8
+
} from 'react-native'
3
9
import {type AppBskyFeedPost} from '@atproto/api'
10
+
import {msg, Trans} from '@lingui/macro'
11
+
import {useLingui} from '@lingui/react'
4
12
5
13
import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html'
6
-
import { atoms } from '#/alf'
14
+
import {atoms as a} from '#/alf'
15
+
import {Button, ButtonText} from '#/components/Button'
7
16
import {InlineLinkText} from '#/components/Link'
8
17
import {P, Text} from '#/components/Typography'
9
18
···
36
45
numberOfLines,
37
46
}: MastodonHtmlContentProps) {
38
47
const renderMastodonHtml = useRenderMastodonHtml()
48
+
const {_} = useLingui()
49
+
const [isExpanded, setIsExpanded] = useState(false)
50
+
const [contentHeight, setContentHeight] = useState<number | null>(null)
51
+
const [isTall, setIsTall] = useState(false)
39
52
40
53
const renderedContent = useMemo(() => {
41
54
if (!renderMastodonHtml) return null
···
53
66
return sanitizeAndRenderHtml(rawHtml, numberOfLines, textStyle)
54
67
}, [record, renderMastodonHtml, numberOfLines, textStyle])
55
68
69
+
const handleLayout = (event: LayoutChangeEvent) => {
70
+
const height = event.nativeEvent.layout.height
71
+
if (contentHeight === null) {
72
+
setContentHeight(height)
73
+
// Consider content "tall" if it's taller than 150px
74
+
setIsTall(height > 150)
75
+
}
76
+
}
77
+
56
78
if (!renderedContent) return null
57
79
58
-
return <View style={style}>{renderedContent}</View>
80
+
const shouldCollapse = isTall && !isExpanded
81
+
82
+
return (
83
+
<View style={style}>
84
+
<View
85
+
style={shouldCollapse ? {maxHeight: 150, overflow: 'hidden'} : undefined}
86
+
onLayout={handleLayout}>
87
+
{renderedContent}
88
+
</View>
89
+
{shouldCollapse && (
90
+
<Button
91
+
label={_(msg`Show more`)}
92
+
onPress={() => setIsExpanded(true)}
93
+
variant="ghost"
94
+
color="primary"
95
+
size="small"
96
+
style={[a.mt_xs]}>
97
+
<ButtonText>
98
+
<Trans>Show more</Trans>
99
+
</ButtonText>
100
+
</Button>
101
+
)}
102
+
</View>
103
+
)
59
104
}
60
105
61
106
const LINK_PROTOCOLS = [
···
111
156
const doc = parser.parseFromString(html, 'text/html')
112
157
113
158
const textStyle: StyleProp<TextStyle> = [
114
-
atoms.leading_snug,
115
-
atoms.text_md,
159
+
a.leading_snug,
160
+
a.text_md,
116
161
inputTextStyle,
117
162
]
118
163
···
300
345
}
301
346
302
347
const content = Array.from(doc.body.childNodes).map((node, i) =>
303
-
renderNode(node, i),
348
+
renderNode(node, String(i)),
304
349
)
305
350
306
351
return (
···
326
371
// Remove non-allowed attributes
327
372
for (const attr of attrs) {
328
373
const attrName = attr.name.toLowerCase()
329
-
const isAllowed = allowed.some(a => {
330
-
if (a.endsWith('*')) {
331
-
return attrName.startsWith(a.slice(0, -1))
374
+
const isAllowed = allowed.some(allowedAttr => {
375
+
if (allowedAttr.endsWith('*')) {
376
+
return attrName.startsWith(allowedAttr.slice(0, -1))
332
377
}
333
-
return a === attrName
378
+
return allowedAttr === attrName
334
379
})
335
380
336
381
if (!isAllowed) {