Bluesky app fork with some witchin' additions ๐Ÿ’ซ witchsky.app
bluesky fork client

Render original HTML text of posts bridged from the Fediverse or Wafrn #26

closed opened by maxine.puppykitty.racing targeting main
Labels

None yet.

assignee

None yet.

Participants 4
AT URI
at://did:plc:nmc77zslrwafxn75j66mep6o/sh.tangled.repo.pull/3m7io6kv5sl22
+67 -1021
Interdiff #0 โ†’ #1
-435
src/components/Post/MastodonHtmlContent.tsx
··· 1 - import {useMemo} from 'react' 2 - import {type StyleProp, type TextStyle, View, type ViewStyle} from 'react-native' 3 - import {type AppBskyFeedPost} from '@atproto/api' 4 - 5 - import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html' 6 - import { atoms } from '#/alf' 7 - import {InlineLinkText} from '#/components/Link' 8 - import {P, Text} from '#/components/Typography' 9 - 10 - interface MastodonHtmlContentProps { 11 - record: AppBskyFeedPost.Record 12 - style?: StyleProp<ViewStyle>, 13 - textStyle?: StyleProp<TextStyle>, 14 - numberOfLines?: number 15 - } 16 - 17 - export function useHasMastodonHtmlContent(record: AppBskyFeedPost.Record) { 18 - const renderMastodonHtml = useRenderMastodonHtml() 19 - 20 - return useMemo(() => { 21 - if (!renderMastodonHtml) return false 22 - 23 - const fullText = (record as any).fullText as string | undefined 24 - const bridgyOriginalText = (record as any).bridgyOriginalText as 25 - | string 26 - | undefined 27 - 28 - return !!(fullText || bridgyOriginalText) 29 - }, [record, renderMastodonHtml]) 30 - } 31 - 32 - export function MastodonHtmlContent({ 33 - record, 34 - style, 35 - textStyle, 36 - numberOfLines, 37 - }: MastodonHtmlContentProps) { 38 - const renderMastodonHtml = useRenderMastodonHtml() 39 - 40 - const renderedContent = useMemo(() => { 41 - if (!renderMastodonHtml) return null 42 - 43 - const fullText = (record as any).fullText as string | undefined 44 - const bridgyOriginalText = (record as any).bridgyOriginalText as 45 - | string 46 - | undefined 47 - 48 - const rawHtml = fullText || bridgyOriginalText 49 - 50 - if (!rawHtml) return null 51 - 52 - // Parse HTML once and sanitize/render in a single pass 53 - return sanitizeAndRenderHtml(rawHtml, numberOfLines, textStyle) 54 - }, [record, renderMastodonHtml, numberOfLines, textStyle]) 55 - 56 - if (!renderedContent) return null 57 - 58 - return <View style={style}>{renderedContent}</View> 59 - } 60 - 61 - const LINK_PROTOCOLS = [ 62 - 'http', 63 - 'https', 64 - 'dat', 65 - 'dweb', 66 - 'ipfs', 67 - 'ipns', 68 - 'ssb', 69 - 'gopher', 70 - 'xmpp', 71 - 'magnet', 72 - 'gemini', 73 - ] 74 - 75 - const PROTOCOL_REGEX = /^([a-z][a-z0-9.+-]*):\/\//i 76 - 77 - const ALLOWED_ELEMENTS = [ 78 - 'p', 79 - 'br', 80 - 'span', 81 - 'a', 82 - 'del', 83 - 's', 84 - 'pre', 85 - 'blockquote', 86 - 'code', 87 - 'b', 88 - 'strong', 89 - 'u', 90 - 'i', 91 - 'em', 92 - 'ul', 93 - 'ol', 94 - 'li', 95 - 'ruby', 96 - 'rt', 97 - 'rp', 98 - ] 99 - 100 - function sanitizeAndRenderHtml( 101 - html: string, 102 - _numberOfLines?: number, 103 - inputTextStyle?: StyleProp<TextStyle>, 104 - ): React.ReactNode { 105 - if (typeof DOMParser === 'undefined') { 106 - // Fallback for environments without DOMParser 107 - return html.replace(/<[^>]*>/g, '') 108 - } 109 - 110 - const parser = new DOMParser() 111 - const doc = parser.parseFromString(html, 'text/html') 112 - 113 - const textStyle: StyleProp<TextStyle> = [ 114 - atoms.leading_snug, 115 - atoms.text_md, 116 - inputTextStyle, 117 - ] 118 - 119 - // Sanitize and render in a single pass 120 - const renderNode = (node: Node, key: number, insideLink = false): React.ReactNode => { 121 - if (node.nodeType === Node.TEXT_NODE) { 122 - // Don't wrap text in styled Text component if inside a link 123 - if (insideLink) { 124 - return node.nodeValue 125 - } 126 - return <Text key={key} style={textStyle}> 127 - {node.nodeValue} 128 - </Text> 129 - } 130 - 131 - if (node.nodeType === Node.ELEMENT_NODE) { 132 - const element = node as Element 133 - const tagName = element.tagName.toLowerCase() 134 - 135 - // Handle unsupported elements (h1-h6) - convert to <strong> wrapped in <p> 136 - if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) { 137 - const children = Array.from(element.childNodes).map((child, i) => 138 - renderNode(child, i, insideLink), 139 - ) 140 - return ( 141 - <P key={key} style={textStyle}> 142 - <Text style={{...textStyle, fontWeight: 'bold'}}>{children}</Text> 143 - </P> 144 - ) 145 - } 146 - 147 - // Handle math elements - extract annotation text 148 - if (tagName === 'math') { 149 - const mathText = extractMathAnnotation(element) 150 - if (mathText) { 151 - return <Text key={key} style={textStyle}>{mathText}</Text> 152 - } 153 - return null 154 - } 155 - 156 - // Remove elements not in allowlist - replace with text content 157 - if (!ALLOWED_ELEMENTS.includes(tagName)) { 158 - return element.textContent ? ( 159 - <Text key={key} style={textStyle}>{element.textContent}</Text> 160 - ) : null 161 - } 162 - 163 - // Sanitize and process element 164 - sanitizeElementAttributes(element) 165 - 166 - const children = Array.from(element.childNodes).map((child, i) => 167 - renderNode(child, i, insideLink || tagName === 'a'), 168 - ) 169 - 170 - switch (tagName) { 171 - case 'p': 172 - return <P key={key} style={textStyle}>{children}</P> 173 - case 'blockquote': 174 - return ( 175 - <View key={key} style={{borderLeftWidth: 3, borderLeftColor: '#888', paddingLeft: 12, marginVertical: 4}}> 176 - <P style={textStyle}>{children}</P> 177 - </View> 178 - ) 179 - case 'pre': 180 - return ( 181 - <View key={key} style={{backgroundColor: '#f5f5f5', padding: 8, borderRadius: 4, marginVertical: 4}}> 182 - <P style={[textStyle, { fontFamily: 'monospace'}]}>{children}</P> 183 - </View> 184 - ) 185 - case 'code': 186 - return ( 187 - <Text key={key} style={[textStyle, { fontFamily: 'monospace', backgroundColor: '#f5f5f5', paddingHorizontal: 4, borderRadius: 2}]}> 188 - {children} 189 - </Text> 190 - ) 191 - case 'strong': 192 - case 'b': 193 - return ( 194 - <Text key={key} style={[textStyle, { fontWeight: 'bold'}]}> 195 - {children} 196 - </Text> 197 - ) 198 - case 'em': 199 - case 'i': 200 - return ( 201 - <Text key={key} style={[textStyle, { fontStyle: 'italic'}]}> 202 - {children} 203 - </Text> 204 - ) 205 - case 'u': 206 - return ( 207 - <Text key={key} style={[textStyle, { textDecorationLine: 'underline'}]}> 208 - {children} 209 - </Text> 210 - ) 211 - case 'del': 212 - case 's': 213 - return ( 214 - <Text key={key} style={[textStyle, { textDecorationLine: 'line-through'}]}> 215 - {children} 216 - </Text> 217 - ) 218 - case 'ul': 219 - return ( 220 - <View key={key} style={{marginVertical: 4}}> 221 - {children} 222 - </View> 223 - ) 224 - case 'ol': 225 - return ( 226 - <View key={key} style={{marginVertical: 4}}> 227 - {children} 228 - </View> 229 - ) 230 - case 'li': 231 - const parentIsOl = element.parentElement?.tagName.toLowerCase() === 'ol' 232 - return ( 233 - <View key={key} style={{flexDirection: 'row', marginVertical: 2}}> 234 - <Text style={[textStyle, { marginRight: 8 }]}>{parentIsOl ? 'ฮ“ร‡รณ' : 'ฮ“ร‡รณ'}</Text> 235 - <Text style={[textStyle, { flex: 1 }]}>{children}</Text> 236 - </View> 237 - ) 238 - case 'ruby': 239 - return <Text key={key} style={textStyle}>{children}</Text> 240 - case 'rt': 241 - case 'rp': 242 - return null // TODO support ruby text rendering 243 - case 'a': 244 - const href = element.getAttribute('href') 245 - if (href) { 246 - const linkText = 247 - element.textContent || element.getAttribute('aria-label') || href 248 - const className = element.getAttribute('class') 249 - const isInvisible = className?.includes('invisible') 250 - return ( 251 - <InlineLinkText 252 - key={key} 253 - to={href} 254 - label={linkText} 255 - shouldProxy 256 - style={isInvisible ? {display: 'none'} : textStyle}> 257 - {children} 258 - </InlineLinkText> 259 - ) 260 - } 261 - return <Text key={key}>{children}</Text> 262 - case 'br': 263 - return '\n' 264 - case 'span': 265 - const spanClass = element.getAttribute('class') 266 - // Handle invisible/ellipsis classes for link formatting 267 - if (spanClass?.includes('invisible')) { 268 - return <Text key={key} style={{ display: 'none' }}>{children}</Text> 269 - } 270 - if (spanClass?.includes('ellipsis')) { 271 - // If inside a link, return plain text, otherwise wrapped 272 - if (insideLink) { 273 - return 'ฮ“ร‡ยช' 274 - } 275 - return <Text key={key} style={textStyle}>ฮ“ร‡ยช</Text> 276 - } 277 - // Handle mentions and hashtags 278 - if (spanClass?.includes('mention') || spanClass?.includes('hashtag')) { 279 - // If inside a link, return children as-is without wrapping 280 - if (insideLink) { 281 - return children 282 - } 283 - return <Text key={key} style={textStyle}>{children}</Text> 284 - } 285 - // For spans inside links, return children without wrapping 286 - if (insideLink) { 287 - return children 288 - } 289 - return <Text key={key} style={textStyle}>{children}</Text> 290 - default: 291 - return <Text key={key} style={textStyle}>{children}</Text> 292 - } 293 - } 294 - 295 - return null 296 - } 297 - 298 - const content = Array.from(doc.body.childNodes).map((node, i) => 299 - renderNode(node, i), 300 - ) 301 - 302 - return ( 303 - <View style={{gap: 8}}> 304 - {content} 305 - </View> 306 - ) 307 - } 308 - 309 - function sanitizeElementAttributes(element: Element): void { 310 - const tagName = element.tagName.toLowerCase() 311 - const allowedAttrs: Record<string, string[]> = { 312 - a: ['href', 'rel', 'class', 'translate'], 313 - span: ['class', 'translate'], 314 - ol: ['start', 'reversed'], 315 - li: ['value'], 316 - p: ['class'], 317 - } 318 - 319 - const allowed = allowedAttrs[tagName] || [] 320 - const attrs = Array.from(element.attributes) 321 - 322 - // Remove non-allowed attributes 323 - for (const attr of attrs) { 324 - const attrName = attr.name.toLowerCase() 325 - const isAllowed = allowed.some(a => { 326 - if (a.endsWith('*')) { 327 - return attrName.startsWith(a.slice(0, -1)) 328 - } 329 - return a === attrName 330 - }) 331 - 332 - if (!isAllowed) { 333 - element.removeAttribute(attr.name) 334 - } 335 - } 336 - 337 - // Process specific attributes 338 - if (tagName === 'a') { 339 - processAnchorElement(element) 340 - } 341 - 342 - // Process class whitelist 343 - if (element.hasAttribute('class')) { 344 - processClassWhitelist(element) 345 - } 346 - 347 - // Process translate attribute - remove unless it's "no" 348 - if (element.hasAttribute('translate')) { 349 - const translate = element.getAttribute('translate') 350 - if (translate !== 'no') { 351 - element.removeAttribute('translate') 352 - } 353 - } 354 - } 355 - 356 - function processAnchorElement(element: Element): void { 357 - // Check if href has unsupported protocol 358 - const href = element.getAttribute('href') 359 - if (href) { 360 - const scheme = getScheme(href) 361 - if (scheme !== null && scheme !== 'relative' && !LINK_PROTOCOLS.includes(scheme)) { 362 - // Remove the href to disable the link 363 - element.removeAttribute('href') 364 - } 365 - } 366 - } 367 - 368 - function processClassWhitelist(element: Element): void { 369 - const classList = element.className.split(/[\t\n\f\r ]+/).filter(Boolean) 370 - const whitelisted = classList.filter(className => { 371 - // microformats classes 372 - if (/^[hpuedt]-/.test(className)) return true 373 - // semantic classes 374 - if (/^(mention|hashtag)$/.test(className)) return true 375 - // link formatting classes 376 - if (/^(ellipsis|invisible)$/.test(className)) return true 377 - // quote inline class 378 - if (className === 'quote-inline') return true 379 - return false 380 - }) 381 - 382 - if (whitelisted.length > 0) { 383 - element.className = whitelisted.join(' ') 384 - } else { 385 - element.removeAttribute('class') 386 - } 387 - } 388 - 389 - function getScheme(url: string): string | null { 390 - const match = url.match(PROTOCOL_REGEX) 391 - if (match) { 392 - return match[1].toLowerCase() 393 - } 394 - // Check if it's a relative URL 395 - if (url.startsWith('/') || url.startsWith('.')) { 396 - return 'relative' 397 - } 398 - return null 399 - } 400 - 401 - function extractMathAnnotation(mathElement: Element): string | null { 402 - const semantics = Array.from(mathElement.children).find( 403 - child => child.tagName.toLowerCase() === 'semantics', 404 - ) as Element | undefined 405 - 406 - if (!semantics) return null 407 - 408 - // Look for LaTeX annotation (application/x-tex) 409 - const latexAnnotation = Array.from(semantics.children).find(child => { 410 - return ( 411 - child.tagName.toLowerCase() === 'annotation' && 412 - child.getAttribute('encoding') === 'application/x-tex' 413 - ) 414 - }) 415 - 416 - if (latexAnnotation) { 417 - const display = mathElement.getAttribute('display') 418 - const text = latexAnnotation.textContent || '' 419 - return display === 'block' ? `$$${text}$$` : `$${text}$` 420 - } 421 - 422 - // Look for plain text annotation 423 - const plainAnnotation = Array.from(semantics.children).find(child => { 424 - return ( 425 - child.tagName.toLowerCase() === 'annotation' && 426 - child.getAttribute('encoding') === 'text/plain' 427 - ) 428 - }) 429 - 430 - if (plainAnnotation) { 431 - return plainAnnotation.textContent || null 432 - } 433 - 434 - return null 435 - }
+18 -42
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 56 56 import {PostAlerts} from '#/components/moderation/PostAlerts' 57 57 import {type AppModerationCause} from '#/components/Pills' 58 58 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 59 - import {MastodonHtmlContent, useHasMastodonHtmlContent} from '#/components/Post/MastodonHtmlContent' 60 59 import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 61 60 import {useFormatPostStatCount} from '#/components/PostControls/util' 62 61 import {ProfileHoverCard} from '#/components/ProfileHoverCard' ··· 194 193 const moderation = item.moderation 195 194 const authorShadow = useProfileShadow(post.author) 196 195 const {isActive: live} = useActorStatus(post.author) 197 - const hasMastodonHtml = useHasMastodonHtmlContent(record) 198 196 const richText = useMemo( 199 197 () => 200 198 new RichTextAPI({ ··· 400 398 style={[a.pb_sm]} 401 399 additionalCauses={additionalPostAlerts} 402 400 /> 401 + {richText?.text ? ( 402 + <RichText 403 + enableTags 404 + selectable 405 + value={richText} 406 + style={[a.flex_1, a.text_lg]} 407 + authorHandle={post.author.handle} 408 + shouldProxyLinks={true} 409 + /> 410 + ) : undefined} 411 + {post.embed && ( 412 + <View style={[a.py_xs]}> 413 + <Embed 414 + embed={post.embed} 415 + moderation={moderation} 416 + viewContext={PostEmbedViewContext.ThreadHighlighted} 417 + onOpen={onOpenEmbed} 403 - {hasMastodonHtml ? ( 404 - <> 405 - <MastodonHtmlContent 406 - record={record} 407 - style={[a.flex_1]} 408 - textStyle={[a.text_lg]} 409 418 /> 419 + </View> 410 - {post.embed && ( 411 - <View style={[a.py_xs]}> 412 - <Embed 413 - embed={post.embed} 414 - moderation={moderation} 415 - viewContext={PostEmbedViewContext.ThreadHighlighted} 416 - onOpen={onOpenEmbed} 417 - /> 418 - </View> 419 - )} 420 - </> 421 - ) : ( 422 - <> 423 - {richText?.text ? ( 424 - <RichText 425 - enableTags 426 - selectable 427 - value={richText} 428 - style={[a.flex_1, a.text_lg]} 429 - authorHandle={post.author.handle} 430 - shouldProxyLinks={true} 431 - /> 432 - ) : undefined} 433 - {post.embed && ( 434 - <View style={[a.py_xs]}> 435 - <Embed 436 - embed={post.embed} 437 - moderation={moderation} 438 - viewContext={PostEmbedViewContext.ThreadHighlighted} 439 - onOpen={onOpenEmbed} 440 - /> 441 - </View> 442 - )} 443 - </> 444 420 )} 445 421 </ContentHider> 446 422 <ExpandedPostDetails
+20 -46
src/screens/PostThread/components/ThreadItemPost.tsx
··· 38 38 import {PostHider} from '#/components/moderation/PostHider' 39 39 import {type AppModerationCause} from '#/components/Pills' 40 40 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 41 - import { 42 - MastodonHtmlContent, 43 - useHasMastodonHtmlContent, 44 - } from '#/components/Post/MastodonHtmlContent' 45 41 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 46 42 import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 47 43 import {RichText} from '#/components/RichText' ··· 195 191 const post = item.value.post 196 192 const record = item.value.post.record 197 193 const moderation = item.moderation 198 - const hasMastodonHtml = useHasMastodonHtmlContent(post.record) 199 194 const richText = useMemo( 200 195 () => 201 196 new RichTextAPI({ ··· 306 301 style={[a.pb_2xs]} 307 302 additionalCauses={additionalPostAlerts} 308 303 /> 304 + {richText?.text ? ( 309 - {hasMastodonHtml ? ( 310 305 <> 306 + <RichText 307 + enableTags 308 + value={richText} 311 - <MastodonHtmlContent 312 - record={post.record} 313 309 style={[a.flex_1, a.text_md]} 314 310 numberOfLines={limitLines ? MAX_POST_LINES : undefined} 311 + authorHandle={post.author.handle} 312 + shouldProxyLinks={true} 315 313 /> 314 + {limitLines && ( 315 + <ShowMoreTextButton 316 + style={[a.text_md]} 317 + onPress={onPressShowMore} 318 + /> 316 - {post.embed && ( 317 - <View style={[a.pb_xs]}> 318 - <Embed 319 - embed={post.embed} 320 - moderation={moderation} 321 - viewContext={PostEmbedViewContext.Feed} 322 - /> 323 - </View> 324 - )} 325 - </> 326 - ) : ( 327 - <> 328 - {richText?.text ? ( 329 - <> 330 - <RichText 331 - enableTags 332 - value={richText} 333 - style={[a.flex_1, a.text_md]} 334 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 335 - authorHandle={post.author.handle} 336 - shouldProxyLinks={true} 337 - /> 338 - {limitLines && ( 339 - <ShowMoreTextButton 340 - style={[a.text_md]} 341 - onPress={onPressShowMore} 342 - /> 343 - )} 344 - </> 345 - ) : undefined} 346 - {post.embed && ( 347 - <View style={[a.pb_xs]}> 348 - <Embed 349 - embed={post.embed} 350 - moderation={moderation} 351 - viewContext={PostEmbedViewContext.Feed} 352 - /> 353 - </View> 354 319 )} 355 320 </> 321 + ) : undefined} 322 + {post.embed && ( 323 + <View style={[a.pb_xs]}> 324 + <Embed 325 + embed={post.embed} 326 + moderation={moderation} 327 + viewContext={PostEmbedViewContext.Feed} 328 + /> 329 + </View> 356 330 )} 357 331 <PostControls 358 332 post={postShadow}
-27
src/screens/Settings/DeerSettings.tsx
··· 101 101 useNoDiscoverFallback, 102 102 useSetNoDiscoverFallback, 103 103 } from '#/state/preferences/no-discover-fallback' 104 - import { 105 - useRenderMastodonHtml, 106 - useSetRenderMastodonHtml, 107 - } from '#/state/preferences/render-mastodon-html' 108 104 import { 109 105 useRepostCarouselEnabled, 110 106 useSetRepostCarouselEnabled, ··· 447 443 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm() 448 444 const setHideSimilarAccountsRecomm = useSetHideSimilarAccountsRecomm() 449 445 450 - const renderMastodonHtml = useRenderMastodonHtml() 451 - const setRenderMastodonHtml = useSetRenderMastodonHtml() 452 - 453 446 const disableVerifyEmailReminder = useDisableVerifyEmailReminder() 454 447 const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder() 455 448 ··· 749 742 <Toggle.Platform /> 750 743 </Toggle.Item> 751 744 752 - <Toggle.Item 753 - 754 - <Toggle.Item 755 - name="render_mastodon_html" 756 - label={_(msg`Render Mastodon HTML from bridged posts`)} 757 - value={renderMastodonHtml} 758 - onChange={value => setRenderMastodonHtml(value)} 759 - style={[a.w_full]}> 760 - <Toggle.LabelText style={[a.flex_1]}> 761 - <Trans>Render Mastodon HTML from bridged posts</Trans> 762 - </Toggle.LabelText> 763 - <Toggle.Platform /> 764 - </Toggle.Item> 765 - <Admonition type="info" style={[a.flex_1]}> 766 - <Trans> 767 - When enabled, posts bridged from Mastodon will display their 768 - original HTML formatting instead of the plain text version. 769 - </Trans> 770 - </Admonition> 771 - 772 745 <Toggle.Item 773 746 name="disable_verify_email_reminder" 774 747 label={_(msg`Disable verify email reminder`)}
-2
src/state/persisted/schema.ts
··· 166 166 }) 167 167 .optional(), 168 168 highQualityImages: z.boolean().optional(), 169 - renderMastodonHtml: z.boolean().optional(), 170 169 171 170 showExternalShareButtons: z.boolean().optional(), 172 171 ··· 270 269 ], 271 270 }, 272 271 highQualityImages: false, 273 - renderMastodonHtml: false, 274 272 showExternalShareButtons: false, 275 273 } 276 274
+7 -10
src/state/preferences/index.tsx
··· 33 33 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 34 34 import {Provider as NoAppLabelersProvider} from './no-app-labelers' 35 35 import {Provider as NoDiscoverProvider} from './no-discover-fallback' 36 - import {Provider as RenderMastodonHtmlProvider} from './render-mastodon-html' 37 36 import {Provider as RepostCarouselProvider} from './repost-carousel-enabled' 38 37 import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle' 39 38 import {Provider as SubtitlesProvider} from './subtitles' ··· 97 96 <DisableFollowedByMetricsProvider> 98 97 <DisablePostsMetricsProvider> 99 98 <HideSimilarAccountsRecommProvider> 99 + <EnableSquareAvatarsProvider> 100 + <EnableSquareButtonsProvider> 101 + <DisableVerifyEmailReminderProvider> 102 + {children} 103 + </DisableVerifyEmailReminderProvider> 104 + </EnableSquareButtonsProvider> 105 + </EnableSquareAvatarsProvider> 100 - <RenderMastodonHtmlProvider> 101 - <EnableSquareAvatarsProvider> 102 - <EnableSquareButtonsProvider> 103 - <DisableVerifyEmailReminderProvider> 104 - {children} 105 - </DisableVerifyEmailReminderProvider> 106 - </EnableSquareButtonsProvider> 107 - </EnableSquareAvatarsProvider> 108 - </RenderMastodonHtmlProvider> 109 106 </HideSimilarAccountsRecommProvider> 110 107 </DisablePostsMetricsProvider> 111 108 </DisableFollowedByMetricsProvider>
-49
src/state/preferences/render-mastodon-html.tsx
··· 1 - import React from 'react' 2 - 3 - import * as persisted from '#/state/persisted' 4 - 5 - type StateContext = persisted.Schema['renderMastodonHtml'] 6 - type SetContext = (v: persisted.Schema['renderMastodonHtml']) => void 7 - 8 - const stateContext = React.createContext<StateContext>( 9 - persisted.defaults.renderMastodonHtml, 10 - ) 11 - const setContext = React.createContext<SetContext>( 12 - (_: persisted.Schema['renderMastodonHtml']) => {}, 13 - ) 14 - 15 - export function Provider({children}: React.PropsWithChildren<{}>) { 16 - const [state, setState] = React.useState(persisted.get('renderMastodonHtml')) 17 - 18 - const setStateWrapped = React.useCallback( 19 - (renderMastodonHtml: persisted.Schema['renderMastodonHtml']) => { 20 - setState(renderMastodonHtml) 21 - persisted.write('renderMastodonHtml', renderMastodonHtml) 22 - }, 23 - [setState], 24 - ) 25 - 26 - React.useEffect(() => { 27 - return persisted.onUpdate('renderMastodonHtml', nextValue => { 28 - setState(nextValue) 29 - }) 30 - }, [setStateWrapped]) 31 - 32 - return ( 33 - <stateContext.Provider value={state}> 34 - <setContext.Provider value={setStateWrapped}> 35 - {children} 36 - </setContext.Provider> 37 - </stateContext.Provider> 38 - ) 39 - } 40 - 41 - export function useRenderMastodonHtml() { 42 - return ( 43 - React.useContext(stateContext) ?? persisted.defaults.renderMastodonHtml 44 - ) 45 - } 46 - 47 - export function useSetRenderMastodonHtml() { 48 - return React.useContext(setContext) 49 - }
+22 -54
src/view/com/posts/PostFeedItem.tsx
··· 44 44 import {type AppModerationCause} from '#/components/Pills' 45 45 import {Embed} from '#/components/Post/Embed' 46 46 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 47 - import { 48 - MastodonHtmlContent, 49 - useHasMastodonHtmlContent, 50 - } from '#/components/Post/MastodonHtmlContent' 51 47 import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 52 48 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 53 49 import {PostControls} from '#/components/PostControls' ··· 422 418 threadgateRecord?: AppBskyFeedThreadgate.Record 423 419 }): React.ReactNode => { 424 420 const {currentAccount} = useSession() 425 - const hasMastodonHtml = useHasMastodonHtmlContent( 426 - post.record as AppBskyFeedPost.Record, 427 - ) 428 421 const [limitLines, setLimitLines] = useState( 429 422 () => countLines(richText.text) >= MAX_POST_LINES, 430 423 ) ··· 467 460 style={[a.pb_xs]} 468 461 additionalCauses={additionalPostAlerts} 469 462 /> 463 + {richText.text ? ( 470 - {hasMastodonHtml ? ( 471 464 <> 465 + <RichText 466 + enableTags 467 + testID="postText" 468 + value={richText} 469 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 472 - <MastodonHtmlContent 473 - record={post.record as AppBskyFeedPost.Record} 474 470 style={[a.flex_1, a.text_md]} 471 + authorHandle={postAuthor.handle} 472 + shouldProxyLinks={true} 475 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 476 473 /> 474 + {limitLines && ( 475 + <ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} /> 476 + )} 477 - {postEmbed ? ( 478 - <View style={[a.pb_xs]}> 479 - <Embed 480 - embed={postEmbed} 481 - moderation={moderation} 482 - onOpen={onOpenEmbed} 483 - viewContext={PostEmbedViewContext.Feed} 484 - /> 485 - </View> 486 - ) : null} 487 477 </> 478 + ) : undefined} 479 + {postEmbed ? ( 480 + <View style={[a.pb_xs]}> 481 + <Embed 482 + embed={postEmbed} 483 + moderation={moderation} 484 + onOpen={onOpenEmbed} 485 + viewContext={PostEmbedViewContext.Feed} 486 + /> 487 + </View> 488 + ) : null} 488 - ) : ( 489 - <> 490 - {richText.text ? ( 491 - <> 492 - <RichText 493 - enableTags 494 - testID="postText" 495 - value={richText} 496 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 497 - style={[a.flex_1, a.text_md]} 498 - authorHandle={postAuthor.handle} 499 - shouldProxyLinks={true} 500 - /> 501 - {limitLines && ( 502 - <ShowMoreTextButton 503 - style={[a.text_md]} 504 - onPress={onPressShowMore} 505 - /> 506 - )} 507 - </> 508 - ) : undefined} 509 - {postEmbed ? ( 510 - <View style={[a.pb_xs]}> 511 - <Embed 512 - embed={postEmbed} 513 - moderation={moderation} 514 - onOpen={onOpenEmbed} 515 - viewContext={PostEmbedViewContext.Feed} 516 - /> 517 - </View> 518 - ) : null} 519 - </> 520 - )} 521 489 </ContentHider> 522 490 ) 523 491 }
-356
src/lib/strings/html-sanitizer.ts
··· 1 - /** 2 - * HTML sanitizer inspired by Mastodon's Sanitize::Config 3 - * Sanitizes HTML content to prevent XSS while preserving safe formatting 4 - */ 5 - 6 - const HTTP_PROTOCOLS = ['http', 'https'] 7 - 8 - const LINK_PROTOCOLS = [ 9 - 'http', 10 - 'https', 11 - 'dat', 12 - 'dweb', 13 - 'ipfs', 14 - 'ipns', 15 - 'ssb', 16 - 'gopher', 17 - 'xmpp', 18 - 'magnet', 19 - 'gemini', 20 - ] 21 - 22 - const PROTOCOL_REGEX = /^([a-z][a-z0-9.+-]*):\/\//i 23 - 24 - interface SanitizeOptions { 25 - allowOembed?: boolean 26 - } 27 - 28 - /** 29 - * Sanitizes HTML content following Mastodon's strict rules 30 - */ 31 - export function sanitizeHtml( 32 - html: string, 33 - options: SanitizeOptions = {}, 34 - ): string { 35 - if (typeof DOMParser === 'undefined') { 36 - // Fallback for environments without DOMParser 37 - return sanitizeTextOnly(html) 38 - } 39 - 40 - const parser = new DOMParser() 41 - const doc = parser.parseFromString(html, 'text/html') 42 - const body = doc.body 43 - 44 - sanitizeNode(body, options) 45 - 46 - return body.innerHTML 47 - } 48 - 49 - function sanitizeNode(node: Node, options: SanitizeOptions): void { 50 - const childNodes = Array.from(node.childNodes) 51 - 52 - for (const child of childNodes) { 53 - if (child.nodeType === Node.ELEMENT_NODE) { 54 - const element = child as HTMLElement 55 - const tagName = element.tagName.toLowerCase() 56 - 57 - // Define allowed elements 58 - const allowedElements = options.allowOembed 59 - ? [ 60 - 'p', 61 - 'br', 62 - 'span', 63 - 'a', 64 - 'del', 65 - 's', 66 - 'pre', 67 - 'blockquote', 68 - 'code', 69 - 'b', 70 - 'strong', 71 - 'u', 72 - 'i', 73 - 'em', 74 - 'ul', 75 - 'ol', 76 - 'li', 77 - 'ruby', 78 - 'rt', 79 - 'rp', 80 - 'audio', 81 - 'iframe', 82 - 'source', 83 - 'video', 84 - ] 85 - : [ 86 - 'p', 87 - 'br', 88 - 'span', 89 - 'a', 90 - 'del', 91 - 's', 92 - 'pre', 93 - 'blockquote', 94 - 'code', 95 - 'b', 96 - 'strong', 97 - 'u', 98 - 'i', 99 - 'em', 100 - 'ul', 101 - 'ol', 102 - 'li', 103 - 'ruby', 104 - 'rt', 105 - 'rp', 106 - ] 107 - 108 - // Handle unsupported elements (h1-h6) - convert to <strong> wrapped in <p> 109 - if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) { 110 - const strong = element.ownerDocument!.createElement('strong') 111 - while (element.firstChild) { 112 - strong.appendChild(element.firstChild) 113 - } 114 - const p = element.ownerDocument!.createElement('p') 115 - p.appendChild(strong) 116 - element.replaceWith(p) 117 - sanitizeNode(p, options) 118 - continue 119 - } 120 - 121 - // Handle math elements - extract annotation text 122 - if (tagName === 'math') { 123 - const mathText = extractMathAnnotation(element) 124 - if (mathText) { 125 - const textNode = element.ownerDocument!.createTextNode(mathText) 126 - element.replaceWith(textNode) 127 - } else { 128 - element.remove() 129 - } 130 - continue 131 - } 132 - 133 - if (tagName === 'li') { 134 - // Keep li elements but sanitize their children 135 - sanitizeNode(element, options) 136 - continue 137 - } 138 - 139 - // Remove elements not in allowlist 140 - if (!allowedElements.includes(tagName)) { 141 - // Replace with text content 142 - const textNode = element.ownerDocument!.createTextNode( 143 - element.textContent || '', 144 - ) 145 - element.replaceWith(textNode) 146 - continue 147 - } 148 - 149 - // Sanitize attributes 150 - sanitizeAttributes(element, options) 151 - 152 - // Recursively sanitize children 153 - sanitizeNode(element, options) 154 - } 155 - } 156 - } 157 - 158 - function sanitizeAttributes( 159 - element: HTMLElement, 160 - options: SanitizeOptions, 161 - ): void { 162 - const tagName = element.tagName.toLowerCase() 163 - const allowedAttrs: Record<string, string[]> = { 164 - a: ['href', 'rel', 'class', 'translate'], 165 - span: ['class', 'translate'], 166 - ol: ['start', 'reversed'], 167 - li: ['value'], 168 - p: ['class'], 169 - } 170 - 171 - if (options.allowOembed) { 172 - allowedAttrs.audio = ['controls'] 173 - allowedAttrs.iframe = [ 174 - 'allowfullscreen', 175 - 'frameborder', 176 - 'height', 177 - 'scrolling', 178 - 'src', 179 - 'width', 180 - ] 181 - allowedAttrs.source = ['src', 'type'] 182 - allowedAttrs.video = ['controls', 'height', 'loop', 'width'] 183 - } 184 - 185 - const allowed = allowedAttrs[tagName] || [] 186 - const attrs = Array.from(element.attributes) 187 - 188 - // Remove non-allowed attributes 189 - for (const attr of attrs) { 190 - const attrName = attr.name.toLowerCase() 191 - const isAllowed = allowed.some(a => { 192 - if (a.endsWith('*')) { 193 - return attrName.startsWith(a.slice(0, -1)) 194 - } 195 - return a === attrName 196 - }) 197 - 198 - if (!isAllowed) { 199 - element.removeAttribute(attr.name) 200 - } 201 - } 202 - 203 - // Process specific attributes 204 - if (tagName === 'a') { 205 - processAnchorElement(element) 206 - } 207 - 208 - // Process class whitelist 209 - if (element.hasAttribute('class')) { 210 - processClassWhitelist(element) 211 - } 212 - 213 - // Process translate attribute - remove unless it's "no" 214 - if (element.hasAttribute('translate')) { 215 - const translate = element.getAttribute('translate') 216 - if (translate !== 'no') { 217 - element.removeAttribute('translate') 218 - } 219 - } 220 - 221 - // Validate protocols for elements with src/href 222 - if (element.hasAttribute('href') || element.hasAttribute('src')) { 223 - validateProtocols(element, options) 224 - } 225 - } 226 - 227 - function processAnchorElement(element: HTMLElement): void { 228 - // Add required attributes 229 - element.setAttribute('rel', 'nofollow noopener') 230 - element.setAttribute('target', '_blank') 231 - 232 - // Check if href has unsupported protocol 233 - const href = element.getAttribute('href') 234 - if (href) { 235 - const scheme = getScheme(href) 236 - if (scheme !== null && scheme !== 'relative' && !LINK_PROTOCOLS.includes(scheme)) { 237 - // Replace element with its text content 238 - const textNode = element.ownerDocument!.createTextNode( 239 - element.textContent || '', 240 - ) 241 - element.replaceWith(textNode) 242 - } 243 - } 244 - } 245 - 246 - function processClassWhitelist(element: HTMLElement): void { 247 - const classList = element.className.split(/[\t\n\f\r ]+/).filter(Boolean) 248 - const whitelisted = classList.filter(className => { 249 - // microformats classes 250 - if (/^[hpuedt]-/.test(className)) return true 251 - // semantic classes 252 - if (/^(mention|hashtag)$/.test(className)) return true 253 - // link formatting classes 254 - if (/^(ellipsis|invisible)$/.test(className)) return true 255 - // quote inline class 256 - if (className === 'quote-inline') return true 257 - return false 258 - }) 259 - 260 - if (whitelisted.length > 0) { 261 - element.className = whitelisted.join(' ') 262 - } else { 263 - element.removeAttribute('class') 264 - } 265 - } 266 - 267 - function validateProtocols( 268 - element: HTMLElement, 269 - options: SanitizeOptions, 270 - ): void { 271 - const tagName = element.tagName.toLowerCase() 272 - const src = element.getAttribute('src') 273 - const href = element.getAttribute('href') 274 - const url = src || href 275 - 276 - if (!url) return 277 - 278 - const scheme = getScheme(url) 279 - 280 - // For oembed elements, only allow HTTP protocols for src 281 - if ( 282 - options.allowOembed && 283 - src && 284 - ['iframe', 'source'].includes(tagName) 285 - ) { 286 - if (scheme !== null && !HTTP_PROTOCOLS.includes(scheme)) { 287 - element.removeAttribute('src') 288 - } 289 - // Add sandbox attribute to iframes 290 - if (tagName === 'iframe') { 291 - element.setAttribute( 292 - 'sandbox', 293 - 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms', 294 - ) 295 - } 296 - } 297 - } 298 - 299 - function getScheme(url: string): string | null { 300 - const match = url.match(PROTOCOL_REGEX) 301 - if (match) { 302 - return match[1].toLowerCase() 303 - } 304 - // Check if it's a relative URL 305 - if (url.startsWith('/') || url.startsWith('.')) { 306 - return 'relative' 307 - } 308 - return null 309 - } 310 - 311 - /** 312 - * Extract math annotation from MathML element 313 - * Follows FEP-dc88 spec for math element representation 314 - */ 315 - function extractMathAnnotation(mathElement: HTMLElement): string | null { 316 - const semantics = Array.from(mathElement.children).find( 317 - child => child.tagName.toLowerCase() === 'semantics', 318 - ) as HTMLElement | undefined 319 - 320 - if (!semantics) return null 321 - 322 - // Look for LaTeX annotation (application/x-tex) 323 - const latexAnnotation = Array.from(semantics.children).find(child => { 324 - return ( 325 - child.tagName.toLowerCase() === 'annotation' && 326 - child.getAttribute('encoding') === 'application/x-tex' 327 - ) 328 - }) 329 - 330 - if (latexAnnotation) { 331 - const display = mathElement.getAttribute('display') 332 - const text = latexAnnotation.textContent || '' 333 - return display === 'block' ? `$$${text}$$` : `$${text}$` 334 - } 335 - 336 - // Look for plain text annotation 337 - const plainAnnotation = Array.from(semantics.children).find(child => { 338 - return ( 339 - child.tagName.toLowerCase() === 'annotation' && 340 - child.getAttribute('encoding') === 'text/plain' 341 - ) 342 - }) 343 - 344 - if (plainAnnotation) { 345 - return plainAnnotation.textContent || null 346 - } 347 - 348 - return null 349 - } 350 - 351 - /** 352 - * Fallback sanitizer that strips all HTML tags 353 - */ 354 - function sanitizeTextOnly(html: string): string { 355 - return html.replace(/<[^>]*>/g, '') 356 - }

History

2 rounds 5 comments
sign up or login to add to the discussion
5 commits
expand
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

i am like 99% sure this would be considered a license violation if merged as mastodon is licensed under AGPL while witchsky is MIT

Good point, I will rewrite the sanitizer from scratch

Hey Maxine! Did you get this done? Iโ€™d like to see if we can merge it once the conflicts are resolved.

Sorry ewan, haven't had the time, also this PR has some weird bugs (sometimes the render crashes and I never diagnosed it), you might want to close this one for the meanwhile

I might look into writing a non-vibe-coded version of this at some point, it'd be a fun way to cut my teeth on webdev again

closed without merging
2 commits
expand
6e85dcd3
feat: render full post contents for posts bridged from mastodon or wafrn
e7e78fad
fix: don't duplicate work in MastodonHtmlContent
expand 0 comments