Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Render HTML from bridged Fediverse posts

uwx 5aeed8d4 76589b45

+1149 -70
+561
src/components/Post/MastodonHtmlContent.tsx
··· 1 + import {useMemo, useState} from 'react' 2 + import { 3 + type LayoutChangeEvent, 4 + type StyleProp, 5 + type TextStyle, 6 + View, 7 + type ViewStyle, 8 + } from 'react-native' 9 + import {type AppBskyFeedPost} from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html' 14 + import {atoms as a} from '#/alf' 15 + import {Button, ButtonText} from '#/components/Button' 16 + import {InlineLinkText} from '#/components/Link' 17 + import {P, Text} from '#/components/Typography' 18 + 19 + interface MastodonHtmlContentProps { 20 + record: AppBskyFeedPost.Record 21 + style?: StyleProp<ViewStyle> 22 + textStyle?: StyleProp<TextStyle> 23 + numberOfLines?: number 24 + } 25 + 26 + export function useHasMastodonHtmlContent(record: AppBskyFeedPost.Record) { 27 + const renderMastodonHtml = useRenderMastodonHtml() 28 + 29 + return useMemo(() => { 30 + if (!renderMastodonHtml) return false 31 + 32 + const fullText = record.fullText as string | undefined 33 + const bridgyOriginalText = record.bridgyOriginalText as string | undefined 34 + 35 + return !!(fullText || bridgyOriginalText) 36 + }, [record, renderMastodonHtml]) 37 + } 38 + 39 + export function MastodonHtmlContent({ 40 + record, 41 + style, 42 + textStyle, 43 + numberOfLines, 44 + }: MastodonHtmlContentProps) { 45 + const renderMastodonHtml = useRenderMastodonHtml() 46 + const {_} = useLingui() 47 + const [isExpanded, setIsExpanded] = useState(false) 48 + const [contentHeight, setContentHeight] = useState<number | null>(null) 49 + const [isTall, setIsTall] = useState(false) 50 + 51 + const renderedContent = useMemo(() => { 52 + if (!renderMastodonHtml) return null 53 + 54 + const fullText = record.fullText as string | undefined 55 + const bridgyOriginalText = record.bridgyOriginalText as string | undefined 56 + 57 + const rawHtml = fullText || bridgyOriginalText 58 + 59 + if (!rawHtml) return null 60 + 61 + // Parse HTML once and sanitize/render in a single pass 62 + return sanitizeAndRenderHtml(rawHtml, numberOfLines, textStyle) 63 + }, [record, renderMastodonHtml, numberOfLines, textStyle]) 64 + 65 + const handleLayout = (event: LayoutChangeEvent) => { 66 + const height = event.nativeEvent.layout.height 67 + if (contentHeight === null) { 68 + setContentHeight(height) 69 + // Consider content "tall" if it's taller than 150px 70 + setIsTall(height > 150) 71 + } 72 + } 73 + 74 + if (!renderedContent) return null 75 + 76 + const shouldCollapse = isTall && !isExpanded 77 + 78 + return ( 79 + <View style={style}> 80 + <View 81 + style={ 82 + shouldCollapse ? {maxHeight: 150, overflow: 'hidden'} : undefined 83 + } 84 + onLayout={handleLayout}> 85 + {renderedContent} 86 + </View> 87 + {shouldCollapse && ( 88 + <Button 89 + label={_(msg`Show more`)} 90 + onPress={() => setIsExpanded(true)} 91 + variant="ghost" 92 + color="primary" 93 + size="small" 94 + style={[a.mt_xs]}> 95 + <ButtonText> 96 + <Trans>Show more</Trans> 97 + </ButtonText> 98 + </Button> 99 + )} 100 + </View> 101 + ) 102 + } 103 + 104 + const LINK_PROTOCOLS = [ 105 + 'http', 106 + 'https', 107 + 'dat', 108 + 'dweb', 109 + 'ipfs', 110 + 'ipns', 111 + 'ssb', 112 + 'gopher', 113 + 'xmpp', 114 + 'magnet', 115 + 'gemini', 116 + ] 117 + 118 + const PROTOCOL_REGEX = /^([a-z][a-z0-9.+-]*):\/\//i 119 + 120 + const ALLOWED_ELEMENTS = [ 121 + 'p', 122 + 'br', 123 + 'span', 124 + 'a', 125 + 'del', 126 + 's', 127 + 'pre', 128 + 'blockquote', 129 + 'code', 130 + 'b', 131 + 'strong', 132 + 'u', 133 + 'i', 134 + 'em', 135 + 'ul', 136 + 'ol', 137 + 'li', 138 + 'ruby', 139 + 'rt', 140 + 'rp', 141 + ] 142 + 143 + function sanitizeAndRenderHtml( 144 + html: string, 145 + _numberOfLines?: number, 146 + inputTextStyle?: StyleProp<TextStyle>, 147 + ): React.ReactNode { 148 + if (typeof DOMParser === 'undefined') { 149 + // Fallback for environments without DOMParser 150 + return html.replace(/<[^>]*>/g, '') 151 + } 152 + 153 + const parser = new DOMParser() 154 + const doc = parser.parseFromString(html, 'text/html') 155 + 156 + const textStyle: StyleProp<TextStyle> = [ 157 + a.leading_snug, 158 + a.text_md, 159 + inputTextStyle, 160 + ] 161 + 162 + // Sanitize and render in a single pass 163 + const renderNode = ( 164 + node: Node, 165 + key: string, 166 + insideLink = false, 167 + listItemIndex?: number, 168 + ): React.ReactNode => { 169 + if (node.nodeType === Node.TEXT_NODE) { 170 + // Don't wrap text in styled Text component if inside a link 171 + if (insideLink) { 172 + return node.nodeValue 173 + } 174 + return ( 175 + <Text key={key} style={textStyle}> 176 + {node.nodeValue} 177 + </Text> 178 + ) 179 + } 180 + 181 + if (node.nodeType === Node.ELEMENT_NODE) { 182 + const element = node as Element 183 + const tagName = element.tagName.toLowerCase() 184 + 185 + // Handle unsupported elements (h1-h6) - convert to <strong> wrapped in <p> 186 + if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) { 187 + const children = Array.from(element.childNodes).map((child, i) => 188 + renderNode(child, String(i), insideLink), 189 + ) 190 + return ( 191 + <P key={key} style={textStyle}> 192 + <Text style={{...textStyle, fontWeight: 'bold'}}>{children}</Text> 193 + </P> 194 + ) 195 + } 196 + 197 + // Handle math elements - extract annotation text 198 + if (tagName === 'math') { 199 + const mathText = extractMathAnnotation(element) 200 + if (mathText) { 201 + return ( 202 + <Text key={key} style={textStyle}> 203 + {mathText} 204 + </Text> 205 + ) 206 + } 207 + return null 208 + } 209 + 210 + // Remove elements not in allowlist - replace with text content 211 + if (!ALLOWED_ELEMENTS.includes(tagName)) { 212 + return element.textContent ? ( 213 + <Text key={key} style={textStyle}> 214 + {element.textContent} 215 + </Text> 216 + ) : null 217 + } 218 + 219 + // Sanitize and process element 220 + sanitizeElementAttributes(element) 221 + 222 + const children = Array.from(element.childNodes).map((child, i) => 223 + renderNode(child, String(i), insideLink || tagName === 'a'), 224 + ) 225 + 226 + switch (tagName) { 227 + case 'p': 228 + return ( 229 + <P key={key} style={textStyle}> 230 + {children} 231 + </P> 232 + ) 233 + case 'blockquote': 234 + return ( 235 + <View 236 + key={key} 237 + style={{ 238 + borderLeftWidth: 3, 239 + borderLeftColor: '#888', 240 + paddingLeft: 12, 241 + marginVertical: 4, 242 + }}> 243 + <P style={textStyle}>{children}</P> 244 + </View> 245 + ) 246 + case 'pre': 247 + return ( 248 + <View 249 + key={key} 250 + style={{ 251 + backgroundColor: '#f5f5f5', 252 + padding: 8, 253 + borderRadius: 4, 254 + marginVertical: 4, 255 + }}> 256 + <P style={[textStyle, {fontFamily: 'monospace'}]}>{children}</P> 257 + </View> 258 + ) 259 + case 'code': 260 + return ( 261 + <Text 262 + key={key} 263 + style={[ 264 + textStyle, 265 + { 266 + fontFamily: 'monospace', 267 + backgroundColor: '#f5f5f5', 268 + paddingHorizontal: 4, 269 + borderRadius: 2, 270 + }, 271 + ]}> 272 + {children} 273 + </Text> 274 + ) 275 + case 'strong': 276 + case 'b': 277 + return ( 278 + <Text key={key} style={[textStyle, {fontWeight: 'bold'}]}> 279 + {children} 280 + </Text> 281 + ) 282 + case 'em': 283 + case 'i': 284 + return ( 285 + <Text key={key} style={[textStyle, {fontStyle: 'italic'}]}> 286 + {children} 287 + </Text> 288 + ) 289 + case 'u': 290 + return ( 291 + <Text 292 + key={key} 293 + style={[textStyle, {textDecorationLine: 'underline'}]}> 294 + {children} 295 + </Text> 296 + ) 297 + case 'del': 298 + case 's': 299 + return ( 300 + <Text 301 + key={key} 302 + style={[textStyle, {textDecorationLine: 'line-through'}]}> 303 + {children} 304 + </Text> 305 + ) 306 + case 'ul': 307 + return ( 308 + <View key={key} style={{marginVertical: 4}}> 309 + {children} 310 + </View> 311 + ) 312 + case 'ol': 313 + const start = element.getAttribute('start') 314 + const startNum = start ? parseInt(start, 10) : 1 315 + return ( 316 + <View key={key} style={{marginVertical: 4}}> 317 + {Array.from(element.childNodes) 318 + .filter( 319 + child => 320 + child.nodeType === Node.ELEMENT_NODE && 321 + (child as Element).tagName.toLowerCase() === 'li', 322 + ) 323 + .map((child, i) => 324 + renderNode(child, `${key}-${i}`, insideLink, startNum + i), 325 + )} 326 + </View> 327 + ) 328 + case 'li': 329 + const marker = 330 + listItemIndex !== undefined ? `${listItemIndex}.` : '\u2022' 331 + return ( 332 + <View key={key} style={{flexDirection: 'row', marginVertical: 2}}> 333 + <Text style={[textStyle, {marginRight: 8}]}>{marker}</Text> 334 + <Text style={[textStyle, {flex: 1}]}>{children}</Text> 335 + </View> 336 + ) 337 + case 'ruby': 338 + return ( 339 + <Text key={key} style={textStyle}> 340 + {children} 341 + </Text> 342 + ) 343 + case 'rt': 344 + case 'rp': 345 + return null // TODO support ruby text rendering 346 + case 'a': 347 + const href = element.getAttribute('href') 348 + if (href) { 349 + const linkText = 350 + element.textContent || element.getAttribute('aria-label') || href 351 + const className = element.getAttribute('class') 352 + const isInvisible = className?.includes('invisible') 353 + return ( 354 + <InlineLinkText 355 + key={key} 356 + to={href} 357 + label={linkText} 358 + shouldProxy 359 + style={isInvisible ? {display: 'none'} : textStyle}> 360 + {children} 361 + </InlineLinkText> 362 + ) 363 + } 364 + return <Text key={key}>{children}</Text> 365 + case 'br': 366 + return '\n' 367 + case 'span': 368 + const spanClass = element.getAttribute('class') 369 + // Handle invisible/ellipsis classes for link formatting 370 + if (spanClass?.includes('invisible')) { 371 + return ( 372 + <Text key={key} style={{display: 'none'}}> 373 + {children} 374 + </Text> 375 + ) 376 + } 377 + if (spanClass?.includes('ellipsis')) { 378 + // If inside a link, return plain text, otherwise wrapped 379 + if (insideLink) { 380 + return '\u2026' 381 + } 382 + return ( 383 + <Text key={key} style={textStyle}> 384 + {'\u2026'} 385 + </Text> 386 + ) 387 + } 388 + // Handle mentions and hashtags 389 + if ( 390 + spanClass?.includes('mention') || 391 + spanClass?.includes('hashtag') 392 + ) { 393 + // If inside a link, return children as-is without wrapping 394 + if (insideLink) { 395 + return children 396 + } 397 + return ( 398 + <Text key={key} style={textStyle}> 399 + {children} 400 + </Text> 401 + ) 402 + } 403 + // For spans inside links, return children without wrapping 404 + if (insideLink) { 405 + return children 406 + } 407 + return ( 408 + <Text key={key} style={textStyle}> 409 + {children} 410 + </Text> 411 + ) 412 + default: 413 + return ( 414 + <Text key={key} style={textStyle}> 415 + {children} 416 + </Text> 417 + ) 418 + } 419 + } 420 + 421 + return null 422 + } 423 + 424 + const content = Array.from(doc.body.childNodes).map((node, i) => 425 + renderNode(node, String(i)), 426 + ) 427 + 428 + return <View style={{gap: 8}}>{content}</View> 429 + } 430 + 431 + function sanitizeElementAttributes(element: Element): void { 432 + const tagName = element.tagName.toLowerCase() 433 + const allowedAttrs: Record<string, string[]> = { 434 + a: ['href', 'rel', 'class', 'translate'], 435 + span: ['class', 'translate'], 436 + ol: ['start', 'reversed'], 437 + li: ['value'], 438 + p: ['class'], 439 + } 440 + 441 + const allowed = allowedAttrs[tagName] || [] 442 + const attrs = Array.from(element.attributes) 443 + 444 + // Remove non-allowed attributes 445 + for (const attr of attrs) { 446 + const attrName = attr.name.toLowerCase() 447 + const isAllowed = allowed.some(allowedAttr => { 448 + if (allowedAttr.endsWith('*')) { 449 + return attrName.startsWith(allowedAttr.slice(0, -1)) 450 + } 451 + return allowedAttr === attrName 452 + }) 453 + 454 + if (!isAllowed) { 455 + element.removeAttribute(attr.name) 456 + } 457 + } 458 + 459 + // Process specific attributes 460 + if (tagName === 'a') { 461 + processAnchorElement(element) 462 + } 463 + 464 + // Process class whitelist 465 + if (element.hasAttribute('class')) { 466 + processClassWhitelist(element) 467 + } 468 + 469 + // Process translate attribute - remove unless it's "no" 470 + if (element.hasAttribute('translate')) { 471 + const translate = element.getAttribute('translate') 472 + if (translate !== 'no') { 473 + element.removeAttribute('translate') 474 + } 475 + } 476 + } 477 + 478 + function processAnchorElement(element: Element): void { 479 + // Check if href has unsupported protocol 480 + const href = element.getAttribute('href') 481 + if (href) { 482 + const scheme = getScheme(href) 483 + if ( 484 + scheme !== null && 485 + scheme !== 'relative' && 486 + !LINK_PROTOCOLS.includes(scheme) 487 + ) { 488 + // Remove the href to disable the link 489 + element.removeAttribute('href') 490 + } 491 + } 492 + } 493 + 494 + function processClassWhitelist(element: Element): void { 495 + const classList = element.className.split(/[\t\n\f\r ]+/).filter(Boolean) 496 + const whitelisted = classList.filter(className => { 497 + // microformats classes 498 + if (/^[hpuedt]-/.test(className)) return true 499 + // semantic classes 500 + if (/^(mention|hashtag)$/.test(className)) return true 501 + // link formatting classes 502 + if (/^(ellipsis|invisible)$/.test(className)) return true 503 + // quote inline class 504 + if (className === 'quote-inline') return true 505 + return false 506 + }) 507 + 508 + if (whitelisted.length > 0) { 509 + element.className = whitelisted.join(' ') 510 + } else { 511 + element.removeAttribute('class') 512 + } 513 + } 514 + 515 + function getScheme(url: string): string | null { 516 + const match = url.match(PROTOCOL_REGEX) 517 + if (match) { 518 + return match[1].toLowerCase() 519 + } 520 + // Check if it's a relative URL 521 + if (url.startsWith('/') || url.startsWith('.')) { 522 + return 'relative' 523 + } 524 + return null 525 + } 526 + 527 + function extractMathAnnotation(mathElement: Element): string | null { 528 + const semantics = Array.from(mathElement.children).find( 529 + child => child.tagName.toLowerCase() === 'semantics', 530 + ) as Element | undefined 531 + 532 + if (!semantics) return null 533 + 534 + // Look for LaTeX annotation (application/x-tex) 535 + const latexAnnotation = Array.from(semantics.children).find(child => { 536 + return ( 537 + child.tagName.toLowerCase() === 'annotation' && 538 + child.getAttribute('encoding') === 'application/x-tex' 539 + ) 540 + }) 541 + 542 + if (latexAnnotation) { 543 + const display = mathElement.getAttribute('display') 544 + const text = latexAnnotation.textContent || '' 545 + return display === 'block' ? `$$${text}$$` : `$${text}$` 546 + } 547 + 548 + // Look for plain text annotation 549 + const plainAnnotation = Array.from(semantics.children).find(child => { 550 + return ( 551 + child.tagName.toLowerCase() === 'annotation' && 552 + child.getAttribute('encoding') === 'text/plain' 553 + ) 554 + }) 555 + 556 + if (plainAnnotation) { 557 + return plainAnnotation.textContent || null 558 + } 559 + 560 + return null 561 + }
+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 ( 237 + scheme !== null && 238 + scheme !== 'relative' && 239 + !LINK_PROTOCOLS.includes(scheme) 240 + ) { 241 + // Replace element with its text content 242 + const textNode = element.ownerDocument!.createTextNode( 243 + element.textContent || '', 244 + ) 245 + element.replaceWith(textNode) 246 + } 247 + } 248 + } 249 + 250 + function processClassWhitelist(element: HTMLElement): void { 251 + const classList = element.className.split(/[\t\n\f\r ]+/).filter(Boolean) 252 + const whitelisted = classList.filter(className => { 253 + // microformats classes 254 + if (/^[hpuedt]-/.test(className)) return true 255 + // semantic classes 256 + if (/^(mention|hashtag)$/.test(className)) return true 257 + // link formatting classes 258 + if (/^(ellipsis|invisible)$/.test(className)) return true 259 + // quote inline class 260 + if (className === 'quote-inline') return true 261 + return false 262 + }) 263 + 264 + if (whitelisted.length > 0) { 265 + element.className = whitelisted.join(' ') 266 + } else { 267 + element.removeAttribute('class') 268 + } 269 + } 270 + 271 + function validateProtocols( 272 + element: HTMLElement, 273 + options: SanitizeOptions, 274 + ): void { 275 + const tagName = element.tagName.toLowerCase() 276 + const src = element.getAttribute('src') 277 + const href = element.getAttribute('href') 278 + const url = src || href 279 + 280 + if (!url) return 281 + 282 + const scheme = getScheme(url) 283 + 284 + // For oembed elements, only allow HTTP protocols for src 285 + if (options.allowOembed && src && ['iframe', 'source'].includes(tagName)) { 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 + }
+46 -19
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 52 52 import {type AppModerationCause} from '#/components/Pills' 53 53 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 54 54 import {TranslatedPost} from '#/components/Post/Translated' 55 + import { 56 + MastodonHtmlContent, 57 + useHasMastodonHtmlContent, 58 + } from '#/components/Post/MastodonHtmlContent' 55 59 import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 56 60 import {useFormatPostStatCount} from '#/components/PostControls/util' 57 61 import {ProfileBadges} from '#/components/ProfileBadges' ··· 192 196 const moderation = item.moderation 193 197 const authorShadow = useProfileShadow(post.author) 194 198 const {isActive: live} = useActorStatus(post.author) 199 + const hasMastodonHtml = useHasMastodonHtmlContent(record) 195 200 const richText = useMemo( 196 201 () => 197 202 new RichTextAPI({ ··· 408 413 style={[a.pb_sm]} 409 414 additionalCauses={additionalPostAlerts} 410 415 /> 411 - {richText?.text ? ( 412 - <RichText 413 - enableTags 414 - selectable 415 - value={richText} 416 - style={[a.flex_1, a.text_lg]} 417 - authorHandle={post.author.handle} 418 - shouldProxyLinks={true} 419 - /> 420 - ) : undefined} 421 - <TranslatedPost post={post} postTextStyle={[a.text_lg]} /> 422 - {post.embed && ( 423 - <View style={[a.py_xs]}> 424 - <Embed 425 - embed={post.embed} 426 - moderation={moderation} 427 - viewContext={PostEmbedViewContext.ThreadHighlighted} 428 - onOpen={onOpenEmbed} 416 + {hasMastodonHtml ? ( 417 + <> 418 + <MastodonHtmlContent 419 + record={record} 420 + style={[a.flex_1]} 421 + textStyle={[a.text_lg]} 429 422 /> 430 - </View> 423 + {post.embed && ( 424 + <View style={[a.py_xs]}> 425 + <Embed 426 + embed={post.embed} 427 + moderation={moderation} 428 + viewContext={PostEmbedViewContext.ThreadHighlighted} 429 + onOpen={onOpenEmbed} 430 + /> 431 + </View> 432 + )} 433 + </> 434 + ) : ( 435 + <> 436 + {richText?.text ? ( 437 + <RichText 438 + enableTags 439 + selectable 440 + value={richText} 441 + style={[a.flex_1, a.text_lg]} 442 + authorHandle={post.author.handle} 443 + shouldProxyLinks={true} 444 + /> 445 + ) : undefined} 446 + <TranslatedPost post={post} postTextStyle={[a.text_lg]} /> 447 + {post.embed && ( 448 + <View style={[a.py_xs]}> 449 + <Embed 450 + embed={post.embed} 451 + moderation={moderation} 452 + viewContext={PostEmbedViewContext.ThreadHighlighted} 453 + onOpen={onOpenEmbed} 454 + /> 455 + </View> 456 + )} 457 + </> 431 458 )} 432 459 </ContentHider> 433 460 <ExpandedPostDetails
+49 -23
src/screens/PostThread/components/ThreadItemPost.tsx
··· 37 37 import {PostHider} from '#/components/moderation/PostHider' 38 38 import {type AppModerationCause} from '#/components/Pills' 39 39 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 40 + import { 41 + MastodonHtmlContent, 42 + useHasMastodonHtmlContent, 43 + } from '#/components/Post/MastodonHtmlContent' 40 44 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 41 45 import {TranslatedPost} from '#/components/Post/Translated' 42 46 import {PostControls, PostControlsSkeleton} from '#/components/PostControls' ··· 192 196 const post = item.value.post 193 197 const record = item.value.post.record 194 198 const moderation = item.moderation 199 + const hasMastodonHtml = useHasMastodonHtmlContent(post.record) 195 200 const richText = useMemo( 196 201 () => 197 202 new RichTextAPI({ ··· 303 308 style={[a.pb_2xs]} 304 309 additionalCauses={additionalPostAlerts} 305 310 /> 306 - {richText?.text ? ( 307 - <View style={[a.mb_2xs]}> 308 - <RichText 309 - enableTags 310 - value={richText} 311 + {hasMastodonHtml ? ( 312 + <> 313 + <MastodonHtmlContent 314 + record={post.record} 311 315 style={[a.flex_1, a.text_md]} 312 316 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 313 - authorHandle={post.author.handle} 314 - shouldProxyLinks={true} 315 317 /> 316 - {limitLines && ( 317 - <ShowMoreTextButton 318 - style={[a.text_md]} 319 - onPress={onPressShowMore} 320 - /> 318 + {post.embed && ( 319 + <View style={[a.pb_xs]}> 320 + <Embed 321 + embed={post.embed} 322 + moderation={moderation} 323 + viewContext={PostEmbedViewContext.Feed} 324 + /> 325 + </View> 321 326 )} 322 - </View> 323 - ) : undefined} 324 - <TranslatedPost hideTranslateLink post={post} /> 325 - {post.embed && ( 326 - <View style={[a.pb_xs]}> 327 - <Embed 328 - embed={post.embed} 329 - moderation={moderation} 330 - viewContext={PostEmbedViewContext.Feed} 331 - /> 332 - </View> 327 + </> 328 + ) : ( 329 + <> 330 + {richText?.text ? ( 331 + <> 332 + <RichText 333 + enableTags 334 + value={richText} 335 + style={[a.flex_1, a.text_md]} 336 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 337 + authorHandle={post.author.handle} 338 + shouldProxyLinks={true} 339 + /> 340 + {limitLines && ( 341 + <ShowMoreTextButton 342 + style={[a.text_md]} 343 + onPress={onPressShowMore} 344 + /> 345 + )} 346 + </> 347 + ) : undefined} 348 + <TranslatedPost hideTranslateLink post={post} /> 349 + {post.embed && ( 350 + <View style={[a.pb_xs]}> 351 + <Embed 352 + embed={post.embed} 353 + moderation={moderation} 354 + viewContext={PostEmbedViewContext.Feed} 355 + /> 356 + </View> 357 + )} 358 + </> 333 359 )} 334 360 <PostControls 335 361 post={postShadow}
+25
src/screens/Settings/RunesSettings.tsx
··· 141 141 useSetPostReplacement, 142 142 } from '#/state/preferences/post-name-replacement' 143 143 import { 144 + useRenderMastodonHtml, 145 + useSetRenderMastodonHtml, 146 + } from '#/state/preferences/render-mastodon-html' 147 + import { 144 148 useRepostCarouselEnabled, 145 149 useSetRepostCarouselEnabled, 146 150 } from '#/state/preferences/repost-carousel-enabled' ··· 1008 1012 const hideUnreplyablePosts = useHideUnreplyablePosts() 1009 1013 const setHideUnreplyablePosts = useSetHideUnreplyablePosts() 1010 1014 1015 + const renderMastodonHtml = useRenderMastodonHtml() 1016 + const setRenderMastodonHtml = useSetRenderMastodonHtml() 1017 + 1011 1018 const disableVerifyEmailReminder = useDisableVerifyEmailReminder() 1012 1019 const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder() 1013 1020 ··· 1376 1383 </Toggle.LabelText> 1377 1384 <Toggle.Platform /> 1378 1385 </Toggle.Item> 1386 + 1387 + <Toggle.Item 1388 + name="render_mastodon_html" 1389 + label={_(msg`Render Mastodon HTML from bridged posts`)} 1390 + value={renderMastodonHtml} 1391 + onChange={value => setRenderMastodonHtml(value)} 1392 + style={[a.w_full]}> 1393 + <Toggle.LabelText style={[a.flex_1]}> 1394 + <Trans>Render Mastodon HTML from bridged posts</Trans> 1395 + </Toggle.LabelText> 1396 + <Toggle.Platform /> 1397 + </Toggle.Item> 1398 + <Admonition type="info" style={[a.flex_1]}> 1399 + <Trans> 1400 + When enabled, posts bridged from Mastodon will display their 1401 + original HTML formatting instead of the plain text version. 1402 + </Trans> 1403 + </Admonition> 1379 1404 1380 1405 <Toggle.Item 1381 1406 name="hide_unreplyable_posts"
+2
src/state/persisted/schema.ts
··· 188 188 postName: z.string().optional(), 189 189 postsName: z.string().optional(), 190 190 }), 191 + renderMastodonHtml: z.boolean().optional(), 191 192 192 193 showExternalShareButtons: z.boolean().optional(), 193 194 ··· 322 323 enabled: true, 323 324 hideBskyPds: true, 324 325 }, 326 + renderMastodonHtml: false, 325 327 showExternalShareButtons: false, 326 328 translationServicePreference: 'google', 327 329 libreTranslateInstance: 'https://libretranslate.com/',
+6 -3
src/state/preferences/index.tsx
··· 39 39 import {Provider as OpenRouterProvider} from './openrouter' 40 40 import {Provider as PdsLabelProvider} from './pds-label' 41 41 import {Provider as PostNameReplacementProvider} from './post-name-replacement.tsx' 42 + import {Provider as RenderMastodonHtmlProvider} from './render-mastodon-html' 42 43 import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 43 44 import {Provider as ShowFollowsYouBadgeProvider} from './show-follows-you-badge' 44 45 import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' ··· 139 140 <OpenRouterProvider> 140 141 <DisableComposerPromptProvider> 141 142 <DiscoverContextEnabledProvider> 142 - { 143 - children 144 - } 143 + <RenderMastodonHtmlProvider> 144 + { 145 + children 146 + } 147 + </RenderMastodonHtmlProvider> 145 148 </DiscoverContextEnabledProvider> 146 149 </DisableComposerPromptProvider> 147 150 </OpenRouterProvider>
+47
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 React.useContext(stateContext) ?? persisted.defaults.renderMastodonHtml 43 + } 44 + 45 + export function useSetRenderMastodonHtml() { 46 + return React.useContext(setContext) 47 + }
+57 -25
src/view/com/posts/PostFeedItem.tsx
··· 40 40 import {type AppModerationCause} from '#/components/Pills' 41 41 import {Embed} from '#/components/Post/Embed' 42 42 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 43 + import { 44 + MastodonHtmlContent, 45 + useHasMastodonHtmlContent, 46 + } from '#/components/Post/MastodonHtmlContent' 43 47 import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 44 48 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 45 49 import {TranslatedPost} from '#/components/Post/Translated' ··· 428 432 threadgateRecord?: AppBskyFeedThreadgate.Record 429 433 }): React.ReactNode => { 430 434 const {currentAccount} = useSession() 435 + const hasMastodonHtml = useHasMastodonHtmlContent( 436 + post.record as AppBskyFeedPost.Record, 437 + ) 431 438 const [limitLines, setLimitLines] = useState( 432 439 () => countLines(richText.text) >= MAX_POST_LINES, 433 440 ) ··· 478 485 style={[a.pb_xs]} 479 486 additionalCauses={additionalPostAlerts} 480 487 /> 481 - {richText.text ? ( 482 - <View style={[a.mb_2xs]}> 483 - <RichText 484 - enableTags 485 - testID="postText" 486 - value={richText} 487 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 488 + {hasMastodonHtml ? ( 489 + <> 490 + <MastodonHtmlContent 491 + record={post.record as AppBskyFeedPost.Record} 488 492 style={[a.flex_1, a.text_md]} 489 - authorHandle={postAuthor.handle} 490 - shouldProxyLinks={true} 491 - /> 492 - {limitLines && ( 493 - <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> 494 - )} 495 - </View> 496 - ) : undefined} 497 - {record && <TranslatedPost hideTranslateLink post={post} />} 498 - {postEmbed ? ( 499 - <View style={[a.pb_xs]}> 500 - <Embed 501 - embed={postEmbed} 502 - moderation={moderation} 503 - onOpen={onOpenEmbed} 504 - viewContext={PostEmbedViewContext.Feed} 493 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 505 494 /> 506 - </View> 507 - ) : null} 495 + {postEmbed ? ( 496 + <View style={[a.pb_xs]}> 497 + <Embed 498 + embed={postEmbed} 499 + moderation={moderation} 500 + onOpen={onOpenEmbed} 501 + viewContext={PostEmbedViewContext.Feed} 502 + /> 503 + </View> 504 + ) : null} 505 + </> 506 + ) : ( 507 + <> 508 + {richText.text ? ( 509 + <> 510 + <RichText 511 + enableTags 512 + testID="postText" 513 + value={richText} 514 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 515 + style={[a.flex_1, a.text_md]} 516 + authorHandle={postAuthor.handle} 517 + shouldProxyLinks={true} 518 + /> 519 + {limitLines && ( 520 + <ShowMoreTextButton 521 + style={[a.text_md]} 522 + onPress={onPressShowMore} 523 + /> 524 + )} 525 + </> 526 + ) : undefined} 527 + {record && <TranslatedPost hideTranslateLink post={post} />} 528 + {postEmbed ? ( 529 + <View style={[a.pb_xs]}> 530 + <Embed 531 + embed={postEmbed} 532 + moderation={moderation} 533 + onOpen={onOpenEmbed} 534 + viewContext={PostEmbedViewContext.Feed} 535 + /> 536 + </View> 537 + ) : null} 538 + </> 539 + )} 508 540 </ContentHider> 509 541 ) 510 542 }