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.
src/components/Post/MastodonHtmlContent.tsx
src/components/Post/MastodonHtmlContent.tsx
Failed to calculate interdiff for this file.
+18
-42
src/screens/PostThread/components/ThreadItemAnchor.tsx
+18
-42
src/screens/PostThread/components/ThreadItemAnchor.tsx
···
56
import {PostAlerts} from '#/components/moderation/PostAlerts'
57
import {type AppModerationCause} from '#/components/Pills'
58
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
59
-
import {MastodonHtmlContent, useHasMastodonHtmlContent} from '#/components/Post/MastodonHtmlContent'
60
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
61
import {useFormatPostStatCount} from '#/components/PostControls/util'
62
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
···
194
const moderation = item.moderation
195
const authorShadow = useProfileShadow(post.author)
196
const {isActive: live} = useActorStatus(post.author)
197
-
const hasMastodonHtml = useHasMastodonHtmlContent(record)
198
const richText = useMemo(
199
() =>
200
new RichTextAPI({
···
400
style={[a.pb_sm]}
401
additionalCauses={additionalPostAlerts}
402
/>
403
-
{hasMastodonHtml ? (
404
-
<>
405
-
<MastodonHtmlContent
406
-
record={record}
407
-
style={[a.flex_1]}
408
-
textStyle={[a.text_lg]}
409
/>
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
)}
445
</ContentHider>
446
<ExpandedPostDetails
···
56
import {PostAlerts} from '#/components/moderation/PostAlerts'
57
import {type AppModerationCause} from '#/components/Pills'
58
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
59
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
60
import {useFormatPostStatCount} from '#/components/PostControls/util'
61
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
···
193
const moderation = item.moderation
194
const authorShadow = useProfileShadow(post.author)
195
const {isActive: live} = useActorStatus(post.author)
196
const richText = useMemo(
197
() =>
198
new RichTextAPI({
···
398
style={[a.pb_sm]}
399
additionalCauses={additionalPostAlerts}
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}
418
/>
419
+
</View>
420
)}
421
</ContentHider>
422
<ExpandedPostDetails
+20
-46
src/screens/PostThread/components/ThreadItemPost.tsx
+20
-46
src/screens/PostThread/components/ThreadItemPost.tsx
···
38
import {PostHider} from '#/components/moderation/PostHider'
39
import {type AppModerationCause} from '#/components/Pills'
40
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
41
-
import {
42
-
MastodonHtmlContent,
43
-
useHasMastodonHtmlContent,
44
-
} from '#/components/Post/MastodonHtmlContent'
45
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
46
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
47
import {RichText} from '#/components/RichText'
···
195
const post = item.value.post
196
const record = item.value.post.record
197
const moderation = item.moderation
198
-
const hasMastodonHtml = useHasMastodonHtmlContent(post.record)
199
const richText = useMemo(
200
() =>
201
new RichTextAPI({
···
306
style={[a.pb_2xs]}
307
additionalCauses={additionalPostAlerts}
308
/>
309
-
{hasMastodonHtml ? (
310
<>
311
-
<MastodonHtmlContent
312
-
record={post.record}
313
style={[a.flex_1, a.text_md]}
314
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
315
/>
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
)}
355
</>
356
)}
357
<PostControls
358
post={postShadow}
···
38
import {PostHider} from '#/components/moderation/PostHider'
39
import {type AppModerationCause} from '#/components/Pills'
40
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
41
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
42
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
43
import {RichText} from '#/components/RichText'
···
191
const post = item.value.post
192
const record = item.value.post.record
193
const moderation = item.moderation
194
const richText = useMemo(
195
() =>
196
new RichTextAPI({
···
301
style={[a.pb_2xs]}
302
additionalCauses={additionalPostAlerts}
303
/>
304
+
{richText?.text ? (
305
<>
306
+
<RichText
307
+
enableTags
308
+
value={richText}
309
style={[a.flex_1, a.text_md]}
310
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
311
+
authorHandle={post.author.handle}
312
+
shouldProxyLinks={true}
313
/>
314
+
{limitLines && (
315
+
<ShowMoreTextButton
316
+
style={[a.text_md]}
317
+
onPress={onPressShowMore}
318
+
/>
319
)}
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>
330
)}
331
<PostControls
332
post={postShadow}
-27
src/screens/Settings/DeerSettings.tsx
-27
src/screens/Settings/DeerSettings.tsx
···
101
useNoDiscoverFallback,
102
useSetNoDiscoverFallback,
103
} from '#/state/preferences/no-discover-fallback'
104
-
import {
105
-
useRenderMastodonHtml,
106
-
useSetRenderMastodonHtml,
107
-
} from '#/state/preferences/render-mastodon-html'
108
import {
109
useRepostCarouselEnabled,
110
useSetRepostCarouselEnabled,
···
447
const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
448
const setHideSimilarAccountsRecomm = useSetHideSimilarAccountsRecomm()
449
450
-
const renderMastodonHtml = useRenderMastodonHtml()
451
-
const setRenderMastodonHtml = useSetRenderMastodonHtml()
452
-
453
const disableVerifyEmailReminder = useDisableVerifyEmailReminder()
454
const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder()
455
···
749
<Toggle.Platform />
750
</Toggle.Item>
751
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
<Toggle.Item
773
name="disable_verify_email_reminder"
774
label={_(msg`Disable verify email reminder`)}
···
101
useNoDiscoverFallback,
102
useSetNoDiscoverFallback,
103
} from '#/state/preferences/no-discover-fallback'
104
import {
105
useRepostCarouselEnabled,
106
useSetRepostCarouselEnabled,
···
443
const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
444
const setHideSimilarAccountsRecomm = useSetHideSimilarAccountsRecomm()
445
446
const disableVerifyEmailReminder = useDisableVerifyEmailReminder()
447
const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder()
448
···
742
<Toggle.Platform />
743
</Toggle.Item>
744
745
<Toggle.Item
746
name="disable_verify_email_reminder"
747
label={_(msg`Disable verify email reminder`)}
-2
src/state/persisted/schema.ts
-2
src/state/persisted/schema.ts
···
166
})
167
.optional(),
168
highQualityImages: z.boolean().optional(),
169
-
renderMastodonHtml: z.boolean().optional(),
170
171
showExternalShareButtons: z.boolean().optional(),
172
···
270
],
271
},
272
highQualityImages: false,
273
-
renderMastodonHtml: false,
274
showExternalShareButtons: false,
275
}
276
+7
-10
src/state/preferences/index.tsx
+7
-10
src/state/preferences/index.tsx
···
33
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
34
import {Provider as NoAppLabelersProvider} from './no-app-labelers'
35
import {Provider as NoDiscoverProvider} from './no-discover-fallback'
36
-
import {Provider as RenderMastodonHtmlProvider} from './render-mastodon-html'
37
import {Provider as RepostCarouselProvider} from './repost-carousel-enabled'
38
import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle'
39
import {Provider as SubtitlesProvider} from './subtitles'
···
97
<DisableFollowedByMetricsProvider>
98
<DisablePostsMetricsProvider>
99
<HideSimilarAccountsRecommProvider>
100
-
<RenderMastodonHtmlProvider>
101
-
<EnableSquareAvatarsProvider>
102
-
<EnableSquareButtonsProvider>
103
-
<DisableVerifyEmailReminderProvider>
104
-
{children}
105
-
</DisableVerifyEmailReminderProvider>
106
-
</EnableSquareButtonsProvider>
107
-
</EnableSquareAvatarsProvider>
108
-
</RenderMastodonHtmlProvider>
109
</HideSimilarAccountsRecommProvider>
110
</DisablePostsMetricsProvider>
111
</DisableFollowedByMetricsProvider>
···
33
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
34
import {Provider as NoAppLabelersProvider} from './no-app-labelers'
35
import {Provider as NoDiscoverProvider} from './no-discover-fallback'
36
import {Provider as RepostCarouselProvider} from './repost-carousel-enabled'
37
import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle'
38
import {Provider as SubtitlesProvider} from './subtitles'
···
96
<DisableFollowedByMetricsProvider>
97
<DisablePostsMetricsProvider>
98
<HideSimilarAccountsRecommProvider>
99
+
<EnableSquareAvatarsProvider>
100
+
<EnableSquareButtonsProvider>
101
+
<DisableVerifyEmailReminderProvider>
102
+
{children}
103
+
</DisableVerifyEmailReminderProvider>
104
+
</EnableSquareButtonsProvider>
105
+
</EnableSquareAvatarsProvider>
106
</HideSimilarAccountsRecommProvider>
107
</DisablePostsMetricsProvider>
108
</DisableFollowedByMetricsProvider>
-49
src/state/preferences/render-mastodon-html.tsx
-49
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
-
}
···
+22
-54
src/view/com/posts/PostFeedItem.tsx
+22
-54
src/view/com/posts/PostFeedItem.tsx
···
44
import {type AppModerationCause} from '#/components/Pills'
45
import {Embed} from '#/components/Post/Embed'
46
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
47
-
import {
48
-
MastodonHtmlContent,
49
-
useHasMastodonHtmlContent,
50
-
} from '#/components/Post/MastodonHtmlContent'
51
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
52
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
53
import {PostControls} from '#/components/PostControls'
···
422
threadgateRecord?: AppBskyFeedThreadgate.Record
423
}): React.ReactNode => {
424
const {currentAccount} = useSession()
425
-
const hasMastodonHtml = useHasMastodonHtmlContent(
426
-
post.record as AppBskyFeedPost.Record,
427
-
)
428
const [limitLines, setLimitLines] = useState(
429
() => countLines(richText.text) >= MAX_POST_LINES,
430
)
···
467
style={[a.pb_xs]}
468
additionalCauses={additionalPostAlerts}
469
/>
470
-
{hasMastodonHtml ? (
471
<>
472
-
<MastodonHtmlContent
473
-
record={post.record as AppBskyFeedPost.Record}
474
style={[a.flex_1, a.text_md]}
475
-
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
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
</>
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
</ContentHider>
522
)
523
}
···
44
import {type AppModerationCause} from '#/components/Pills'
45
import {Embed} from '#/components/Post/Embed'
46
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
47
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
48
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
49
import {PostControls} from '#/components/PostControls'
···
418
threadgateRecord?: AppBskyFeedThreadgate.Record
419
}): React.ReactNode => {
420
const {currentAccount} = useSession()
421
const [limitLines, setLimitLines] = useState(
422
() => countLines(richText.text) >= MAX_POST_LINES,
423
)
···
460
style={[a.pb_xs]}
461
additionalCauses={additionalPostAlerts}
462
/>
463
+
{richText.text ? (
464
<>
465
+
<RichText
466
+
enableTags
467
+
testID="postText"
468
+
value={richText}
469
+
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
470
style={[a.flex_1, a.text_md]}
471
+
authorHandle={postAuthor.handle}
472
+
shouldProxyLinks={true}
473
/>
474
+
{limitLines && (
475
+
<ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
476
+
)}
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}
489
</ContentHider>
490
)
491
}
-356
src/lib/strings/html-sanitizer.ts
-356
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
-
}
···
History
2 rounds
5 comments
maxine.puppykitty.racing
submitted
#1
5 commits
expand
collapse
e7e78fad
fix: don't duplicate work in MastodonHtmlContent
3e5262ab
chore: remove any casts
265f3ab4
chore: replace unicode ellipsis with escaped version
eff00beb
feat/MastodonHtml: render as ordered lists (with numeric prefixes)
a28c6d3f
feat/MastodonHtml: collapse posts taller than 150px
merge conflicts detected
expand
collapse
expand
collapse
- src/screens/PostThread/components/ThreadItemPost.tsx:301
- src/state/persisted/schema.ts:166
- src/state/preferences/index.tsx:33
- src/view/com/posts/PostFeedItem.tsx:460
expand 5 comments
Good point, I will rewrite the sanitizer from scratch
Hey Maxine! Did you get this done? Iโd like to see if we can merge it once the conflicts are resolved.
Sorry ewan, haven't had the time, also this PR has some weird bugs (sometimes the render crashes and I never diagnosed it), you might want to close this one for the meanwhile
I might look into writing a non-vibe-coded version of this at some point, it'd be a fun way to cut my teeth on webdev again
maxine.puppykitty.racing
submitted
#0
2 commits
expand
collapse
6e85dcd3
feat: render full post contents for posts bridged from mastodon or wafrn
e7e78fad
fix: don't duplicate work in MastodonHtmlContent
i am like 99% sure this would be considered a license violation if merged as mastodon is licensed under AGPL while witchsky is MIT