Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
15 kB · 427 lines
TSX
at prod
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428import {type ReactNode, useState} from 'react'import {Image, View} from 'react-native'import Svg, {G, Path, Rect} from 'react-native-svg'import {Trans, useLingui} from '@lingui/react/macro'
import { getPdsFallbackFaviconUrls, isBridgedPdsUrl, isBskyPdsUrl,} from '#/state/queries/pds-label.util'import {useServiceQuery} from '#/state/queries/service'import {atoms as a, useBreakpoints, useTheme} from '#/alf'import * as Dialog from '#/components/Dialog'import {Database_Filled_Corner0_Rounded as DatabaseIcon} from '#/components/icons/Database'import {InlineLinkText} from '#/components/Link'import {Loader} from '#/components/Loader'import {Text} from '#/components/Typography'
const failedFaviconUrls = new Set<string>()const loadedFaviconUrls = new Set<string>()
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, loadDescription = false,}: { control: Dialog.DialogControlProps pdsUrl: string faviconUrl: string | undefined loadDescription?: boolean}) { const {t: l} = useLingui() const t = useTheme() 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 const descriptionUrl = isBsky ? 'https://bsky.social' : pdsUrl const shouldLoadDescription = loadDescription && !isBridged const description = useServiceQuery(descriptionUrl, { enabled: shouldLoadDescription, })
const contactEmail = description.data?.contact?.email?.trim() const handleDomains = description.data?.availableUserDomains ?? [] const inviteCodeRequired = description.data?.inviteCodeRequired const formattedDomains = handleDomains.map(domain => domain.replace(/^\./, ''), )
return ( <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Handle /> <Dialog.ScrollableInner label={l`PDS Information`} style={[ gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, ]}> <View style={[a.gap_lg, a.pb_lg]}> <View style={[a.flex_row, a.align_center, a.gap_md]}> <PdsBadgeIcon faviconUrl={faviconUrl} pdsUrl={pdsUrl} isBsky={isBsky} isBridged={isBridged} size={36} /> <View style={[a.flex_1]}> <Text style={[a.text_2xl, a.font_semi_bold, a.leading_tight]} numberOfLines={1}> {isBridged ? <Trans>Fediverse</Trans> : displayName} </Text> {isBsky && ( <Text style={[a.text_sm, a.leading_tight]}> <Trans>Bluesky Social</Trans> </Text> )} </View> </View>
{!isBridged && ( <Text style={[a.text_md, a.leading_snug]}> <Trans> This account’s data is stored on a Personal Data Server at{' '} <InlineLinkText to={pdsUrl} label={hostname} style={[a.text_md, a.font_semi_bold]}> {hostname} </InlineLinkText> . </Trans> </Text> )}
{isBridged && ( <Text style={[a.text_md, a.leading_snug]}> <Trans> This account is bridged from the{' '} <InlineLinkText to="https://wikipedia.org/wiki/Fediverse" label={l`Learn more about the Fediverse`} style={[a.text_md, a.font_semi_bold]}> Fediverse </InlineLinkText>{' '} via{' '} <InlineLinkText to="https://witchsky.app/profile/ap.brid.gy" label="Bridgy Fed" style={[a.text_md, a.font_semi_bold]}> Bridgy Fed </InlineLinkText> . </Trans> </Text> )}
{shouldLoadDescription && description.isLoading && ( <View style={[a.flex_row, a.align_center, a.gap_sm]}> <Loader size="md" /> <Text style={[a.text_md, t.atoms.text_contrast_medium]}> <Trans>Loading server details…</Trans> </Text> </View> )}
{shouldLoadDescription && description.isSuccess && description.data && ( <View style={[a.gap_md]}> {contactEmail ? ( <DetailItem label={l`Contact`} valueText={ <InlineLinkText to={`mailto:${contactEmail}`} label={contactEmail} style={[a.text_md, a.font_semi_bold]}> {contactEmail} </InlineLinkText> } /> ) : null}
<DetailItem label={l`Signup`} valueText={ inviteCodeRequired ? ( <Trans>Invite code required</Trans> ) : ( <Trans>Open</Trans> ) } />
{formattedDomains.length > 0 && ( <View style={[a.gap_xs]}> <DetailLabelText>{l`Handles`}</DetailLabelText> <View style={[a.gap_xs]}> {formattedDomains.map(domain => ( <View key={domain} style={[a.flex_row, a.gap_sm, a.align_start]}> <Text style={[a.text_md, a.leading_snug]}>•</Text> <Text style={[a.text_md, a.leading_snug, a.flex_1]}> {domain} </Text> </View> ))} </View> </View> )} </View> )}
{shouldLoadDescription && description.isError && ( <Text style={[a.text_md, t.atoms.text_contrast_medium]}> <Trans>Couldn’t load extra details from this server.</Trans> </Text> )}
{!isBridged && ( <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> <Trans> <InlineLinkText to="https://wikipedia.org/wiki/AT_Protocol#Personal_Data_Servers" label={l`Learn more about PDSes`} style={[a.text_md, a.font_semi_bold]}> Learn more </InlineLinkText>{' '} about PDSes or how to{' '} <InlineLinkText to="https://atproto.com/guides/self-hosting#pds" label={l`Self-hosting PDS documentation`} style={[a.text_md, a.font_semi_bold]}> self-host </InlineLinkText>{' '} your own. </Trans> </Text> )} </View>
<Dialog.Close /> </Dialog.ScrollableInner> </Dialog.Outer> )}
function DetailItem({label, valueText}: {label: string; valueText: ReactNode}) { return ( <View style={[a.gap_xs]}> <DetailLabelText>{label}</DetailLabelText> <Text style={[a.text_md, a.leading_snug]}>{valueText}</Text> </View> )}
function DetailLabelText({children}: {children: ReactNode}) { const t = useTheme() return ( <Text style={[ a.text_sm, a.font_semi_bold, a.leading_snug, t.atoms.text_contrast_medium, ]}> {children} </Text> )}
function BskyBadgeSVG({size}: {size: number}) { return ( <Svg width={size} height={size} viewBox="0 0 24 24"> <Rect width={24} height={24} rx={6} fill="#0085ff" /> <G transform="translate(2.4 2.4) scale(0.8)"> <Path fill="#fff" fillRule="evenodd" clipRule="evenodd" 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" /> </G> </Svg> )}
function FediverseBadgeSVG({size}: {size: number}) { return ( <Svg width={size} height={size} viewBox="0 0 24 24"> <Rect width={24} height={24} rx={6} fill="#6364FF" /> <G transform="translate(2.4 2.4) scale(0.03)"> <Path fill="#fff" fillRule="evenodd" clipRule="evenodd" 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" /> </G> </Svg> )}
function DbBadgeIcon({ size, borderRadius,}: { size: number borderRadius: number}) { const t = useTheme() return ( <View style={[ a.align_center, a.justify_center, { width: size, height: size, borderRadius, backgroundColor: t.atoms.bg_contrast_100.backgroundColor, }, ]}> <DatabaseIcon width={Math.round(size * 0.7)} height={Math.round(size * 0.7)} fill={t.atoms.text_contrast_medium.color} /> </View> )}
function FaviconBadgeIcon({ size, borderRadius, faviconUrls,}: { size: number borderRadius: number faviconUrls: string[]}) { const t = useTheme() const getNextUrl = (currentUrl?: string) => faviconUrls.find( url => url !== currentUrl && url && !failedFaviconUrls.has(url), ) const [currentUrl, setCurrentUrl] = useState<string | undefined>(getNextUrl) // Remounted post headers should display cached favicons immediately. const [imageLoaded, setImageLoaded] = useState( () => !!currentUrl && loadedFaviconUrls.has(currentUrl), )
if (!currentUrl) { return <DbBadgeIcon size={size} borderRadius={borderRadius} /> }
return ( <View style={[ a.overflow_hidden, { width: size, height: size, borderRadius, backgroundColor: t.atoms.bg_contrast_100.backgroundColor, }, ]}> {!imageLoaded ? ( <DbBadgeIcon size={size} borderRadius={borderRadius} /> ) : null} <Image key={currentUrl} source={{uri: currentUrl}} style={{ width: size, height: size, position: 'absolute', top: 0, left: 0, opacity: imageLoaded ? 1 : 0, }} accessibilityIgnoresInvertColors fadeDuration={0} onLoad={() => { loadedFaviconUrls.add(currentUrl) setImageLoaded(true) }} onError={() => { failedFaviconUrls.add(currentUrl) loadedFaviconUrls.delete(currentUrl)
const nextUrl = getNextUrl(currentUrl) setImageLoaded(!!nextUrl && loadedFaviconUrls.has(nextUrl)) setCurrentUrl(nextUrl) }} /> </View> )}
export function PdsBadgeIcon({ faviconUrl, pdsUrl, isBsky, isBridged, size, borderRadius,}: { faviconUrl?: string pdsUrl?: string isBsky: boolean isBridged: boolean size: number borderRadius?: number}) { const r = borderRadius ?? size / 5 if (isBsky) return <BskyBadgeSVG size={size} /> if (isBridged) return <FediverseBadgeSVG size={size} /> const faviconCandidates = Array.from( new Set( [faviconUrl, ...(pdsUrl ? getPdsFallbackFaviconUrls(pdsUrl) : [])].filter( Boolean, ) as string[], ), ) if (faviconCandidates.length > 0) return ( <FaviconBadgeIcon key={faviconCandidates.join('|')} size={size} borderRadius={r} faviconUrls={faviconCandidates} /> ) return <DbBadgeIcon size={size} borderRadius={r} />}