diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx
index 26f4a451f..811692033 100644
--- a/src/components/AccountList.tsx
+++ b/src/components/AccountList.tsx
@@ -17,6 +17,7 @@ import {Button} from '#/components/Button'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import {PdsBadge} from '#/components/PdsBadge'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -167,6 +168,7 @@ function AccountItem({
profile?.displayName || profile?.handle || account.handle,
)}
+
{verification.showBadge && (
+ if (!data) return null
+ if (hideBskyPds && data.isBsky) return null
+
+ return (
+
+ )
+}
+
+function PdsBadgeLoading({size}: {size: 'lg' | 'md' | 'sm'}) {
+ const {gtPhone} = useBreakpoints()
+ let dimensions = 12
+ if (size === 'lg') {
+ dimensions = gtPhone ? 20 : 18
+ } else if (size === 'md') {
+ dimensions = 14
+ }
+ return (
+
+
+
+ )
+}
+
+function PdsBadgeInner({
+ pdsUrl,
+ faviconUrl,
+ isBsky,
+ isBridged,
+ size,
+ interactive,
+}: {
+ pdsUrl: string
+ faviconUrl: string
+ isBsky: boolean
+ isBridged: boolean
+ size: 'lg' | 'md' | 'sm'
+ interactive: boolean
+}) {
+ const {_} = useLingui()
+ const {gtPhone} = useBreakpoints()
+ const dialogControl = Dialog.useDialogControl()
+
+ let dimensions = 12
+ if (size === 'lg') {
+ dimensions = gtPhone ? 20 : 18
+ } else if (size === 'md') {
+ dimensions = 14
+ }
+
+ const icon = (
+
+ )
+
+ if (!interactive) {
+ return (
+
+ {icon}
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/src/components/PdsDialog.tsx b/src/components/PdsDialog.tsx
new file mode 100644
index 000000000..e50685d6f
--- /dev/null
+++ b/src/components/PdsDialog.tsx
@@ -0,0 +1,260 @@
+import {useState} from 'react'
+import {Image, View} from 'react-native'
+import {
+ FontAwesomeIcon,
+ type FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label'
+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 {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+function formatBskyPdsDisplayName(hostname: string): string {
+ const match = hostname.match(/^([^.]+)\.([^.]+)\.host\.bsky\.network$/)
+ if (match) {
+ const name = match[1].charAt(0).toUpperCase() + match[1].slice(1)
+ const rawRegion = match[2]
+ const region = rawRegion
+ .replace(/^us-east$/, 'US East')
+ .replace(/^us-west$/, 'US West')
+ .replace(/^eu-west$/, 'EU West')
+ .replace(
+ /^ap-(.+)$/,
+ (_match: string, r: string) =>
+ `AP ${r.charAt(0).toUpperCase()}${r.slice(1)}`,
+ )
+ return `${name} (${region})`
+ }
+ if (hostname === 'bsky.social') return 'Bluesky Social'
+ return hostname
+}
+
+export function PdsDialog({
+ control,
+ pdsUrl,
+ faviconUrl,
+}: {
+ control: Dialog.DialogControlProps
+ pdsUrl: string
+ faviconUrl: string
+}) {
+ const {_} = useLingui()
+ const {gtMobile} = useBreakpoints()
+
+ let hostname = pdsUrl
+ try {
+ hostname = new URL(pdsUrl).hostname
+ } catch {}
+
+ const isBsky = isBskyPdsUrl(pdsUrl)
+ const isBridged = isBridgedPdsUrl(pdsUrl)
+ const displayName = isBsky ? formatBskyPdsDisplayName(hostname) : hostname
+
+ return (
+
+
+
+
+
+
+
+
+ {displayName}
+
+ {isBsky && (
+
+ Bluesky-hosted PDS
+
+ )}
+ {isBridged && (
+
+ Fediverse bridge
+
+ )}
+
+
+
+
+
+ This account's data is stored on a Personal Data Server (PDS):{' '}
+
+ {displayName}
+
+ {'. '}A PDS is where your posts, follows, and other data live on
+ the AT Protocol network.
+
+
+
+ {isBridged && (
+
+
+ This account is bridged from the Fediverse via{' '}
+
+ Bridgy Fed
+
+ . Their original account lives on a Fediverse platform such as
+ Mastodon.
+
+
+ )}
+
+ {!isBsky && !isBridged && (
+
+
+ This account is self-hosted or uses a third-party PDS provider.
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function FaviconOrGlobe({
+ faviconUrl,
+ isBsky,
+ isBridged,
+ size,
+ borderRadius,
+}: {
+ faviconUrl: string
+ isBsky: boolean
+ isBridged: boolean
+ size: 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 (
+
+
+
+ )
+}
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 9e1de5f94..b11d6cb7d 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -40,6 +40,7 @@ import {
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
+import {PdsBadge} from '#/components/PdsBadge'
import * as Pills from '#/components/Pills'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
@@ -259,6 +260,14 @@ function InlineNameAndHandle({
numberOfLines={1}>
{forceLTR(name)}
+
+
+
{verification.showBadge && (
{name}
+
+
+
{verification.showBadge && (
+
+
+
{verification.showBadge && (
@@ -581,7 +585,7 @@ function Inner({
{!isBlockedUser && (
<>
- {disableFollowersMetrics && disableFollowingMetrics ? ( null ) :
+ {disableFollowersMetrics && disableFollowingMetrics ? null : (
{!disableFollowersMetrics ? (
- {followers}
+
+ {followers}{' '}
+
{pluralizedFollowers}
@@ -601,14 +607,16 @@ function Inner({
label={_(msg`${following} following`)}
style={[t.atoms.text]}
onPress={hide}>
- {following}
+
+ {following}{' '}
+
{pluralizedFollowings}
) : null}
- }
+ )}
{profile.description?.trim() && !moderation.ui('profileView').blur ? (
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index afdeee545..f23bae999 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -20,6 +20,7 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PdsBadge} from '#/components/PdsBadge'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -161,6 +162,9 @@ function HeaderReady({
numberOfLines={1}>
{displayName}
+
+
+
{verification.showBadge && (
+
+
+
{verification.showBadge && (
-
+
+
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
index bbad21103..6af670910 100644
--- a/src/screens/Profile/Header/DisplayName.tsx
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -5,6 +5,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {type Shadow} from '#/state/cache/types'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
+import {PdsBadge} from '#/components/PdsBadge'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
@@ -36,10 +37,14 @@ export function ProfileHeaderDisplayName({
+
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 8e214116b..d178d72a4 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -42,6 +42,7 @@ import {
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {Link} from '#/components/Link'
+import {PdsBadge} from '#/components/PdsBadge'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import * as Toast from '#/components/Toast'
@@ -162,7 +163,15 @@ let ProfileHeaderStandard = ({
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
-
+
+
diff --git a/src/screens/Settings/RunesSettings.tsx b/src/screens/Settings/RunesSettings.tsx
index 212514dec..fac1dc917 100644
--- a/src/screens/Settings/RunesSettings.tsx
+++ b/src/screens/Settings/RunesSettings.tsx
@@ -5,7 +5,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
-import { DEFAULT_ALT_TEXT_AI_MODEL } from '#/lib/constants'
+import {DEFAULT_ALT_TEXT_AI_MODEL} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {dynamicActivate} from '#/locale/i18n'
@@ -114,6 +114,12 @@ import {
useSetOpenRouterApiKey,
useSetOpenRouterModel,
} from '#/state/preferences/openrouter'
+import {
+ usePdsLabelEnabled,
+ usePdsLabelHideBskyPds,
+ useSetPdsLabelEnabled,
+ useSetPdsLabelHideBskyPds,
+} from '#/state/preferences/pds-label'
import {
usePostReplacement,
useSetPostReplacement,
@@ -708,6 +714,11 @@ export function RunesSettingsScreen({}: Props) {
const deerVerificationEnabled = useDeerVerificationEnabled()
const setDeerVerificationEnabled = useSetDeerVerificationEnabled()
+ const pdsLabelEnabled = usePdsLabelEnabled()
+ const setPdsLabelEnabled = useSetPdsLabelEnabled()
+ const pdsLabelHideBskyPds = usePdsLabelHideBskyPds()
+ const setPdsLabelHideBskyPds = useSetPdsLabelHideBskyPds()
+
const repostCarouselEnabled = useRepostCarouselEnabled()
const setRepostCarouselEnabled = useSetRepostCarouselEnabled()
@@ -760,7 +771,9 @@ export function RunesSettingsScreen({}: Props) {
setHandleInLinks(value)}
style={[a.w_full]}>
@@ -908,6 +921,35 @@ export function RunesSettingsScreen({}: Props) {
Tweaks
+ setPdsLabelEnabled(value)}
+ style={[a.w_full]}>
+
+
+ Show a PDS badge next to the display name on profiles
+
+
+
+
+ {pdsLabelEnabled && (
+ setPdsLabelHideBskyPds(value)}
+ style={[a.w_full]}>
+
+ Hide PDS badge for Bluesky-hosted accounts
+
+
+
+ )}
+
- Current model:{' '}
- {openRouterModel ?? DEFAULT_ALT_TEXT_AI_MODEL}.{' '}
+ Current model: {openRouterModel ?? DEFAULT_ALT_TEXT_AI_MODEL}.{' '}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index 242b84c96..136c6fb7e 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -372,16 +372,17 @@ function ProfilePreview({
]}>
{displayName}
- {shouldShowVerificationCheckButton(verificationState) && (
-
+
+ {shouldShowVerificationCheckButton(verificationState) && (
-
- )}
+ )}
+
{sanitizeHandle(profile.handle, '@')}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 644387382..7ba7f633c 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -173,6 +173,12 @@ const schema = z.object({
.optional(),
highQualityImages: z.boolean().optional(),
hideUnreplyablePosts: z.boolean().optional(),
+ pdsLabel: z
+ .object({
+ enabled: z.boolean(),
+ hideBskyPds: z.boolean(),
+ })
+ .optional(),
postReplacement: z.object({
enabled: z.boolean().optional(),
@@ -304,6 +310,10 @@ export const defaults: Schema = {
},
highQualityImages: false,
hideUnreplyablePosts: false,
+ pdsLabel: {
+ enabled: false,
+ hideBskyPds: true,
+ },
showExternalShareButtons: false,
translationServicePreference: 'google',
libreTranslateInstance: 'https://libretranslate.com/',
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 8afa9b433..43c325cab 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -37,6 +37,7 @@ import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
import {Provider as NoAppLabelersProvider} from './no-app-labelers'
import {Provider as NoDiscoverProvider} from './no-discover-fallback'
import {Provider as OpenRouterProvider} from './openrouter'
+import {Provider as PdsLabelProvider} from './pds-label'
import {Provider as PostNameReplacementProvider} from './post-name-replacement.tsx'
import {Provider as RepostCarouselProvider} from './repost-carousel-enabled'
import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle'
@@ -96,81 +97,83 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- children
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ children
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/state/preferences/pds-label.tsx b/src/state/preferences/pds-label.tsx
new file mode 100644
index 000000000..08e7cf9d9
--- /dev/null
+++ b/src/state/preferences/pds-label.tsx
@@ -0,0 +1,75 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['pdsLabel']
+type SetContext = (v: persisted.Schema['pdsLabel']) => void
+
+const stateContext = React.createContext(
+ persisted.defaults.pdsLabel,
+)
+const setContext = React.createContext(
+ (_: persisted.Schema['pdsLabel']) => {},
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const [state, setState] = React.useState(persisted.get('pdsLabel'))
+
+ const setStateWrapped = React.useCallback(
+ (pdsLabel: persisted.Schema['pdsLabel']) => {
+ setState(pdsLabel)
+ persisted.write('pdsLabel', pdsLabel)
+ },
+ [setState],
+ )
+
+ React.useEffect(() => {
+ return persisted.onUpdate('pdsLabel', next => {
+ setState(next)
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function usePdsLabel() {
+ return React.useContext(stateContext) ?? persisted.defaults.pdsLabel!
+}
+
+export function usePdsLabelEnabled() {
+ return usePdsLabel().enabled
+}
+
+export function usePdsLabelHideBskyPds() {
+ return usePdsLabel().hideBskyPds
+}
+
+export function useSetPdsLabel() {
+ return React.useContext(setContext)
+}
+
+export function useSetPdsLabelEnabled() {
+ const pdsLabel = usePdsLabel()
+ const setPdsLabel = useSetPdsLabel()
+
+ return React.useMemo(
+ () => (enabled: boolean) => setPdsLabel({...pdsLabel, enabled}),
+ [pdsLabel, setPdsLabel],
+ )
+}
+
+export function useSetPdsLabelHideBskyPds() {
+ const pdsLabel = usePdsLabel()
+ const setPdsLabel = useSetPdsLabel()
+
+ return React.useMemo(
+ () => (hideBskyPds: boolean) => setPdsLabel({...pdsLabel, hideBskyPds}),
+ [pdsLabel, setPdsLabel],
+ )
+}
diff --git a/src/state/queries/pds-label.ts b/src/state/queries/pds-label.ts
new file mode 100644
index 000000000..e81c034f6
--- /dev/null
+++ b/src/state/queries/pds-label.ts
@@ -0,0 +1,78 @@
+import {useQuery} from '@tanstack/react-query'
+
+import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity'
+
+const BSKY_PDS_HOSTNAMES = ['bsky.social', 'staging.bsky.dev']
+const BSKY_PDS_SUFFIX = '.bsky.network'
+const BRIDGY_FED_HOSTNAME = 'atproto.brid.gy'
+
+export function isBskyPdsUrl(url: string): boolean {
+ try {
+ const hostname = new URL(url).hostname
+ return (
+ BSKY_PDS_HOSTNAMES.includes(hostname) ||
+ hostname.endsWith(BSKY_PDS_SUFFIX)
+ )
+ } catch {
+ return false
+ }
+}
+
+export function isBridgedPdsUrl(url: string): boolean {
+ try {
+ return new URL(url).hostname === BRIDGY_FED_HOSTNAME
+ } catch {
+ return false
+ }
+}
+
+async function fetchFaviconUrl(pdsUrl: string): Promise {
+ let origin = ''
+ try {
+ origin = new URL(pdsUrl).origin
+ } catch {
+ return ''
+ }
+ 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) {
+ 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`
+}
+
+export const RQKEY_ROOT = 'pds-label'
+export const RQKEY = (did: string) => [RQKEY_ROOT, did]
+
+export function usePdsLabelQuery(did: string | undefined) {
+ return useQuery({
+ queryKey: RQKEY(did ?? ''),
+ queryFn: async () => {
+ if (!did) return null
+ 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}
+ },
+ enabled: !!did,
+ staleTime: 1000 * 60 * 60, // 1 hour
+ })
+}
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index cba3382da..e3a242676 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -16,6 +16,7 @@ import {sanitizePronouns} from '#/lib/strings/pronouns'
import {type ComposerOptsPostRef} from '#/state/shell/composer'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
+import {PdsBadge} from '#/components/PdsBadge'
import {QuoteEmbed} from '#/components/Post/Embed'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
@@ -116,6 +117,9 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
sanitizeHandle(replyTo.author.handle),
)}
+
+
+
{verification.showBadge && (
{displayName}
+
+
+
{state.isVerified && (
+
+
+
{verification.showBadge && (
{
),
)}
+
+
+
{verification.showBadge && (
{profile?.displayName || account.handle}
+
{verification.showBadge && (