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.
-435
src/components/Post/MastodonHtmlContent.tsx
-435
src/components/Post/MastodonHtmlContent.tsx
···
1
-
import {useMemo} from 'react'
2
-
import {type StyleProp, type TextStyle, View, type ViewStyle} from 'react-native'
3
-
import {type AppBskyFeedPost} from '@atproto/api'
4
-
5
-
import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html'
6
-
import { atoms } from '#/alf'
7
-
import {InlineLinkText} from '#/components/Link'
8
-
import {P, Text} from '#/components/Typography'
9
-
10
-
interface MastodonHtmlContentProps {
11
-
record: AppBskyFeedPost.Record
12
-
style?: StyleProp<ViewStyle>,
13
-
textStyle?: StyleProp<TextStyle>,
14
-
numberOfLines?: number
15
-
}
16
-
17
-
export function useHasMastodonHtmlContent(record: AppBskyFeedPost.Record) {
18
-
const renderMastodonHtml = useRenderMastodonHtml()
19
-
20
-
return useMemo(() => {
21
-
if (!renderMastodonHtml) return false
22
-
23
-
const fullText = (record as any).fullText as string | undefined
24
-
const bridgyOriginalText = (record as any).bridgyOriginalText as
25
-
| string
26
-
| undefined
27
-
28
-
return !!(fullText || bridgyOriginalText)
29
-
}, [record, renderMastodonHtml])
30
-
}
31
-
32
-
export function MastodonHtmlContent({
33
-
record,
34
-
style,
35
-
textStyle,
36
-
numberOfLines,
37
-
}: MastodonHtmlContentProps) {
38
-
const renderMastodonHtml = useRenderMastodonHtml()
39
-
40
-
const renderedContent = useMemo(() => {
41
-
if (!renderMastodonHtml) return null
42
-
43
-
const fullText = (record as any).fullText as string | undefined
44
-
const bridgyOriginalText = (record as any).bridgyOriginalText as
45
-
| string
46
-
| undefined
47
-
48
-
const rawHtml = fullText || bridgyOriginalText
49
-
50
-
if (!rawHtml) return null
51
-
52
-
// Parse HTML once and sanitize/render in a single pass
53
-
return sanitizeAndRenderHtml(rawHtml, numberOfLines, textStyle)
54
-
}, [record, renderMastodonHtml, numberOfLines, textStyle])
55
-
56
-
if (!renderedContent) return null
57
-
58
-
return <View style={style}>{renderedContent}</View>
59
-
}
60
-
61
-
const LINK_PROTOCOLS = [
62
-
'http',
63
-
'https',
64
-
'dat',
65
-
'dweb',
66
-
'ipfs',
67
-
'ipns',
68
-
'ssb',
69
-
'gopher',
70
-
'xmpp',
71
-
'magnet',
72
-
'gemini',
73
-
]
74
-
75
-
const PROTOCOL_REGEX = /^([a-z][a-z0-9.+-]*):\/\//i
76
-
77
-
const ALLOWED_ELEMENTS = [
78
-
'p',
79
-
'br',
80
-
'span',
81
-
'a',
82
-
'del',
83
-
's',
84
-
'pre',
85
-
'blockquote',
86
-
'code',
87
-
'b',
88
-
'strong',
89
-
'u',
90
-
'i',
91
-
'em',
92
-
'ul',
93
-
'ol',
94
-
'li',
95
-
'ruby',
96
-
'rt',
97
-
'rp',
98
-
]
99
-
100
-
function sanitizeAndRenderHtml(
101
-
html: string,
102
-
_numberOfLines?: number,
103
-
inputTextStyle?: StyleProp<TextStyle>,
104
-
): React.ReactNode {
105
-
if (typeof DOMParser === 'undefined') {
106
-
// Fallback for environments without DOMParser
107
-
return html.replace(/<[^>]*>/g, '')
108
-
}
109
-
110
-
const parser = new DOMParser()
111
-
const doc = parser.parseFromString(html, 'text/html')
112
-
113
-
const textStyle: StyleProp<TextStyle> = [
114
-
atoms.leading_snug,
115
-
atoms.text_md,
116
-
inputTextStyle,
117
-
]
118
-
119
-
// Sanitize and render in a single pass
120
-
const renderNode = (node: Node, key: number, insideLink = false): React.ReactNode => {
121
-
if (node.nodeType === Node.TEXT_NODE) {
122
-
// Don't wrap text in styled Text component if inside a link
123
-
if (insideLink) {
124
-
return node.nodeValue
125
-
}
126
-
return <Text key={key} style={textStyle}>
127
-
{node.nodeValue}
128
-
</Text>
129
-
}
130
-
131
-
if (node.nodeType === Node.ELEMENT_NODE) {
132
-
const element = node as Element
133
-
const tagName = element.tagName.toLowerCase()
134
-
135
-
// Handle unsupported elements (h1-h6) - convert to <strong> wrapped in <p>
136
-
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
137
-
const children = Array.from(element.childNodes).map((child, i) =>
138
-
renderNode(child, i, insideLink),
139
-
)
140
-
return (
141
-
<P key={key} style={textStyle}>
142
-
<Text style={{...textStyle, fontWeight: 'bold'}}>{children}</Text>
143
-
</P>
144
-
)
145
-
}
146
-
147
-
// Handle math elements - extract annotation text
148
-
if (tagName === 'math') {
149
-
const mathText = extractMathAnnotation(element)
150
-
if (mathText) {
151
-
return <Text key={key} style={textStyle}>{mathText}</Text>
152
-
}
153
-
return null
154
-
}
155
-
156
-
// Remove elements not in allowlist - replace with text content
157
-
if (!ALLOWED_ELEMENTS.includes(tagName)) {
158
-
return element.textContent ? (
159
-
<Text key={key} style={textStyle}>{element.textContent}</Text>
160
-
) : null
161
-
}
162
-
163
-
// Sanitize and process element
164
-
sanitizeElementAttributes(element)
165
-
166
-
const children = Array.from(element.childNodes).map((child, i) =>
167
-
renderNode(child, i, insideLink || tagName === 'a'),
168
-
)
169
-
170
-
switch (tagName) {
171
-
case 'p':
172
-
return <P key={key} style={textStyle}>{children}</P>
173
-
case 'blockquote':
174
-
return (
175
-
<View key={key} style={{borderLeftWidth: 3, borderLeftColor: '#888', paddingLeft: 12, marginVertical: 4}}>
176
-
<P style={textStyle}>{children}</P>
177
-
</View>
178
-
)
179
-
case 'pre':
180
-
return (
181
-
<View key={key} style={{backgroundColor: '#f5f5f5', padding: 8, borderRadius: 4, marginVertical: 4}}>
182
-
<P style={[textStyle, { fontFamily: 'monospace'}]}>{children}</P>
183
-
</View>
184
-
)
185
-
case 'code':
186
-
return (
187
-
<Text key={key} style={[textStyle, { fontFamily: 'monospace', backgroundColor: '#f5f5f5', paddingHorizontal: 4, borderRadius: 2}]}>
188
-
{children}
189
-
</Text>
190
-
)
191
-
case 'strong':
192
-
case 'b':
193
-
return (
194
-
<Text key={key} style={[textStyle, { fontWeight: 'bold'}]}>
195
-
{children}
196
-
</Text>
197
-
)
198
-
case 'em':
199
-
case 'i':
200
-
return (
201
-
<Text key={key} style={[textStyle, { fontStyle: 'italic'}]}>
202
-
{children}
203
-
</Text>
204
-
)
205
-
case 'u':
206
-
return (
207
-
<Text key={key} style={[textStyle, { textDecorationLine: 'underline'}]}>
208
-
{children}
209
-
</Text>
210
-
)
211
-
case 'del':
212
-
case 's':
213
-
return (
214
-
<Text key={key} style={[textStyle, { textDecorationLine: 'line-through'}]}>
215
-
{children}
216
-
</Text>
217
-
)
218
-
case 'ul':
219
-
return (
220
-
<View key={key} style={{marginVertical: 4}}>
221
-
{children}
222
-
</View>
223
-
)
224
-
case 'ol':
225
-
return (
226
-
<View key={key} style={{marginVertical: 4}}>
227
-
{children}
228
-
</View>
229
-
)
230
-
case 'li':
231
-
const parentIsOl = element.parentElement?.tagName.toLowerCase() === 'ol'
232
-
return (
233
-
<View key={key} style={{flexDirection: 'row', marginVertical: 2}}>
234
-
<Text style={[textStyle, { marginRight: 8 }]}>{parentIsOl ? 'ฮรรณ' : 'ฮรรณ'}</Text>
235
-
<Text style={[textStyle, { flex: 1 }]}>{children}</Text>
236
-
</View>
237
-
)
238
-
case 'ruby':
239
-
return <Text key={key} style={textStyle}>{children}</Text>
240
-
case 'rt':
241
-
case 'rp':
242
-
return null // TODO support ruby text rendering
243
-
case 'a':
244
-
const href = element.getAttribute('href')
245
-
if (href) {
246
-
const linkText =
247
-
element.textContent || element.getAttribute('aria-label') || href
248
-
const className = element.getAttribute('class')
249
-
const isInvisible = className?.includes('invisible')
250
-
return (
251
-
<InlineLinkText
252
-
key={key}
253
-
to={href}
254
-
label={linkText}
255
-
shouldProxy
256
-
style={isInvisible ? {display: 'none'} : textStyle}>
257
-
{children}
258
-
</InlineLinkText>
259
-
)
260
-
}
261
-
return <Text key={key}>{children}</Text>
262
-
case 'br':
263
-
return '\n'
264
-
case 'span':
265
-
const spanClass = element.getAttribute('class')
266
-
// Handle invisible/ellipsis classes for link formatting
267
-
if (spanClass?.includes('invisible')) {
268
-
return <Text key={key} style={{ display: 'none' }}>{children}</Text>
269
-
}
270
-
if (spanClass?.includes('ellipsis')) {
271
-
// If inside a link, return plain text, otherwise wrapped
272
-
if (insideLink) {
273
-
return 'ฮรยช'
274
-
}
275
-
return <Text key={key} style={textStyle}>ฮรยช</Text>
276
-
}
277
-
// Handle mentions and hashtags
278
-
if (spanClass?.includes('mention') || spanClass?.includes('hashtag')) {
279
-
// If inside a link, return children as-is without wrapping
280
-
if (insideLink) {
281
-
return children
282
-
}
283
-
return <Text key={key} style={textStyle}>{children}</Text>
284
-
}
285
-
// For spans inside links, return children without wrapping
286
-
if (insideLink) {
287
-
return children
288
-
}
289
-
return <Text key={key} style={textStyle}>{children}</Text>
290
-
default:
291
-
return <Text key={key} style={textStyle}>{children}</Text>
292
-
}
293
-
}
294
-
295
-
return null
296
-
}
297
-
298
-
const content = Array.from(doc.body.childNodes).map((node, i) =>
299
-
renderNode(node, i),
300
-
)
301
-
302
-
return (
303
-
<View style={{gap: 8}}>
304
-
{content}
305
-
</View>
306
-
)
307
-
}
308
-
309
-
function sanitizeElementAttributes(element: Element): void {
310
-
const tagName = element.tagName.toLowerCase()
311
-
const allowedAttrs: Record<string, string[]> = {
312
-
a: ['href', 'rel', 'class', 'translate'],
313
-
span: ['class', 'translate'],
314
-
ol: ['start', 'reversed'],
315
-
li: ['value'],
316
-
p: ['class'],
317
-
}
318
-
319
-
const allowed = allowedAttrs[tagName] || []
320
-
const attrs = Array.from(element.attributes)
321
-
322
-
// Remove non-allowed attributes
323
-
for (const attr of attrs) {
324
-
const attrName = attr.name.toLowerCase()
325
-
const isAllowed = allowed.some(a => {
326
-
if (a.endsWith('*')) {
327
-
return attrName.startsWith(a.slice(0, -1))
328
-
}
329
-
return a === attrName
330
-
})
331
-
332
-
if (!isAllowed) {
333
-
element.removeAttribute(attr.name)
334
-
}
335
-
}
336
-
337
-
// Process specific attributes
338
-
if (tagName === 'a') {
339
-
processAnchorElement(element)
340
-
}
341
-
342
-
// Process class whitelist
343
-
if (element.hasAttribute('class')) {
344
-
processClassWhitelist(element)
345
-
}
346
-
347
-
// Process translate attribute - remove unless it's "no"
348
-
if (element.hasAttribute('translate')) {
349
-
const translate = element.getAttribute('translate')
350
-
if (translate !== 'no') {
351
-
element.removeAttribute('translate')
352
-
}
353
-
}
354
-
}
355
-
356
-
function processAnchorElement(element: Element): void {
357
-
// Check if href has unsupported protocol
358
-
const href = element.getAttribute('href')
359
-
if (href) {
360
-
const scheme = getScheme(href)
361
-
if (scheme !== null && scheme !== 'relative' && !LINK_PROTOCOLS.includes(scheme)) {
362
-
// Remove the href to disable the link
363
-
element.removeAttribute('href')
364
-
}
365
-
}
366
-
}
367
-
368
-
function processClassWhitelist(element: Element): void {
369
-
const classList = element.className.split(/[\t\n\f\r ]+/).filter(Boolean)
370
-
const whitelisted = classList.filter(className => {
371
-
// microformats classes
372
-
if (/^[hpuedt]-/.test(className)) return true
373
-
// semantic classes
374
-
if (/^(mention|hashtag)$/.test(className)) return true
375
-
// link formatting classes
376
-
if (/^(ellipsis|invisible)$/.test(className)) return true
377
-
// quote inline class
378
-
if (className === 'quote-inline') return true
379
-
return false
380
-
})
381
-
382
-
if (whitelisted.length > 0) {
383
-
element.className = whitelisted.join(' ')
384
-
} else {
385
-
element.removeAttribute('class')
386
-
}
387
-
}
388
-
389
-
function getScheme(url: string): string | null {
390
-
const match = url.match(PROTOCOL_REGEX)
391
-
if (match) {
392
-
return match[1].toLowerCase()
393
-
}
394
-
// Check if it's a relative URL
395
-
if (url.startsWith('/') || url.startsWith('.')) {
396
-
return 'relative'
397
-
}
398
-
return null
399
-
}
400
-
401
-
function extractMathAnnotation(mathElement: Element): string | null {
402
-
const semantics = Array.from(mathElement.children).find(
403
-
child => child.tagName.toLowerCase() === 'semantics',
404
-
) as Element | undefined
405
-
406
-
if (!semantics) return null
407
-
408
-
// Look for LaTeX annotation (application/x-tex)
409
-
const latexAnnotation = Array.from(semantics.children).find(child => {
410
-
return (
411
-
child.tagName.toLowerCase() === 'annotation' &&
412
-
child.getAttribute('encoding') === 'application/x-tex'
413
-
)
414
-
})
415
-
416
-
if (latexAnnotation) {
417
-
const display = mathElement.getAttribute('display')
418
-
const text = latexAnnotation.textContent || ''
419
-
return display === 'block' ? `$$${text}$$` : `$${text}$`
420
-
}
421
-
422
-
// Look for plain text annotation
423
-
const plainAnnotation = Array.from(semantics.children).find(child => {
424
-
return (
425
-
child.tagName.toLowerCase() === 'annotation' &&
426
-
child.getAttribute('encoding') === 'text/plain'
427
-
)
428
-
})
429
-
430
-
if (plainAnnotation) {
431
-
return plainAnnotation.textContent || null
432
-
}
433
-
434
-
return null
435
-
}
+18
-42
src/screens/PostThread/components/ThreadItemAnchor.tsx
+18
-42
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
+20
-46
src/screens/PostThread/components/ThreadItemPost.tsx
+20
-46
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}
-27
src/screens/Settings/DeerSettings.tsx
-27
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`)}
-2
src/state/persisted/schema.ts
-2
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
+7
-10
src/state/preferences/index.tsx
+7
-10
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>
-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
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
}
-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
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
closed without merging
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