From 00930e6433e2b249463bcad84c2427189e6dcc8f Mon Sep 17 00:00:00 2001 From: Eric Hamilton Date: Mon, 2 Mar 2026 12:50:00 -0800 Subject: [PATCH] feat: pds badge for identifying service providers. --- src/components/AccountList.tsx | 2 + src/components/PdsBadge.tsx | 152 ++++++++++ src/components/PdsDialog.tsx | 260 ++++++++++++++++++ src/components/ProfileCard.tsx | 12 + src/components/ProfileHoverCard/index.web.tsx | 18 +- src/components/dms/MessagesListHeader.tsx | 4 + src/components/icons/Fediverse.tsx | 6 + .../Messages/components/ChatListItem.tsx | 4 + .../components/ThreadItemAnchor.tsx | 5 +- src/screens/Profile/Header/DisplayName.tsx | 5 + .../Profile/Header/ProfileHeaderStandard.tsx | 11 +- src/screens/Settings/RunesSettings.tsx | 49 +++- src/screens/Settings/Settings.tsx | 19 +- src/state/persisted/schema.ts | 10 + src/state/preferences/index.tsx | 153 ++++++----- src/state/preferences/pds-label.tsx | 75 +++++ src/state/queries/pds-label.ts | 78 ++++++ src/view/com/composer/ComposerReplyTo.tsx | 4 + .../text-input/mobile/Autocomplete.tsx | 4 + .../notifications/NotificationFeedItem.tsx | 4 + src/view/com/util/PostMeta.tsx | 11 + src/view/icons/index.tsx | 3 + src/view/shell/Drawer.tsx | 2 + 23 files changed, 796 insertions(+), 95 deletions(-) create mode 100644 src/components/PdsBadge.tsx create mode 100644 src/components/PdsDialog.tsx create mode 100644 src/components/icons/Fediverse.tsx create mode 100644 src/state/preferences/pds-label.tsx create mode 100644 src/state/queries/pds-label.ts 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 && (