diff --git a/src/components/PdsBadge.tsx b/src/components/PdsBadge.tsx index 48d7cbce1..0ab06d89c 100644 --- a/src/components/PdsBadge.tsx +++ b/src/components/PdsBadge.tsx @@ -6,35 +6,44 @@ import { usePdsLabelEnabled, usePdsLabelHideBskyPds, } from '#/state/preferences/pds-label' -import {usePdsLabelQuery} from '#/state/queries/pds-label' +import {usePdsFaviconQuery, usePdsLabelQuery} from '#/state/queries/pds-label' import {atoms as a, useBreakpoints} from '#/alf' import {Button} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {FaviconOrGlobe, PdsDialog} from '#/components/PdsDialog' +import {PdsBadgeIcon, PdsDialog} from '#/components/PdsDialog' import {IS_WEB} from '#/env' export function PdsBadge({ did, + handle, size, interactive = true, }: { did: string + handle?: string size: 'lg' | 'md' | 'sm' interactive?: boolean }) { const enabled = usePdsLabelEnabled() const hideBskyPds = usePdsLabelHideBskyPds() const {data, isLoading} = usePdsLabelQuery(enabled ? did : undefined) + const {data: faviconUrl} = usePdsFaviconQuery( + data && !data.isBsky && !data.isBridged ? data.pdsUrl : undefined, + ) + + const isBskyHandle = + !!handle && + (handle.endsWith('.bsky.social') || handle === 'bsky.social') if (!enabled) return null - if (isLoading) return + if (isLoading) return if (!data) return null if (hideBskyPds && data.isBsky) return null return ( - ) @@ -73,7 +88,7 @@ function PdsBadgeInner({ interactive, }: { pdsUrl: string - faviconUrl: string + faviconUrl: string | undefined isBsky: boolean isBridged: boolean size: 'lg' | 'md' | 'sm' @@ -91,12 +106,12 @@ function PdsBadgeInner({ } const icon = ( - ) @@ -126,18 +141,23 @@ function PdsBadgeInner({ } }}> {({hovered}) => ( - - {icon} + + + {icon} + )} diff --git a/src/components/PdsDialog.tsx b/src/components/PdsDialog.tsx index e50685d6f..ee7e037ec 100644 --- a/src/components/PdsDialog.tsx +++ b/src/components/PdsDialog.tsx @@ -8,11 +8,12 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label' + +const failedFaviconUrls = new Set() import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {Fediverse as FediverseIcon} from '#/components/icons/Fediverse' -import {Mark as BskyMark} from '#/components/icons/Logo' +import Svg, {G, Path, Rect} from 'react-native-svg' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' @@ -43,7 +44,7 @@ export function PdsDialog({ }: { control: Dialog.DialogControlProps pdsUrl: string - faviconUrl: string + faviconUrl: string | undefined }) { const {_} = useLingui() const {gtMobile} = useBreakpoints() @@ -67,34 +68,36 @@ export function PdsDialog({ ]}> - - {displayName} + */} + + {isBsky && Bluesky-hosted PDS} + {isBridged && Fediverse bridge} + {!isBsky && !isBridged && Third-party PDS} - {isBsky && ( - - Bluesky-hosted PDS - - )} - {isBridged && ( - - Fediverse bridge - - )} - This account's data is stored on a Personal Data Server (PDS):{' '} + This badge represents the{' '} + + Personal Data Server + {' '} + this account is stored on:{' '} )} + {isBsky && ( + + + This account is hosted on one of Bluesky's first party PDS's. + + + )} + {!isBsky && !isBridged && ( @@ -157,85 +168,46 @@ export function PdsDialog({ ) } -export function FaviconOrGlobe({ - faviconUrl, - isBsky, - isBridged, +function BskyBadgeSVG({size}: {size: number}) { + return ( + + + + + + + ) +} + +function FediverseBadgeSVG({size}: {size: number}) { + return ( + + + + + + + ) +} + +function DbBadgeIcon({ size, borderRadius, }: { - faviconUrl: string - isBsky: boolean - isBridged: boolean size: number - borderRadius?: number + borderRadius: number }) { const t = useTheme() - const [imgError, setImgError] = useState(false) - const resolvedBorderRadius = borderRadius ?? size / 5 - - if (isBsky) { - return ( - - - - ) - } - - if (isBridged) { - return ( - - - - ) - } - - if (!imgError && faviconUrl) { - return ( - - setImgError(true)} - accessibilityIgnoresInvertColors - /> - - ) - } - return ( @@ -258,3 +230,70 @@ export function FaviconOrGlobe({ ) } + +function FaviconBadgeIcon({ + size, + borderRadius, + faviconUrl, +}: { + size: number + borderRadius: number + faviconUrl: string +}) { + const t = useTheme() + const [imgError, setImgError] = useState(() => + failedFaviconUrls.has(faviconUrl), + ) + + if (imgError) { + return + } + + return ( + + { + failedFaviconUrls.add(faviconUrl) + setImgError(true) + }} + /> + + ) +} + +export function PdsBadgeIcon({ + faviconUrl, + isBsky, + isBridged, + size, + borderRadius, +}: { + faviconUrl?: string + isBsky: boolean + isBridged: boolean + size: number + borderRadius?: number +}) { + const r = borderRadius ?? size / 5 + if (isBsky) return + if (isBridged) return + if (faviconUrl) + return ( + + ) + return +} diff --git a/src/state/queries/pds-label.ts b/src/state/queries/pds-label.ts index e81c034f6..6e546ad43 100644 --- a/src/state/queries/pds-label.ts +++ b/src/state/queries/pds-label.ts @@ -1,6 +1,7 @@ import {useQuery} from '@tanstack/react-query' import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' +import {IS_WEB} from '#/env' const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev'] const BSKY_PDS_SUFFIX = '.bsky.network' @@ -26,35 +27,54 @@ export function isBridgedPdsUrl(url: string): boolean { } } -async function fetchFaviconUrl(pdsUrl: string): Promise { +async function fetchFaviconUrl(pdsUrl: string): Promise { let origin = '' try { origin = new URL(pdsUrl).origin } catch { - return '' + return undefined } - try { - const res = await fetch(origin, {headers: {Accept: 'text/html'}}) - if (res.ok) { - const html = await res.text() - // Match or in either attribute order - const match = - html.match( - /]+rel=["'][^"']*\bicon\b[^"']*["'][^>]*href=["']([^"']+)["']/i, - ) || - html.match( - /]+href=["']([^"']+)["'][^>]*rel=["'][^"']*\bicon\b[^"']*["']/i, - ) - if (match) { + + if (IS_WEB) { + // fetch() is blocked by CORS for third-party origins on web. + // Use the browser Image constructor instead — it loads cross-origin without CORS. + // Only resolve with the URL once the image confirms it loaded. + return new Promise(resolve => { + const url = `${origin}/favicon.ico` + const img = new Image() + img.onload = () => resolve(url) + img.onerror = () => resolve(undefined) + img.src = url + }) + } + + const [linkIconUrl, faviconIcoUrl] = await Promise.all([ + fetch(origin, {headers: {Accept: 'text/html'}}) + .then(async res => { + if (!res.ok) return undefined + const html = await res.text() + // Match or in either attribute order + const match = + html.match( + /]+rel=["'][^"']*\bicon\b[^"']*["'][^>]*href=["']([^"']+)["']/i, + ) || + html.match( + /]+href=["']([^"']+)["'][^>]*rel=["'][^"']*\bicon\b[^"']*["']/i, + ) + if (!match) return undefined const href = match[1] if (href.startsWith('http')) return href if (href.startsWith('//')) return `https:${href}` if (href.startsWith('/')) return `${origin}${href}` return `${origin}/${href}` - } - } - } catch {} - return `${origin}/favicon.ico` + }) + .catch(() => undefined), + fetch(`${origin}/favicon.ico`) + .then(res => (res.ok ? `${origin}/favicon.ico` : undefined)) + .catch(() => undefined), + ]) + + return faviconIcoUrl ?? linkIconUrl } export const RQKEY_ROOT = 'pds-label' @@ -68,11 +88,24 @@ export function usePdsLabelQuery(did: string | undefined) { const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) const isBsky = isBskyPdsUrl(pdsUrl) const isBridged = isBridgedPdsUrl(pdsUrl) - const faviconUrl = - isBsky || isBridged ? '' : await fetchFaviconUrl(pdsUrl) - return {pdsUrl, isBsky, isBridged, faviconUrl} + return {pdsUrl, isBsky, isBridged} }, enabled: !!did, staleTime: 1000 * 60 * 60, // 1 hour }) } + +export const RQKEY_FAVICON_ROOT = 'pds-favicon' +export const RQKEY_FAVICON = (pdsUrl: string) => [RQKEY_FAVICON_ROOT, pdsUrl] + +export function usePdsFaviconQuery(pdsUrl: string | undefined) { + return useQuery({ + queryKey: RQKEY_FAVICON(pdsUrl ?? ''), + queryFn: async () => { + if (!pdsUrl) return undefined + return await fetchFaviconUrl(pdsUrl) + }, + enabled: !!pdsUrl, + staleTime: 1000 * 60 * 60, // 1 hour + }) +}