Bluesky app fork with some witchin' additions 💫

fix: pds badge - hard coded svg icons & favicon fetch fix

authored by jeanmachine.dev and committed by tangled.org 045a68b5 00930e64

+228 -136
+44 -24
src/components/PdsBadge.tsx
··· 6 usePdsLabelEnabled, 7 usePdsLabelHideBskyPds, 8 } from '#/state/preferences/pds-label' 9 - import {usePdsLabelQuery} from '#/state/queries/pds-label' 10 import {atoms as a, useBreakpoints} from '#/alf' 11 import {Button} from '#/components/Button' 12 import * as Dialog from '#/components/Dialog' 13 - import {FaviconOrGlobe, PdsDialog} from '#/components/PdsDialog' 14 import {IS_WEB} from '#/env' 15 16 export function PdsBadge({ 17 did, 18 size, 19 interactive = true, 20 }: { 21 did: string 22 size: 'lg' | 'md' | 'sm' 23 interactive?: boolean 24 }) { 25 const enabled = usePdsLabelEnabled() 26 const hideBskyPds = usePdsLabelHideBskyPds() 27 const {data, isLoading} = usePdsLabelQuery(enabled ? did : undefined) 28 29 if (!enabled) return null 30 - if (isLoading) return <PdsBadgeLoading size={size} /> 31 if (!data) return null 32 if (hideBskyPds && data.isBsky) return null 33 34 return ( 35 <PdsBadgeInner 36 pdsUrl={data.pdsUrl} 37 - faviconUrl={data.faviconUrl} 38 isBsky={data.isBsky} 39 isBridged={data.isBridged} 40 size={size} ··· 43 ) 44 } 45 46 - function PdsBadgeLoading({size}: {size: 'lg' | 'md' | 'sm'}) { 47 const {gtPhone} = useBreakpoints() 48 let dimensions = 12 49 if (size === 'lg') { ··· 53 } 54 return ( 55 <View style={{width: dimensions, height: dimensions}}> 56 - <FaviconOrGlobe 57 - faviconUrl="" 58 - isBsky={false} 59 isBridged={false} 60 size={dimensions} 61 - borderRadius={dimensions / 4} 62 /> 63 </View> 64 ) ··· 73 interactive, 74 }: { 75 pdsUrl: string 76 - faviconUrl: string 77 isBsky: boolean 78 isBridged: boolean 79 size: 'lg' | 'md' | 'sm' ··· 91 } 92 93 const icon = ( 94 - <FaviconOrGlobe 95 faviconUrl={faviconUrl} 96 isBsky={isBsky} 97 isBridged={isBridged} 98 size={dimensions} 99 - borderRadius={dimensions / 4} 100 /> 101 ) 102 ··· 126 } 127 }}> 128 {({hovered}) => ( 129 - <View 130 - style={[ 131 - a.justify_center, 132 - a.align_center, 133 - a.transition_transform, 134 - { 135 - width: dimensions, 136 - height: dimensions, 137 - transform: [{scale: hovered ? 1.1 : 1}], 138 - }, 139 - ]}> 140 - {icon} 141 </View> 142 )} 143 </Button>
··· 6 usePdsLabelEnabled, 7 usePdsLabelHideBskyPds, 8 } from '#/state/preferences/pds-label' 9 + import {usePdsFaviconQuery, usePdsLabelQuery} from '#/state/queries/pds-label' 10 import {atoms as a, useBreakpoints} from '#/alf' 11 import {Button} from '#/components/Button' 12 import * as Dialog from '#/components/Dialog' 13 + import {PdsBadgeIcon, PdsDialog} from '#/components/PdsDialog' 14 import {IS_WEB} from '#/env' 15 16 export function PdsBadge({ 17 did, 18 + handle, 19 size, 20 interactive = true, 21 }: { 22 did: string 23 + handle?: string 24 size: 'lg' | 'md' | 'sm' 25 interactive?: boolean 26 }) { 27 const enabled = usePdsLabelEnabled() 28 const hideBskyPds = usePdsLabelHideBskyPds() 29 const {data, isLoading} = usePdsLabelQuery(enabled ? did : undefined) 30 + const {data: faviconUrl} = usePdsFaviconQuery( 31 + data && !data.isBsky && !data.isBridged ? data.pdsUrl : undefined, 32 + ) 33 + 34 + const isBskyHandle = 35 + !!handle && 36 + (handle.endsWith('.bsky.social') || handle === 'bsky.social') 37 38 if (!enabled) return null 39 + if (isLoading) return <PdsBadgeLoading size={size} isBsky={isBskyHandle} /> 40 if (!data) return null 41 if (hideBskyPds && data.isBsky) return null 42 43 return ( 44 <PdsBadgeInner 45 pdsUrl={data.pdsUrl} 46 + faviconUrl={faviconUrl} 47 isBsky={data.isBsky} 48 isBridged={data.isBridged} 49 size={size} ··· 52 ) 53 } 54 55 + function PdsBadgeLoading({ 56 + size, 57 + isBsky = false, 58 + }: { 59 + size: 'lg' | 'md' | 'sm' 60 + isBsky?: boolean 61 + }) { 62 const {gtPhone} = useBreakpoints() 63 let dimensions = 12 64 if (size === 'lg') { ··· 68 } 69 return ( 70 <View style={{width: dimensions, height: dimensions}}> 71 + <PdsBadgeIcon 72 + faviconUrl={undefined} 73 + isBsky={isBsky} 74 isBridged={false} 75 size={dimensions} 76 + borderRadius={Math.round(dimensions * 0.25)} 77 /> 78 </View> 79 ) ··· 88 interactive, 89 }: { 90 pdsUrl: string 91 + faviconUrl: string | undefined 92 isBsky: boolean 93 isBridged: boolean 94 size: 'lg' | 'md' | 'sm' ··· 106 } 107 108 const icon = ( 109 + <PdsBadgeIcon 110 faviconUrl={faviconUrl} 111 isBsky={isBsky} 112 isBridged={isBridged} 113 size={dimensions} 114 + borderRadius={Math.round(dimensions * 0.25)} 115 /> 116 ) 117 ··· 141 } 142 }}> 143 {({hovered}) => ( 144 + <View style={{width: dimensions, height: dimensions}}> 145 + <View 146 + style={[ 147 + a.justify_center, 148 + a.align_center, 149 + a.transition_transform, 150 + { 151 + position: 'absolute', 152 + top: 0, 153 + left: 0, 154 + right: 0, 155 + bottom: 0, 156 + transform: [{scale: hovered ? 1.1 : 1}], 157 + }, 158 + ]}> 159 + {icon} 160 + </View> 161 </View> 162 )} 163 </Button>
+129 -90
src/components/PdsDialog.tsx
··· 8 import {useLingui} from '@lingui/react' 9 10 import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label' 11 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 import {Button, ButtonText} from '#/components/Button' 13 import * as Dialog from '#/components/Dialog' 14 - import {Fediverse as FediverseIcon} from '#/components/icons/Fediverse' 15 - import {Mark as BskyMark} from '#/components/icons/Logo' 16 import {InlineLinkText} from '#/components/Link' 17 import {Text} from '#/components/Typography' 18 ··· 43 }: { 44 control: Dialog.DialogControlProps 45 pdsUrl: string 46 - faviconUrl: string 47 }) { 48 const {_} = useLingui() 49 const {gtMobile} = useBreakpoints() ··· 67 ]}> 68 <View style={[a.gap_md, a.pb_lg]}> 69 <View style={[a.flex_row, a.align_center, a.gap_md]}> 70 - <FaviconOrGlobe 71 faviconUrl={faviconUrl} 72 isBsky={isBsky} 73 isBridged={isBridged} 74 size={36} 75 /> 76 <View style={[a.flex_1]}> 77 - <Text 78 style={[a.text_2xl, a.font_semi_bold, a.leading_tight]} 79 numberOfLines={1}> 80 {displayName} 81 </Text> 82 - {isBsky && ( 83 - <Text style={[a.text_sm]}> 84 - <Trans>Bluesky-hosted PDS</Trans> 85 - </Text> 86 - )} 87 - {isBridged && ( 88 - <Text style={[a.text_sm]}> 89 - <Trans>Fediverse bridge</Trans> 90 - </Text> 91 - )} 92 </View> 93 </View> 94 95 <Text style={[a.text_md, a.leading_snug]}> 96 <Trans> 97 - This account's data is stored on a Personal Data Server (PDS):{' '} 98 <InlineLinkText 99 to={pdsUrl} 100 label={displayName} ··· 122 </Text> 123 )} 124 125 {!isBsky && !isBridged && ( 126 <Text style={[a.text_md, a.leading_snug]}> 127 <Trans> ··· 157 ) 158 } 159 160 - export function FaviconOrGlobe({ 161 - faviconUrl, 162 - isBsky, 163 - isBridged, 164 size, 165 borderRadius, 166 }: { 167 - faviconUrl: string 168 - isBsky: boolean 169 - isBridged: boolean 170 size: number 171 - borderRadius?: number 172 }) { 173 const t = useTheme() 174 - const [imgError, setImgError] = useState(false) 175 - const resolvedBorderRadius = borderRadius ?? size / 5 176 - 177 - if (isBsky) { 178 - return ( 179 - <View 180 - style={[ 181 - a.align_center, 182 - a.justify_center, 183 - a.overflow_hidden, 184 - { 185 - width: size, 186 - height: size, 187 - borderRadius: resolvedBorderRadius, 188 - backgroundColor: '#0085ff', 189 - }, 190 - ]}> 191 - <BskyMark width={Math.round(size * 0.8)} style={{color: '#fff'}} /> 192 - </View> 193 - ) 194 - } 195 - 196 - if (isBridged) { 197 - return ( 198 - <View 199 - style={[ 200 - a.align_center, 201 - a.justify_center, 202 - a.overflow_hidden, 203 - { 204 - width: size, 205 - height: size, 206 - borderRadius: resolvedBorderRadius, 207 - backgroundColor: '#6364FF', 208 - }, 209 - ]}> 210 - <FediverseIcon width={Math.round(size * 0.8)} style={{color: '#fff'}} /> 211 - </View> 212 - ) 213 - } 214 - 215 - if (!imgError && faviconUrl) { 216 - return ( 217 - <View 218 - style={[ 219 - a.overflow_hidden, 220 - a.align_center, 221 - a.justify_center, 222 - { 223 - width: size, 224 - height: size, 225 - borderRadius: resolvedBorderRadius, 226 - backgroundColor: t.atoms.bg_contrast_100.backgroundColor, 227 - }, 228 - ]}> 229 - <Image 230 - source={{uri: faviconUrl}} 231 - style={{width: size, height: size}} 232 - onError={() => setImgError(true)} 233 - accessibilityIgnoresInvertColors 234 - /> 235 - </View> 236 - ) 237 - } 238 - 239 return ( 240 <View 241 style={[ ··· 244 { 245 width: size, 246 height: size, 247 - borderRadius: resolvedBorderRadius, 248 backgroundColor: t.atoms.bg_contrast_100.backgroundColor, 249 }, 250 ]}> ··· 258 </View> 259 ) 260 }
··· 8 import {useLingui} from '@lingui/react' 9 10 import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label' 11 + 12 + const failedFaviconUrls = new Set<string>() 13 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 14 import {Button, ButtonText} from '#/components/Button' 15 import * as Dialog from '#/components/Dialog' 16 + import Svg, {G, Path, Rect} from 'react-native-svg' 17 import {InlineLinkText} from '#/components/Link' 18 import {Text} from '#/components/Typography' 19 ··· 44 }: { 45 control: Dialog.DialogControlProps 46 pdsUrl: string 47 + faviconUrl: string | undefined 48 }) { 49 const {_} = useLingui() 50 const {gtMobile} = useBreakpoints() ··· 68 ]}> 69 <View style={[a.gap_md, a.pb_lg]}> 70 <View style={[a.flex_row, a.align_center, a.gap_md]}> 71 + <PdsBadgeIcon 72 faviconUrl={faviconUrl} 73 isBsky={isBsky} 74 isBridged={isBridged} 75 size={36} 76 /> 77 <View style={[a.flex_1]}> 78 + {/*<Text 79 style={[a.text_2xl, a.font_semi_bold, a.leading_tight]} 80 numberOfLines={1}> 81 {displayName} 82 + </Text>*/} 83 + <Text style={[a.text_2xl, a.font_semi_bold, a.leading_tight]}> 84 + {isBsky && <Trans>Bluesky-hosted PDS</Trans>} 85 + {isBridged && <Trans>Fediverse bridge</Trans>} 86 + {!isBsky && !isBridged && <Trans>Third-party PDS</Trans>} 87 </Text> 88 </View> 89 </View> 90 91 <Text style={[a.text_md, a.leading_snug]}> 92 <Trans> 93 + This badge represents the{' '} 94 + <InlineLinkText 95 + to={'https://atproto.com/guides/self-hosting'} 96 + label={displayName} 97 + style={[a.text_md, a.font_semi_bold]}> 98 + Personal Data Server 99 + </InlineLinkText>{' '} 100 + this account is stored on:{' '} 101 <InlineLinkText 102 to={pdsUrl} 103 label={displayName} ··· 125 </Text> 126 )} 127 128 + {isBsky && ( 129 + <Text style={[a.text_md, a.leading_snug]}> 130 + <Trans> 131 + This account is hosted on one of Bluesky's first party PDS's. 132 + </Trans> 133 + </Text> 134 + )} 135 + 136 {!isBsky && !isBridged && ( 137 <Text style={[a.text_md, a.leading_snug]}> 138 <Trans> ··· 168 ) 169 } 170 171 + function BskyBadgeSVG({size}: {size: number}) { 172 + return ( 173 + <Svg width={size} height={size} viewBox="0 0 24 24"> 174 + <Rect width={24} height={24} rx={6} fill="#0085ff" /> 175 + <G transform="translate(2.4 2.4) scale(0.8)"> 176 + <Path 177 + fill="#fff" 178 + fillRule="evenodd" 179 + clipRule="evenodd" 180 + d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z" 181 + /> 182 + </G> 183 + </Svg> 184 + ) 185 + } 186 + 187 + function FediverseBadgeSVG({size}: {size: number}) { 188 + return ( 189 + <Svg width={size} height={size} viewBox="0 0 24 24"> 190 + <Rect width={24} height={24} rx={6} fill="#6364FF" /> 191 + <G transform="translate(2.4 2.4) scale(0.03)"> 192 + <Path 193 + fill="#fff" 194 + fillRule="evenodd" 195 + clipRule="evenodd" 196 + d="M426.8 590.9C407.1 590.4 389.3 579.3 380.2 561.8C371.2 544.4 372.3 523.4 383.2 507C394.1 490.6 413 481.5 432.6 483.1C452.3 483.6 470.1 494.7 479.2 512.2C488.2 529.6 487.1 550.6 476.2 567C465.3 583.4 446.4 592.5 426.8 590.9zM376.7 510.3C371.2 521.2 369.3 533.6 371.1 545.7L200.7 518.4C206.2 507.5 208.2 495.1 206.4 483L376.7 510.3zM144.7 545.6C125.1 545.1 107.3 533.9 98.3 516.5C89.2 499 90.4 478.1 101.3 461.7C112.1 445.4 131 436.2 150.6 437.8C170.2 438.3 188 449.5 197 466.9C206.1 484.4 204.9 505.3 194 521.7C183.2 538 164.3 547.2 144.7 545.6zM402.4 484.2C391.5 489.8 382.7 498.6 377 509.5L306.4 438.6L340 421.6L402.4 484.3zM518.1 325C526.8 333.6 537.9 339.3 550 341.4L471.4 494.8C462.7 486.2 451.6 480.5 439.5 478.4L518.1 325zM408.7 283.3L439.2 478.4C427.1 476.5 414.7 478.3 403.8 483.7L371.6 277.4L408.8 283.4zM382.4 392.9L206.2 482.2C204.2 470.1 198.6 459 190 450.2L376.6 355.6L382.4 392.8zM229.7 370.9L189.4 449.6C180.7 441 169.6 435.3 157.5 433.3L203.1 344.3L229.7 371zM156.7 433C144.6 431.2 132.3 433.2 121.3 438.6L94.7 268.3C106.8 270.1 119.2 268.2 130.1 262.7L156.7 433zM303.8 385.2L270.2 402.2L130.8 262.3C141.7 256.7 150.5 247.9 156.2 237L303.8 385.2zM501.3 292.4C503.3 304.5 508.9 315.6 517.5 324.3L428.2 369.5L422.4 332.3L501.3 292.3zM556.9 336.7C537.3 336.2 519.5 325 510.5 307.6C501.4 290.1 502.6 269.2 513.5 252.8C524.3 236.5 543.2 227.3 562.8 228.9C582.4 229.4 600.2 240.6 609.2 258C618.3 275.5 617.1 296.4 606.2 312.8C595.4 329.1 576.5 338.3 556.9 336.7zM316.6 122.7C325.3 131.3 336.4 137 348.4 139L253.1 325.1L226.5 298.4L316.5 122.6zM506.9 256.1C501.4 267 499.4 279.4 501.2 291.4L294.8 258.3L312 224.8L507 256.1zM100.7 263.6C81.1 263.1 63.3 251.9 54.3 234.5C45.2 217 46.4 196.1 57.3 179.7C68.1 163.4 87 154.2 106.6 155.8C126.2 156.3 144 167.5 153 184.9C162.1 202.4 160.9 223.3 150 239.7C139.2 256 120.3 265.2 100.7 263.6zM532.7 230.2C521.8 235.8 513 244.6 507.3 255.5L385.5 133.3C396.4 127.7 405.2 118.9 410.9 108L532.6 230.2zM261.3 216.6L244.1 250.1L156.7 236.1C162.1 225.2 164.1 212.8 162.2 200.7L261.2 216.6zM400.8 232.5L363.6 226.5L350 139.3C362.1 141 374.5 139 385.3 133.4L400.8 232.5zM299.8 90.2C301.8 102.3 307.4 113.4 316 122.1L162.1 200.1C160.1 188 154.5 176.9 145.9 168.2L299.8 90.2zM355.4 134.5C335.7 134 317.9 122.9 308.8 105.4C299.8 88 300.9 67 311.8 50.6C322.7 34.2 341.6 25.1 361.2 26.7C380.9 27.2 398.7 38.3 407.8 55.8C416.8 73.2 415.7 94.2 404.8 110.6C393.9 127 375 136.1 355.4 134.5z" 197 + /> 198 + </G> 199 + </Svg> 200 + ) 201 + } 202 + 203 + function DbBadgeIcon({ 204 size, 205 borderRadius, 206 }: { 207 size: number 208 + borderRadius: number 209 }) { 210 const t = useTheme() 211 return ( 212 <View 213 style={[ ··· 216 { 217 width: size, 218 height: size, 219 + borderRadius, 220 backgroundColor: t.atoms.bg_contrast_100.backgroundColor, 221 }, 222 ]}> ··· 230 </View> 231 ) 232 } 233 + 234 + function FaviconBadgeIcon({ 235 + size, 236 + borderRadius, 237 + faviconUrl, 238 + }: { 239 + size: number 240 + borderRadius: number 241 + faviconUrl: string 242 + }) { 243 + const t = useTheme() 244 + const [imgError, setImgError] = useState(() => 245 + failedFaviconUrls.has(faviconUrl), 246 + ) 247 + 248 + if (imgError) { 249 + return <DbBadgeIcon size={size} borderRadius={borderRadius} /> 250 + } 251 + 252 + return ( 253 + <View 254 + style={[ 255 + a.overflow_hidden, 256 + a.align_center, 257 + a.justify_center, 258 + { 259 + width: size, 260 + height: size, 261 + borderRadius, 262 + backgroundColor: t.atoms.bg_contrast_100.backgroundColor, 263 + }, 264 + ]}> 265 + <Image 266 + source={{uri: faviconUrl}} 267 + style={{width: size, height: size}} 268 + accessibilityIgnoresInvertColors 269 + onError={() => { 270 + failedFaviconUrls.add(faviconUrl) 271 + setImgError(true) 272 + }} 273 + /> 274 + </View> 275 + ) 276 + } 277 + 278 + export function PdsBadgeIcon({ 279 + faviconUrl, 280 + isBsky, 281 + isBridged, 282 + size, 283 + borderRadius, 284 + }: { 285 + faviconUrl?: string 286 + isBsky: boolean 287 + isBridged: boolean 288 + size: number 289 + borderRadius?: number 290 + }) { 291 + const r = borderRadius ?? size / 5 292 + if (isBsky) return <BskyBadgeSVG size={size} /> 293 + if (isBridged) return <FediverseBadgeSVG size={size} /> 294 + if (faviconUrl) 295 + return ( 296 + <FaviconBadgeIcon size={size} borderRadius={r} faviconUrl={faviconUrl} /> 297 + ) 298 + return <DbBadgeIcon size={size} borderRadius={r} /> 299 + }
+55 -22
src/state/queries/pds-label.ts
··· 1 import {useQuery} from '@tanstack/react-query' 2 3 import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' 4 5 const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] 6 const BSKY_PDS_SUFFIX = '.bsky.network' ··· 26 } 27 } 28 29 - async function fetchFaviconUrl(pdsUrl: string): Promise<string> { 30 let origin = '' 31 try { 32 origin = new URL(pdsUrl).origin 33 } catch { 34 - return '' 35 } 36 - try { 37 - const res = await fetch(origin, {headers: {Accept: 'text/html'}}) 38 - if (res.ok) { 39 - const html = await res.text() 40 - // Match <link rel="icon"> or <link rel="shortcut icon"> in either attribute order 41 - const match = 42 - html.match( 43 - /<link[^>]+rel=["'][^"']*\bicon\b[^"']*["'][^>]*href=["']([^"']+)["']/i, 44 - ) || 45 - html.match( 46 - /<link[^>]+href=["']([^"']+)["'][^>]*rel=["'][^"']*\bicon\b[^"']*["']/i, 47 - ) 48 - if (match) { 49 const href = match[1] 50 if (href.startsWith('http')) return href 51 if (href.startsWith('//')) return `https:${href}` 52 if (href.startsWith('/')) return `${origin}${href}` 53 return `${origin}/${href}` 54 - } 55 - } 56 - } catch {} 57 - return `${origin}/favicon.ico` 58 } 59 60 export const RQKEY_ROOT = 'pds-label' ··· 68 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) 69 const isBsky = isBskyPdsUrl(pdsUrl) 70 const isBridged = isBridgedPdsUrl(pdsUrl) 71 - const faviconUrl = 72 - isBsky || isBridged ? '' : await fetchFaviconUrl(pdsUrl) 73 - return {pdsUrl, isBsky, isBridged, faviconUrl} 74 }, 75 enabled: !!did, 76 staleTime: 1000 * 60 * 60, // 1 hour 77 }) 78 }
··· 1 import {useQuery} from '@tanstack/react-query' 2 3 import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' 4 + import {IS_WEB} from '#/env' 5 6 const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] 7 const BSKY_PDS_SUFFIX = '.bsky.network' ··· 27 } 28 } 29 30 + async function fetchFaviconUrl(pdsUrl: string): Promise<string | undefined> { 31 let origin = '' 32 try { 33 origin = new URL(pdsUrl).origin 34 } catch { 35 + return undefined 36 + } 37 + 38 + if (IS_WEB) { 39 + // fetch() is blocked by CORS for third-party origins on web. 40 + // Use the browser Image constructor instead — it loads cross-origin without CORS. 41 + // Only resolve with the URL once the image confirms it loaded. 42 + return new Promise<string | undefined>(resolve => { 43 + const url = `${origin}/favicon.ico` 44 + const img = new Image() 45 + img.onload = () => resolve(url) 46 + img.onerror = () => resolve(undefined) 47 + img.src = url 48 + }) 49 } 50 + 51 + const [linkIconUrl, faviconIcoUrl] = await Promise.all([ 52 + fetch(origin, {headers: {Accept: 'text/html'}}) 53 + .then(async res => { 54 + if (!res.ok) return undefined 55 + const html = await res.text() 56 + // Match <link rel="icon"> or <link rel="shortcut icon"> in either attribute order 57 + const match = 58 + html.match( 59 + /<link[^>]+rel=["'][^"']*\bicon\b[^"']*["'][^>]*href=["']([^"']+)["']/i, 60 + ) || 61 + html.match( 62 + /<link[^>]+href=["']([^"']+)["'][^>]*rel=["'][^"']*\bicon\b[^"']*["']/i, 63 + ) 64 + if (!match) return undefined 65 const href = match[1] 66 if (href.startsWith('http')) return href 67 if (href.startsWith('//')) return `https:${href}` 68 if (href.startsWith('/')) return `${origin}${href}` 69 return `${origin}/${href}` 70 + }) 71 + .catch(() => undefined), 72 + fetch(`${origin}/favicon.ico`) 73 + .then(res => (res.ok ? `${origin}/favicon.ico` : undefined)) 74 + .catch(() => undefined), 75 + ]) 76 + 77 + return faviconIcoUrl ?? linkIconUrl 78 } 79 80 export const RQKEY_ROOT = 'pds-label' ··· 88 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) 89 const isBsky = isBskyPdsUrl(pdsUrl) 90 const isBridged = isBridgedPdsUrl(pdsUrl) 91 + return {pdsUrl, isBsky, isBridged} 92 }, 93 enabled: !!did, 94 staleTime: 1000 * 60 * 60, // 1 hour 95 }) 96 } 97 + 98 + export const RQKEY_FAVICON_ROOT = 'pds-favicon' 99 + export const RQKEY_FAVICON = (pdsUrl: string) => [RQKEY_FAVICON_ROOT, pdsUrl] 100 + 101 + export function usePdsFaviconQuery(pdsUrl: string | undefined) { 102 + return useQuery({ 103 + queryKey: RQKEY_FAVICON(pdsUrl ?? ''), 104 + queryFn: async () => { 105 + if (!pdsUrl) return undefined 106 + return await fetchFaviconUrl(pdsUrl) 107 + }, 108 + enabled: !!pdsUrl, 109 + staleTime: 1000 * 60 * 60, // 1 hour 110 + }) 111 + }