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 6 usePdsLabelEnabled, 7 7 usePdsLabelHideBskyPds, 8 8 } from '#/state/preferences/pds-label' 9 - import {usePdsLabelQuery} from '#/state/queries/pds-label' 9 + import {usePdsFaviconQuery, usePdsLabelQuery} from '#/state/queries/pds-label' 10 10 import {atoms as a, useBreakpoints} from '#/alf' 11 11 import {Button} from '#/components/Button' 12 12 import * as Dialog from '#/components/Dialog' 13 - import {FaviconOrGlobe, PdsDialog} from '#/components/PdsDialog' 13 + import {PdsBadgeIcon, PdsDialog} from '#/components/PdsDialog' 14 14 import {IS_WEB} from '#/env' 15 15 16 16 export function PdsBadge({ 17 17 did, 18 + handle, 18 19 size, 19 20 interactive = true, 20 21 }: { 21 22 did: string 23 + handle?: string 22 24 size: 'lg' | 'md' | 'sm' 23 25 interactive?: boolean 24 26 }) { 25 27 const enabled = usePdsLabelEnabled() 26 28 const hideBskyPds = usePdsLabelHideBskyPds() 27 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') 28 37 29 38 if (!enabled) return null 30 - if (isLoading) return <PdsBadgeLoading size={size} /> 39 + if (isLoading) return <PdsBadgeLoading size={size} isBsky={isBskyHandle} /> 31 40 if (!data) return null 32 41 if (hideBskyPds && data.isBsky) return null 33 42 34 43 return ( 35 44 <PdsBadgeInner 36 45 pdsUrl={data.pdsUrl} 37 - faviconUrl={data.faviconUrl} 46 + faviconUrl={faviconUrl} 38 47 isBsky={data.isBsky} 39 48 isBridged={data.isBridged} 40 49 size={size} ··· 43 52 ) 44 53 } 45 54 46 - function PdsBadgeLoading({size}: {size: 'lg' | 'md' | 'sm'}) { 55 + function PdsBadgeLoading({ 56 + size, 57 + isBsky = false, 58 + }: { 59 + size: 'lg' | 'md' | 'sm' 60 + isBsky?: boolean 61 + }) { 47 62 const {gtPhone} = useBreakpoints() 48 63 let dimensions = 12 49 64 if (size === 'lg') { ··· 53 68 } 54 69 return ( 55 70 <View style={{width: dimensions, height: dimensions}}> 56 - <FaviconOrGlobe 57 - faviconUrl="" 58 - isBsky={false} 71 + <PdsBadgeIcon 72 + faviconUrl={undefined} 73 + isBsky={isBsky} 59 74 isBridged={false} 60 75 size={dimensions} 61 - borderRadius={dimensions / 4} 76 + borderRadius={Math.round(dimensions * 0.25)} 62 77 /> 63 78 </View> 64 79 ) ··· 73 88 interactive, 74 89 }: { 75 90 pdsUrl: string 76 - faviconUrl: string 91 + faviconUrl: string | undefined 77 92 isBsky: boolean 78 93 isBridged: boolean 79 94 size: 'lg' | 'md' | 'sm' ··· 91 106 } 92 107 93 108 const icon = ( 94 - <FaviconOrGlobe 109 + <PdsBadgeIcon 95 110 faviconUrl={faviconUrl} 96 111 isBsky={isBsky} 97 112 isBridged={isBridged} 98 113 size={dimensions} 99 - borderRadius={dimensions / 4} 114 + borderRadius={Math.round(dimensions * 0.25)} 100 115 /> 101 116 ) 102 117 ··· 126 141 } 127 142 }}> 128 143 {({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} 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> 141 161 </View> 142 162 )} 143 163 </Button>
+129 -90
src/components/PdsDialog.tsx
··· 8 8 import {useLingui} from '@lingui/react' 9 9 10 10 import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label' 11 + 12 + const failedFaviconUrls = new Set<string>() 11 13 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 14 import {Button, ButtonText} from '#/components/Button' 13 15 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 Svg, {G, Path, Rect} from 'react-native-svg' 16 17 import {InlineLinkText} from '#/components/Link' 17 18 import {Text} from '#/components/Typography' 18 19 ··· 43 44 }: { 44 45 control: Dialog.DialogControlProps 45 46 pdsUrl: string 46 - faviconUrl: string 47 + faviconUrl: string | undefined 47 48 }) { 48 49 const {_} = useLingui() 49 50 const {gtMobile} = useBreakpoints() ··· 67 68 ]}> 68 69 <View style={[a.gap_md, a.pb_lg]}> 69 70 <View style={[a.flex_row, a.align_center, a.gap_md]}> 70 - <FaviconOrGlobe 71 + <PdsBadgeIcon 71 72 faviconUrl={faviconUrl} 72 73 isBsky={isBsky} 73 74 isBridged={isBridged} 74 75 size={36} 75 76 /> 76 77 <View style={[a.flex_1]}> 77 - <Text 78 + {/*<Text 78 79 style={[a.text_2xl, a.font_semi_bold, a.leading_tight]} 79 80 numberOfLines={1}> 80 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>} 81 87 </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 88 </View> 93 89 </View> 94 90 95 91 <Text style={[a.text_md, a.leading_snug]}> 96 92 <Trans> 97 - This account's data is stored on a Personal Data Server (PDS):{' '} 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:{' '} 98 101 <InlineLinkText 99 102 to={pdsUrl} 100 103 label={displayName} ··· 122 125 </Text> 123 126 )} 124 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 + 125 136 {!isBsky && !isBridged && ( 126 137 <Text style={[a.text_md, a.leading_snug]}> 127 138 <Trans> ··· 157 168 ) 158 169 } 159 170 160 - export function FaviconOrGlobe({ 161 - faviconUrl, 162 - isBsky, 163 - isBridged, 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({ 164 204 size, 165 205 borderRadius, 166 206 }: { 167 - faviconUrl: string 168 - isBsky: boolean 169 - isBridged: boolean 170 207 size: number 171 - borderRadius?: number 208 + borderRadius: number 172 209 }) { 173 210 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 211 return ( 240 212 <View 241 213 style={[ ··· 244 216 { 245 217 width: size, 246 218 height: size, 247 - borderRadius: resolvedBorderRadius, 219 + borderRadius, 248 220 backgroundColor: t.atoms.bg_contrast_100.backgroundColor, 249 221 }, 250 222 ]}> ··· 258 230 </View> 259 231 ) 260 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 1 import {useQuery} from '@tanstack/react-query' 2 2 3 3 import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' 4 + import {IS_WEB} from '#/env' 4 5 5 6 const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] 6 7 const BSKY_PDS_SUFFIX = '.bsky.network' ··· 26 27 } 27 28 } 28 29 29 - async function fetchFaviconUrl(pdsUrl: string): Promise<string> { 30 + async function fetchFaviconUrl(pdsUrl: string): Promise<string | undefined> { 30 31 let origin = '' 31 32 try { 32 33 origin = new URL(pdsUrl).origin 33 34 } catch { 34 - return '' 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 + }) 35 49 } 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) { 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 49 65 const href = match[1] 50 66 if (href.startsWith('http')) return href 51 67 if (href.startsWith('//')) return `https:${href}` 52 68 if (href.startsWith('/')) return `${origin}${href}` 53 69 return `${origin}/${href}` 54 - } 55 - } 56 - } catch {} 57 - return `${origin}/favicon.ico` 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 58 78 } 59 79 60 80 export const RQKEY_ROOT = 'pds-label' ··· 68 88 const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) 69 89 const isBsky = isBskyPdsUrl(pdsUrl) 70 90 const isBridged = isBridgedPdsUrl(pdsUrl) 71 - const faviconUrl = 72 - isBsky || isBridged ? '' : await fetchFaviconUrl(pdsUrl) 73 - return {pdsUrl, isBsky, isBridged, faviconUrl} 91 + return {pdsUrl, isBsky, isBridged} 74 92 }, 75 93 enabled: !!did, 76 94 staleTime: 1000 * 60 * 60, // 1 hour 77 95 }) 78 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 + }