back interdiff of round #1 and #0

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

open
opened by maxine.puppykitty.racing targeting main
files
src
components
lib
screens
state
view
com
REVERTED
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
REVERTED
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}
REVERTED
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`)}
REVERTED
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
REVERTED
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>
REVERTED
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 - }
REVERTED
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 }
NEW
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 - }
NEW
src/components/Post/MastodonHtmlContent.tsx
··· 1 - import {useMemo} from 'react' 2 - import {type StyleProp, type TextStyle, View, type ViewStyle} from 'react-native' 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' 3 9 import {type AppBskyFeedPost} from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 4 12 5 13 import {useRenderMastodonHtml} from '#/state/preferences/render-mastodon-html' 6 - import { atoms } from '#/alf' 14 + import {atoms as a} from '#/alf' 15 + import {Button, ButtonText} from '#/components/Button' 7 16 import {InlineLinkText} from '#/components/Link' 8 17 import {P, Text} from '#/components/Typography' 9 18 ··· 36 45 numberOfLines, 37 46 }: MastodonHtmlContentProps) { 38 47 const renderMastodonHtml = useRenderMastodonHtml() 48 + const {_} = useLingui() 49 + const [isExpanded, setIsExpanded] = useState(false) 50 + const [contentHeight, setContentHeight] = useState<number | null>(null) 51 + const [isTall, setIsTall] = useState(false) 39 52 40 53 const renderedContent = useMemo(() => { 41 54 if (!renderMastodonHtml) return null ··· 53 66 return sanitizeAndRenderHtml(rawHtml, numberOfLines, textStyle) 54 67 }, [record, renderMastodonHtml, numberOfLines, textStyle]) 55 68 69 + const handleLayout = (event: LayoutChangeEvent) => { 70 + const height = event.nativeEvent.layout.height 71 + if (contentHeight === null) { 72 + setContentHeight(height) 73 + // Consider content "tall" if it's taller than 150px 74 + setIsTall(height > 150) 75 + } 76 + } 77 + 56 78 if (!renderedContent) return null 57 79 58 - return <View style={style}>{renderedContent}</View> 80 + const shouldCollapse = isTall && !isExpanded 81 + 82 + return ( 83 + <View style={style}> 84 + <View 85 + style={shouldCollapse ? {maxHeight: 150, overflow: 'hidden'} : undefined} 86 + onLayout={handleLayout}> 87 + {renderedContent} 88 + </View> 89 + {shouldCollapse && ( 90 + <Button 91 + label={_(msg`Show more`)} 92 + onPress={() => setIsExpanded(true)} 93 + variant="ghost" 94 + color="primary" 95 + size="small" 96 + style={[a.mt_xs]}> 97 + <ButtonText> 98 + <Trans>Show more</Trans> 99 + </ButtonText> 100 + </Button> 101 + )} 102 + </View> 103 + ) 59 104 } 60 105 61 106 const LINK_PROTOCOLS = [ ··· 111 156 const doc = parser.parseFromString(html, 'text/html') 112 157 113 158 const textStyle: StyleProp<TextStyle> = [ 114 - atoms.leading_snug, 115 - atoms.text_md, 159 + a.leading_snug, 160 + a.text_md, 116 161 inputTextStyle, 117 162 ] 118 163 ··· 300 345 } 301 346 302 347 const content = Array.from(doc.body.childNodes).map((node, i) => 303 - renderNode(node, i), 348 + renderNode(node, String(i)), 304 349 ) 305 350 306 351 return ( ··· 326 371 // Remove non-allowed attributes 327 372 for (const attr of attrs) { 328 373 const attrName = attr.name.toLowerCase() 329 - const isAllowed = allowed.some(a => { 330 - if (a.endsWith('*')) { 331 - return attrName.startsWith(a.slice(0, -1)) 374 + const isAllowed = allowed.some(allowedAttr => { 375 + if (allowedAttr.endsWith('*')) { 376 + return attrName.startsWith(allowedAttr.slice(0, -1)) 332 377 } 333 - return a === attrName 378 + return allowedAttr === attrName 334 379 }) 335 380 336 381 if (!isAllowed) {