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
+
}
+42
-18
src/screens/PostThread/components/ThreadItemAnchor.tsx
+42
-18
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 {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
···
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
+46
-20
src/screens/PostThread/components/ThreadItemPost.tsx
+46
-20
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 {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}
···
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}
+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
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`)}
···
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`)}
+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
+10
-7
src/state/preferences/index.tsx
+10
-7
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 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>
···
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>
+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
+
}
+54
-22
src/view/com/posts/PostFeedItem.tsx
+54
-22
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 {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
}
···
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
}
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鈥檇 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