From 3a7f9c6db8b0c0e73f762f8ee6986afee2389c97 Mon Sep 17 00:00:00 2001 From: "whey.party" Date: Thu, 4 Dec 2025 14:33:47 +0100 Subject: [PATCH 1/2] add deer custom-appview --- .../PostControls/PostMenu/PostMenuItems.tsx | 19 ++- src/env/common.ts | 5 + src/lib/api/feed/custom.ts | 5 +- src/lib/constants.ts | 6 +- src/lib/react-query.tsx | 3 +- src/screens/Settings/DeerSettings.tsx | 158 +++++++++++++++++- src/state/preferences/custom-appview-did.tsx | 21 +++ src/state/queries/resolve-identity.ts | 74 ++++++-- src/state/session/agent.ts | 22 ++- src/storage/schema.ts | 1 + 10 files changed, 283 insertions(+), 31 deletions(-) create mode 100644 src/state/preferences/custom-appview-did.tsx diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index cb9df6112..9622eaf0e 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -17,6 +17,7 @@ import { type AppBskyFeedThreadgate, AtUri, type BlobRef, + isDid, type RichText as RichTextAPI, } from '@atproto/api' import {msg} from '@lingui/macro' @@ -313,9 +314,17 @@ let PostMenuItems = ({ } } - let videoUri: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} | undefined + let videoUri: + | { + uri: string + width: number + height: number + blobRef?: BlobRef + altText?: string + } + | undefined let recordVideo: AppBskyEmbedVideo.Main | undefined - + if (recordEmbed?.$type === 'app.bsky.embed.video') { recordVideo = recordEmbed as AppBskyEmbedVideo.Main } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { @@ -324,7 +333,7 @@ let PostMenuItems = ({ recordVideo = media as AppBskyEmbedVideo.Main } } - + if (post.embed?.$type === 'app.bsky.embed.video#view') { const embed = post.embed as AppBskyEmbedVideo.View if (recordVideo) { @@ -569,8 +578,8 @@ let PostMenuItems = ({ if (!videoEmbed) return const did = post.author.did const cid = videoEmbed.cid - if (!did.startsWith('did:')) return - const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) + if (!isDid(did)) return + const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}:${string}`) const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` Toast.show(_(msg({message: 'Downloading video...', context: 'toast'}))) diff --git a/src/env/common.ts b/src/env/common.ts index c6ce4b297..5e5d70171 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -111,3 +111,8 @@ export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL */ export const BAPP_CONFIG_DEV_BYPASS_SECRET: string = process.env.BAPP_CONFIG_DEV_BYPASS_SECRET + +export const ENV_PUBLIC_BSKY_SERVICE: string | undefined = + process.env.EXPO_PUBLIC_PUBLIC_BSKY_SERVICE +export const ENV_APPVIEW_DID_PROXY: `did:${string}#bsky_appview` | undefined = + process.env.EXPO_PUBLIC_APPVIEW_DID_PROXY diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 18bb8c8f0..d6bea2c79 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -5,6 +5,7 @@ import { jsonStringToLex, } from '@atproto/api' +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import { getAppLanguageAsContentLanguage, getContentLanguages, @@ -120,7 +121,7 @@ async function loggedOutFetch({ // manually construct fetch call so we can add the `lang` cache-busting param let res = await fetch( - `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ + `${PUBLIC_BSKY_SERVICE}/xrpc/app.bsky.feed.getFeed?feed=${feed}${ cursor ? `&cursor=${cursor}` : '' }&limit=${limit}&lang=${contentLangs}`, { @@ -140,7 +141,7 @@ async function loggedOutFetch({ // no data, try again with language headers removed res = await fetch( - `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ + `${PUBLIC_BSKY_SERVICE}/xrpc/app.bsky.feed.getFeed?feed=${feed}${ cursor ? `&cursor=${cursor}` : '' }&limit=${limit}`, {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}}, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c63670929..63eff05ab 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,18 +3,20 @@ import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api' import {type ProxyHeaderValue} from '#/state/session/agent' import {BLUESKY_PROXY_DID, CHAT_PROXY_DID} from '#/env' - +import {ENV_APPVIEW_DID_PROXY, ENV_PUBLIC_BSKY_SERVICE} from '#/env' export const LOCAL_DEV_SERVICE = Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' export const STAGING_SERVICE = 'https://staging.bsky.dev' export const BSKY_SERVICE = 'https://bsky.social' export const BSKY_SERVICE_DID = 'did:web:bsky.social' -export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app' +export const PUBLIC_BSKY_SERVICE = + ENV_PUBLIC_BSKY_SERVICE || 'https://public.api.bsky.app' export const DEFAULT_SERVICE = BSKY_SERVICE export const HELP_DESK_URL = `https://tangled.org/jollywhoppers.com/witchsky.app/` export const EMBED_SERVICE = 'https://embed.bsky.app' export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download' +export const APPVIEW_DID_PROXY = ENV_APPVIEW_DID_PROXY export const STARTER_PACK_MAX_SIZE = 150 export const CARD_ASPECT_RATIO = 1200 / 630 diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index fe3ec6f4c..cb3e2102e 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -11,6 +11,7 @@ import type React from 'react' import {isNative} from '#/platform/detection' import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' +import {PUBLIC_BSKY_SERVICE} from './constants' // any query keys in this array will be persisted to AsyncStorage export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' @@ -22,7 +23,7 @@ async function checkIsOnline(): Promise { setTimeout(() => { controller.abort() }, 15e3) - const res = await fetch('https://public.api.bsky.app/xrpc/_health', { + const res = await fetch(`${PUBLIC_BSKY_SERVICE}/xrpc/_health`, { cache: 'no-store', signal: controller.signal, }) diff --git a/src/screens/Settings/DeerSettings.tsx b/src/screens/Settings/DeerSettings.tsx index acb375939..7e8ac1f16 100644 --- a/src/screens/Settings/DeerSettings.tsx +++ b/src/screens/Settings/DeerSettings.tsx @@ -1,10 +1,12 @@ import {useState} from 'react' import {View} from 'react-native' +import {isDid} from '@atproto/api' import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {type NativeStackScreenProps} from '@react-navigation/native-stack' +import {APPVIEW_DID_PROXY} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {type CommonNavigatorParams} from '#/lib/routes/types' import {type Gate} from '#/lib/statsig/gates' @@ -20,6 +22,7 @@ import { useConstellationInstance, useSetConstellationInstance, } from '#/state/preferences/constellation-instance' +import {useCustomAppViewDid} from '#/state/preferences/custom-appview-did' import { useDeerVerificationEnabled, useDeerVerificationTrusted, @@ -107,6 +110,8 @@ import { useShowLinkInHandle, } from '#/state/preferences/show-link-in-handle.tsx' import {useProfilesQuery} from '#/state/queries/profile' +import {findService, useDidDocument} from '#/state/queries/resolve-identity' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useBreakpoints} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -124,7 +129,6 @@ import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/ico import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' import {SearchProfileCard} from '../Search/components/SearchProfileCard' - type Props = NativeStackScreenProps function ConstellationInstanceDialog({ @@ -201,6 +205,136 @@ function ConstellationInstanceDialog({ ) } +function CustomAppViewDidDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const pal = usePalette('default') + const {_} = useLingui() + + const [did, setDid] = useState('') + const [, setCustomAppViewDid] = useCustomAppViewDid() + + const doc = useDidDocument({did}) + const bskyAppViewService = + doc.data && findService(doc.data, '#bsky_appview', 'BskyAppView') + + const submit = () => { + if (did.length === 0) { + setCustomAppViewDid(undefined) + control.close() + return + } + if (!bskyAppViewService?.serviceEndpoint) return + setCustomAppViewDid(did) + control.close() + } + + return ( + setDid('')}> + + + + + Custom AppView Proxy DID + + + + + { + setDid(value) + }} + placeholder={ + APPVIEW_DID_PROXY?.substring(0, APPVIEW_DID_PROXY.indexOf('#')) || + `did:web:api.bsky.app` + } + placeholderTextColor={pal.colors.textLight} + onSubmitEditing={submit} + accessibilityHint={_( + msg`Input the DID of the AppView to proxy requests through`, + )} + isInvalid={ + !!did && !bskyAppViewService?.serviceEndpoint && !doc.isLoading + } + /> + + {did && !isDid(did) && ( + + + + )} + + {did && (did.includes('#') || did.includes('?')) && ( + + + + )} + + {doc.isError && ( + + + + )} + + {doc.data && + !bskyAppViewService && + (doc.data as {message?: string}).message && ( + + + + )} + + {doc.data && !bskyAppViewService && ( + + + + )} + + {bskyAppViewService && ( + + {JSON.stringify(bskyAppViewService, null, 2)} + + )} + + + + + + + + + + ) +} + function TrustedVerifiersDialog({ control, }: { @@ -335,6 +469,8 @@ export function DeerSettingsScreen({}: Props) { [gate]: value, }) } + const [customAppViewDid] = useCustomAppViewDid() + const setCustomAppViewDidControl = Dialog.useDialogControl() return ( @@ -479,6 +615,25 @@ export function DeerSettingsScreen({}: Props) { + + + + {`Custom AppView DID`} + + setCustomAppViewDidControl.open()} + /> + + + + + Restart app after changing your AppView. + {customAppViewDid && _(` Currently ${customAppViewDid}`)} + + + + @@ -801,6 +956,7 @@ export function DeerSettingsScreen({}: Props) { + ) diff --git a/src/state/preferences/custom-appview-did.tsx b/src/state/preferences/custom-appview-did.tsx new file mode 100644 index 000000000..634b0088d --- /dev/null +++ b/src/state/preferences/custom-appview-did.tsx @@ -0,0 +1,21 @@ +import {isDid} from '@atproto/api' + +import {device, useStorage} from '#/storage' + +export function useCustomAppViewDid() { + const [customAppViewDid = undefined, setCustomAppViewDid] = useStorage( + device, + ['customAppViewDid'], + ) + + return [customAppViewDid, setCustomAppViewDid] as const +} + +export function readCustomAppViewDidUri() { + const maybeDid = device.get(['customAppViewDid']) + if (!maybeDid || !isDid(maybeDid)) { + return undefined + } + + return `${maybeDid}#bsky_appview` as `did:${string}#bsky_appview` +} diff --git a/src/state/queries/resolve-identity.ts b/src/state/queries/resolve-identity.ts index 74fb92404..f2409e390 100644 --- a/src/state/queries/resolve-identity.ts +++ b/src/state/queries/resolve-identity.ts @@ -1,26 +1,68 @@ +import {type Did, isDid} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' + +import {STALE} from '.' import {LRU} from './direct-fetch-record' +const RQKEY_ROOT = 'resolve-identity' +export const RQKEY = (did: string) => [RQKEY_ROOT, did] + +// this isn't trusted... +export type DidDocument = { + '@context'?: string[] + id?: string + alsoKnownAs?: string[] + verificationMethod?: VerificationMethod[] + service?: Service[] +} + +export type VerificationMethod = { + id?: string + type?: string + controller?: string + publicKeyMultibase?: string +} + +export type Service = { + id?: string + type?: string + serviceEndpoint?: string +} -const serviceCache = new LRU<`did:${string}`, string>() +const serviceCache = new LRU() -export async function resolvePdsServiceUrl(did: `did:${string}`) { +export async function resolveDidDocument(did: Did) { return await serviceCache.getOrTryInsertWith(did, async () => { const docUrl = did.startsWith('did:plc:') ? `https://plc.directory/${did}` : `https://${did.substring(8)}/.well-known/did.json` - // TODO: validate! - const doc: { - service: { - serviceEndpoint: string - type: string - }[] - } = await (await fetch(docUrl)).json() - const service = doc.service.find( - s => s.type === 'AtprotoPersonalDataServer', - )?.serviceEndpoint - - if (service === undefined) - throw new Error(`could not find a service for ${did}`) - return service + // TODO: we should probably validate this... + return await (await fetch(docUrl)).json() + }) +} + +export function findService(doc: DidDocument, id: string, type?: string) { + // probably not defensive enough, but we don't have atproto/did as a dep... + if (!Array.isArray(doc?.service)) return + return doc.service.find( + s => s?.serviceEndpoint && s?.id === id && (!type || s?.type === type), + ) +} + +export async function resolvePdsServiceUrl(did: Did) { + const doc = await resolveDidDocument(did) + return findService(doc, '#atproto_pds', 'AtprotoPersonalDataServer') + ?.serviceEndpoint +} + +export function useDidDocument({did}: {did: string}) { + return useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY(did || ''), + async queryFn() { + if (!isDid(did)) return undefined + return await resolveDidDocument(did) + }, + enabled: isDid(did) && !(did.includes('#') || did.includes('?')), }) } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 36d19299b..becd5c082 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -13,6 +13,7 @@ import {type FetchHandlerOptions} from '@atproto/xrpc' import {networkRetry} from '#/lib/async/retry' import { + APPVIEW_DID_PROXY, BLUESKY_PROXY_HEADER, BSKY_SERVICE, DISCOVER_SAVED_FEED, @@ -25,6 +26,7 @@ import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import {emitNetworkConfirmed, emitNetworkLost} from '../events' +import {readCustomAppViewDidUri} from '../preferences/custom-appview-did' import {addSessionErrorLog} from './logging' import { configureModerationForAccount, @@ -39,7 +41,9 @@ export function createPublicAgent() { configureModerationForGuest() // Side effect but only relevant for tests const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE}) - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) + const proxyDid = + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY + agent.configureProxy(proxyDid) return agent } @@ -77,7 +81,9 @@ export async function createAgentAndResume( } } - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) + const proxyDid = + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY + agent.configureProxy(proxyDid) return agent.prepare(gates, moderation, onSessionChange) } @@ -112,7 +118,9 @@ export async function createAgentAndLogin( const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) + const proxyDid = + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY + agent.configureProxy(proxyDid) return agent.prepare(gates, moderation, onSessionChange) } @@ -201,7 +209,9 @@ export async function createAgentAndCreateAccount( logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`}) } - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) + const proxyDid = + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY + agent.configureProxy(proxyDid) return agent.prepare(gates, moderation, onSessionChange) } @@ -304,6 +314,10 @@ class BskyAppAgent extends BskyAgent { } }, }) + const proxyDid = readCustomAppViewDidUri() || APPVIEW_DID_PROXY + if (proxyDid) { + this.configureProxy(proxyDid) + } } async prepare( diff --git a/src/storage/schema.ts b/src/storage/schema.ts index d21fe9ab4..6eb3ca82e 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -41,6 +41,7 @@ export type Device = { deerGateCache: string activitySubscriptionsNudged?: boolean threadgateNudged?: boolean + customAppViewDid: string | undefined /** * Policy update overlays. New IDs are required for each new announcement. -- 2.47.3 From bc16ed18219e59df6da09bc04f30a49a0d6f31ca Mon Sep 17 00:00:00 2001 From: "whey.party" Date: Thu, 4 Dec 2025 17:44:46 +0100 Subject: [PATCH 2/2] unproxy preferences --- src/state/queries/preferences/index.ts | 2 +- src/state/session/agent.ts | 6 ++++++ src/state/session/index.tsx | 13 ++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index fd1d70d7d..07195996e 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -22,7 +22,7 @@ import { type ThreadViewPreferences, type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' -import {useAgent} from '#/state/session' +import {useBlankPrefAuthedAgent as useAgent} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' export * from '#/state/queries/preferences/const' diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index becd5c082..abff0dc6b 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -348,6 +348,12 @@ class BskyAppAgent extends BskyAgent { this.sessionManager.session = undefined this.persistSessionHandler = undefined } + + cloneWithoutProxy(): BskyAgent { + const cloned = new BskyAgent({service: this.serviceUrl.toString()}) + cloned.sessionManager.session = this.sessionManager.session + return cloned + } } export type {BskyAppAgent} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 71c9fbb7a..a18e372c4 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useMemo} from 'react' import {type AtpSessionEvent, type BskyAgent} from '@atproto/api' import {isWeb} from '#/platform/detection' @@ -390,3 +390,14 @@ export function useAgent(): BskyAgent { } return agent } + +export function useBlankPrefAuthedAgent(): BskyAgent { + const agent = React.useContext(AgentContext) + if (!agent) { + throw Error('useAgent() must be below .') + } + + return useMemo(() => { + return (agent as BskyAppAgent).cloneWithoutProxy() + }, [agent]) +} -- 2.47.3