Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

More mastodon bridged HTML fixes

uwx aabe5d20 9e7c92c5

+213 -249
+209 -247
src/components/Post/MastodonHtmlContent.tsx
··· 6 6 View, 7 7 type ViewStyle, 8 8 } from 'react-native' 9 + import {MathJaxSvg} from 'react-native-mathjax-html-to-svg' 9 10 import {type AppBskyFeedPost} from '@atproto/api' 10 - import {msg, Trans} from '@lingui/macro' 11 + import {msg} from '@lingui/core/macro' 11 12 import {useLingui} from '@lingui/react' 13 + import {Trans} from '@lingui/react/macro' 12 14 13 15 import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html' 14 16 import {atoms as a} from '#/alf' 15 17 import {Button, ButtonText} from '#/components/Button' 16 18 import {InlineLinkText} from '#/components/Link' 17 19 import {P, Text} from '#/components/Typography' 20 + import {RichTextTag} from '../RichTextTag' 18 21 19 22 interface MastodonHtmlContentProps { 20 23 record: AppBskyFeedPost.Record ··· 32 35 const fullText = record.fullText as string | undefined 33 36 const bridgyOriginalText = record.bridgyOriginalText as string | undefined 34 37 35 - return !!(fullText || bridgyOriginalText) 38 + return ( 39 + !!(fullText || bridgyOriginalText) && typeof DOMParser !== 'undefined' 40 + ) 36 41 }, [record, renderMastodonHtml]) 37 42 } 38 43 ··· 101 106 ) 102 107 } 103 108 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 - ] 109 + const PROTOCOL_REGEX = 110 + /^(https?|dat|dweb|ipfs|ipns|ssb|gopher|xmpp|magnet|gemini):\/\//i 142 111 143 112 function sanitizeAndRenderHtml( 144 113 html: string, ··· 146 115 inputTextStyle?: StyleProp<TextStyle>, 147 116 ): React.ReactNode { 148 117 if (typeof DOMParser === 'undefined') { 149 - // Fallback for environments without DOMParser 150 - return html.replace(/<[^>]*>/g, '') 118 + return null 151 119 } 152 120 153 121 const parser = new DOMParser() 154 122 const doc = parser.parseFromString(html, 'text/html') 123 + 124 + const base = null // TODO: find instance URL, allow relative URLs. mastodon allows this 155 125 156 126 const textStyle: StyleProp<TextStyle> = [ 157 127 a.leading_snug, ··· 182 152 const element = node as Element 183 153 const tagName = element.tagName.toLowerCase() 184 154 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> 155 + switch (tagName) { 156 + case 'math': 157 + const mathText = extractMathAnnotation(element) 158 + if (mathText) { 159 + return ( 160 + <MathJaxSvg key={key} fontCache={true}> 161 + {mathText} 162 + </MathJaxSvg> 163 + ) 164 + } 165 + return null 166 + case 'p': { 167 + const children = [...element.childNodes].map((child, i) => 168 + renderNode(child, String(i), insideLink), 205 169 ) 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 170 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 171 return ( 229 172 <P key={key} style={textStyle}> 230 173 {children} 231 174 </P> 232 175 ) 233 - case 'blockquote': 176 + } 177 + case 'blockquote': { 178 + const children = [...element.childNodes].map((child, i) => 179 + renderNode(child, String(i), insideLink), 180 + ) 181 + 234 182 return ( 235 183 <View 236 184 key={key} ··· 243 191 <P style={textStyle}>{children}</P> 244 192 </View> 245 193 ) 246 - case 'pre': 194 + } 195 + case 'pre': { 196 + const children = [...element.childNodes].map((child, i) => 197 + renderNode(child, String(i), insideLink), 198 + ) 199 + 247 200 return ( 248 201 <View 249 202 key={key} 250 203 style={{ 251 - backgroundColor: '#f5f5f5', 252 204 padding: 8, 253 205 borderRadius: 4, 254 206 marginVertical: 4, ··· 256 208 <P style={[textStyle, {fontFamily: 'monospace'}]}>{children}</P> 257 209 </View> 258 210 ) 259 - case 'code': 211 + } 212 + case 'code': { 213 + const children = [...element.childNodes].map((child, i) => 214 + renderNode(child, String(i), insideLink), 215 + ) 216 + 260 217 return ( 261 218 <Text 262 219 key={key} ··· 264 221 textStyle, 265 222 { 266 223 fontFamily: 'monospace', 267 - backgroundColor: '#f5f5f5', 268 224 paddingHorizontal: 4, 269 225 borderRadius: 2, 270 226 }, ··· 272 228 {children} 273 229 </Text> 274 230 ) 231 + } 275 232 case 'strong': 276 - case 'b': 233 + case 'b': { 234 + const children = [...element.childNodes].map((child, i) => 235 + renderNode(child, String(i), insideLink), 236 + ) 237 + 277 238 return ( 278 - <Text key={key} style={[textStyle, {fontWeight: 'bold'}]}> 239 + <Text key={key} style={[textStyle, a.font_bold]}> 279 240 {children} 280 241 </Text> 281 242 ) 243 + } 282 244 case 'em': 283 - case 'i': 245 + case 'i': { 246 + const children = [...element.childNodes].map((child, i) => 247 + renderNode(child, String(i), insideLink), 248 + ) 249 + 284 250 return ( 285 - <Text key={key} style={[textStyle, {fontStyle: 'italic'}]}> 251 + <Text key={key} style={[textStyle, a.italic]}> 286 252 {children} 287 253 </Text> 288 254 ) 289 - case 'u': 255 + } 256 + case 'u': { 257 + const children = [...element.childNodes].map((child, i) => 258 + renderNode(child, String(i), insideLink), 259 + ) 260 + 290 261 return ( 291 - <Text 292 - key={key} 293 - style={[textStyle, {textDecorationLine: 'underline'}]}> 262 + <Text key={key} style={[textStyle, a.underline]}> 294 263 {children} 295 264 </Text> 296 265 ) 266 + } 297 267 case 'del': 298 - case 's': 268 + case 's': { 269 + const children = [...element.childNodes].map((child, i) => 270 + renderNode(child, String(i), insideLink), 271 + ) 272 + 299 273 return ( 300 - <Text 301 - key={key} 302 - style={[textStyle, {textDecorationLine: 'line-through'}]}> 274 + <Text key={key} style={[textStyle, a.strike_through]}> 303 275 {children} 304 276 </Text> 305 277 ) 306 - case 'ul': 278 + } 279 + case 'ul': { 280 + const children = [...element.childNodes].map((child, i) => 281 + renderNode(child, String(i), insideLink), 282 + ) 283 + 307 284 return ( 308 285 <View key={key} style={{marginVertical: 4}}> 309 286 {children} 310 287 </View> 311 288 ) 312 - case 'ol': 289 + } 290 + case 'ol': { 313 291 const start = element.getAttribute('start') 314 292 const startNum = start ? parseInt(start, 10) : 1 315 293 return ( ··· 325 303 )} 326 304 </View> 327 305 ) 328 - case 'li': 306 + } 307 + case 'li': { 308 + const children = [...element.childNodes].map((child, i) => 309 + renderNode(child, String(i), insideLink), 310 + ) 311 + 329 312 const marker = 330 313 listItemIndex !== undefined ? `${listItemIndex}.` : '\u2022' 331 314 return ( ··· 334 317 <Text style={[textStyle, {flex: 1}]}>{children}</Text> 335 318 </View> 336 319 ) 337 - case 'ruby': 320 + } 321 + case 'ruby': { 322 + const children = [...element.childNodes].map((child, i) => 323 + renderNode(child, String(i), insideLink), 324 + ) 325 + 338 326 return ( 339 327 <Text key={key} style={textStyle}> 340 328 {children} 341 329 </Text> 342 330 ) 331 + } 343 332 case 'rt': 344 333 case 'rp': 345 334 return null // TODO support ruby text rendering 346 - case 'a': 335 + case 'a': { 336 + const children = [...element.childNodes].map((child, i) => 337 + renderNode(child, String(i), true), 338 + ) 339 + 347 340 const href = element.getAttribute('href') 348 341 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 - ) 342 + // Returns null if invalid or unsupported URL, otherwise returns an absolute URL string 343 + const url = constructHref(href, base) 344 + if (url) { 345 + const linkText = 346 + element.textContent || 347 + element.getAttribute('aria-label') || 348 + getDisplayUrl(url) 349 + const isInvisible = hasClass(element, 'invisible') 350 + return ( 351 + <InlineLinkText 352 + key={key} 353 + to={url.toString()} 354 + label={linkText} 355 + shouldProxy 356 + style={isInvisible ? {display: 'none'} : textStyle}> 357 + {children} 358 + </InlineLinkText> 359 + ) 360 + } 363 361 } 364 362 return <Text key={key}>{children}</Text> 363 + } 365 364 case 'br': 366 365 return '\n' 367 - case 'span': 368 - const spanClass = element.getAttribute('class') 366 + case 'span': { 367 + const children = [...element.childNodes].map((child, i) => 368 + renderNode(child, String(i), insideLink), 369 + ) 370 + 369 371 // Handle invisible/ellipsis classes for link formatting 370 - if (spanClass?.includes('invisible')) { 372 + if (hasClass(element, 'invisible')) { 371 373 return ( 372 - <Text key={key} style={{display: 'none'}}> 374 + <Text key={key} style={{display: 'none'}} aria-hidden> 373 375 {children} 374 376 </Text> 375 377 ) 376 378 } 377 - if (spanClass?.includes('ellipsis')) { 379 + if (hasClass(element, 'ellipsis')) { 378 380 // If inside a link, return plain text, otherwise wrapped 379 381 if (insideLink) { 380 382 return '\u2026' ··· 385 387 </Text> 386 388 ) 387 389 } 388 - // Handle mentions and hashtags 389 - if ( 390 - spanClass?.includes('mention') || 391 - spanClass?.includes('hashtag') 392 - ) { 390 + // Handle hashtags 391 + if (hasClass(element, 'hashtag')) { 392 + // If inside a link, return children as-is without wrapping 393 + if (insideLink) { 394 + return children 395 + } 396 + 397 + const tagText = element.textContent?.trim().replace(/^#/, '') 398 + if (!tagText) { 399 + return null 400 + } 401 + 402 + return ( 403 + <RichTextTag 404 + key={key} 405 + display={tagText} 406 + tag={tagText} 407 + textStyle={[textStyle, a.underline]} 408 + /> 409 + ) 410 + } 411 + // Handle mentions 412 + if (hasClass(element, 'mention')) { 393 413 // If inside a link, return children as-is without wrapping 394 414 if (insideLink) { 395 415 return children ··· 409 429 {children} 410 430 </Text> 411 431 ) 432 + } 433 + case 'h1': 434 + case 'h2': 435 + case 'h3': 436 + case 'h4': 437 + case 'h5': 438 + case 'h6': { 439 + const children = [...element.childNodes].map((child, i) => 440 + renderNode(child, String(i), insideLink), 441 + ) 442 + 443 + return ( 444 + <P key={key} style={textStyle}> 445 + <Text style={[textStyle, {fontWeight: 'bold'}]}>{children}</Text> 446 + </P> 447 + ) 448 + } 412 449 default: 450 + // Render node contents 413 451 return ( 414 - <Text key={key} style={textStyle}> 415 - {children} 416 - </Text> 452 + <> 453 + {[...element.childNodes].map((child, i) => 454 + renderNode(child, `${key}-${i}`, insideLink), 455 + )} 456 + </> 417 457 ) 418 458 } 419 459 } ··· 428 468 return <View style={{gap: 8}}>{content}</View> 429 469 } 430 470 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 - } 471 + function hasClass(element: Element, name: string): boolean { 472 + return element.classList.contains(name) 473 + } 440 474 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) 475 + function constructHref(href: string, base: string | null): URL | null { 476 + const match = href.match(PROTOCOL_REGEX) 477 + if (match) { 478 + try { 479 + return new URL(href) 480 + } catch (err) { 481 + return null 456 482 } 457 483 } 458 484 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') 485 + // Check if it's a relative URL 486 + if (href.startsWith('/') || href.startsWith('.')) { 487 + if (!base) { 488 + return null 474 489 } 475 - } 476 - } 477 490 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') 491 + try { 492 + return new URL(href, base) 493 + } catch (err) { 494 + return null 490 495 } 491 496 } 492 - } 493 497 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 498 return null 525 499 } 526 500 527 501 function extractMathAnnotation(mathElement: Element): string | null { 528 - const semantics = Array.from(mathElement.children).find( 529 - child => child.tagName.toLowerCase() === 'semantics', 530 - ) as Element | undefined 502 + // look for <annotation encoding="application/x-tex"> or <annotation encoding="text/plain"> 503 + return ( 504 + mathElement 505 + .querySelector( 506 + 'annotation[encoding="application/x-tex"], annotation[encoding="text/plain"]', 507 + ) 508 + ?.textContent?.trim() || null 509 + ) 510 + } 531 511 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}$` 512 + function getDisplayUrl(url: URL): string { 513 + // For HTTP(S) protocol, display a simplified URL without the protocol and "www" subdomain 514 + if (url.protocol === 'http:' || url.protocol === 'https:') { 515 + let displayUrl = url.host + url.pathname + url.search + url.hash 516 + if (displayUrl.startsWith('www.')) { 517 + displayUrl = displayUrl.slice(4) 518 + } 519 + return displayUrl 546 520 } 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 521 + // For other protocols, return the full URL string 522 + return url.toString() 561 523 }
+2 -1
src/screens/PostThread/components/ThreadItemPost.tsx
··· 312 312 <> 313 313 <MastodonHtmlContent 314 314 record={post.record} 315 - style={[a.flex_1, a.text_md]} 315 + style={[a.flex_1]} 316 + textStyle={[a.text_md]} 316 317 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 317 318 /> 318 319 {post.embed && (
+2 -1
src/view/com/posts/PostFeedItem.tsx
··· 489 489 <> 490 490 <MastodonHtmlContent 491 491 record={post.record as AppBskyFeedPost.Record} 492 - style={[a.flex_1, a.text_md]} 492 + style={[a.flex_1]} 493 + textStyle={[a.text_md]} 493 494 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 494 495 /> 495 496 {postEmbed ? (