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
+ })
+}