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.
+665
-67
Diff
round #0
+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
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'
59
60
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
60
61
import {useFormatPostStatCount} from '#/components/PostControls/util'
61
62
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
···
193
194
const moderation = item.moderation
194
195
const authorShadow = useProfileShadow(post.author)
195
196
const {isActive: live} = useActorStatus(post.author)
197
+
const hasMastodonHtml = useHasMastodonHtmlContent(record)
196
198
const richText = useMemo(
197
199
() =>
198
200
new RichTextAPI({
···
398
400
style={[a.pb_sm]}
399
401
additionalCauses={additionalPostAlerts}
400
402
/>
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]}
418
409
/>
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
+
</>
420
444
)}
421
445
</ContentHider>
422
446
<ExpandedPostDetails
+46
-20
src/screens/PostThread/components/ThreadItemPost.tsx
+46
-20
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'
41
45
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
42
46
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
43
47
import {RichText} from '#/components/RichText'
···
191
195
const post = item.value.post
192
196
const record = item.value.post.record
193
197
const moderation = item.moderation
198
+
const hasMastodonHtml = useHasMastodonHtmlContent(post.record)
194
199
const richText = useMemo(
195
200
() =>
196
201
new RichTextAPI({
···
301
306
style={[a.pb_2xs]}
302
307
additionalCauses={additionalPostAlerts}
303
308
/>
304
-
{richText?.text ? (
309
+
{hasMastodonHtml ? (
305
310
<>
306
-
<RichText
307
-
enableTags
308
-
value={richText}
311
+
<MastodonHtmlContent
312
+
record={post.record}
309
313
style={[a.flex_1, a.text_md]}
310
314
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
311
-
authorHandle={post.author.handle}
312
-
shouldProxyLinks={true}
313
315
/>
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>
319
354
)}
320
355
</>
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
356
)}
331
357
<PostControls
332
358
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'
104
108
import {
105
109
useRepostCarouselEnabled,
106
110
useSetRepostCarouselEnabled,
···
443
447
const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
444
448
const setHideSimilarAccountsRecomm = useSetHideSimilarAccountsRecomm()
445
449
450
+
const renderMastodonHtml = useRenderMastodonHtml()
451
+
const setRenderMastodonHtml = useSetRenderMastodonHtml()
452
+
446
453
const disableVerifyEmailReminder = useDisableVerifyEmailReminder()
447
454
const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder()
448
455
···
742
749
<Toggle.Platform />
743
750
</Toggle.Item>
744
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
+
745
772
<Toggle.Item
746
773
name="disable_verify_email_reminder"
747
774
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(),
169
170
170
171
showExternalShareButtons: z.boolean().optional(),
171
172
···
269
270
],
270
271
},
271
272
highQualityImages: false,
273
+
renderMastodonHtml: false,
272
274
showExternalShareButtons: false,
273
275
}
274
276
+10
-7
src/state/preferences/index.tsx
+10
-7
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'
36
37
import {Provider as RepostCarouselProvider} from './repost-carousel-enabled'
37
38
import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle'
38
39
import {Provider as SubtitlesProvider} from './subtitles'
···
96
97
<DisableFollowedByMetricsProvider>
97
98
<DisablePostsMetricsProvider>
98
99
<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>
106
109
</HideSimilarAccountsRecommProvider>
107
110
</DisablePostsMetricsProvider>
108
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
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'
47
51
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
48
52
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
49
53
import {PostControls} from '#/components/PostControls'
···
418
422
threadgateRecord?: AppBskyFeedThreadgate.Record
419
423
}): React.ReactNode => {
420
424
const {currentAccount} = useSession()
425
+
const hasMastodonHtml = useHasMastodonHtmlContent(
426
+
post.record as AppBskyFeedPost.Record,
427
+
)
421
428
const [limitLines, setLimitLines] = useState(
422
429
() => countLines(richText.text) >= MAX_POST_LINES,
423
430
)
···
460
467
style={[a.pb_xs]}
461
468
additionalCauses={additionalPostAlerts}
462
469
/>
463
-
{richText.text ? (
470
+
{hasMastodonHtml ? (
464
471
<>
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}
470
474
style={[a.flex_1, a.text_md]}
471
-
authorHandle={postAuthor.handle}
472
-
shouldProxyLinks={true}
475
+
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
473
476
/>
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}
477
487
</>
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
+
)}
489
521
</ContentHider>
490
522
)
491
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
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
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