custom appview setting #22

merged
opened by whey.party targeting main
  • ports the custom-appview branch of deer. includes a new persisted setting in the deer (experimental) settings page, and also a new environment variable to set the default appview did
  • ports my own unproxy preferences which is needed for custom appviews to work as explained in this thread

i have only tested it on web. it works on web

i had to diverge from the deer custom-appview branch a bit because the original way of switching out the proxy no longer works so i had to add even more proxy overrides

Changed files
+278 -29
src
components
PostControls
env
lib
screens
Settings
state
preferences
queries
session
storage
+14 -5
src/components/PostControls/PostMenu/PostMenuItems.tsx
···
type AppBskyFeedThreadgate,
AtUri,
type BlobRef,
+
isDid,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
···
}
}
-
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') {
···
recordVideo = media as AppBskyEmbedVideo.Main
}
}
-
+
if (post.embed?.$type === 'app.bsky.embed.video#view') {
const embed = post.embed as AppBskyEmbedVideo.View
if (recordVideo) {
···
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'})))
+5
src/env/common.ts
···
*/
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
+3 -2
src/lib/api/feed/custom.ts
···
jsonStringToLex,
} from '@atproto/api'
+
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {
getAppLanguageAsContentLanguage,
getContentLanguages,
···
// 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}`,
{
···
// 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}},
+4 -2
src/lib/constants.ts
···
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
+2 -1
src/lib/react-query.tsx
···
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'
···
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,
})
+157 -1
src/screens/Settings/DeerSettings.tsx
···
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'
···
useConstellationInstance,
useSetConstellationInstance,
} from '#/state/preferences/constellation-instance'
+
import {useCustomAppViewDid} from '#/state/preferences/custom-appview-did'
import {
useDeerVerificationEnabled,
useDeerVerificationTrusted,
···
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'
···
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {SearchProfileCard} from '../Search/components/SearchProfileCard'
-
type Props = NativeStackScreenProps<CommonNavigatorParams>
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 (
+
<Dialog.Outer
+
control={control}
+
nativeOptions={{preventExpansion: true}}
+
onClose={() => setDid('')}>
+
<Dialog.Handle />
+
<Dialog.ScrollableInner label={_(msg`Custom AppView Proxy DID`)}>
+
<View style={[a.gap_sm, a.pb_lg]}>
+
<Text style={[a.text_2xl, a.font_bold]}>
+
<Trans>Custom AppView Proxy DID</Trans>
+
</Text>
+
</View>
+
+
<View style={a.gap_lg}>
+
<Dialog.Input
+
label="Text input field"
+
autoFocus
+
style={[styles.textInput, pal.border, pal.text]}
+
onChangeText={value => {
+
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) && (
+
<View>
+
<ErrorMessage message={_(msg`must enter a DID`)} />
+
</View>
+
)}
+
+
{did && (did.includes('#') || did.includes('?')) && (
+
<View>
+
<ErrorMessage message={_(msg`don't include the service id`)} />
+
</View>
+
)}
+
+
{doc.isError && (
+
<View>
+
<ErrorMessage
+
message={
+
doc.error.message || _(msg`document resolution failure`)
+
}
+
/>
+
</View>
+
)}
+
+
{doc.data &&
+
!bskyAppViewService &&
+
(doc.data as {message?: string}).message && (
+
<View>
+
<ErrorMessage
+
message={(doc.data as {message: string}).message}
+
/>
+
</View>
+
)}
+
+
{doc.data && !bskyAppViewService && (
+
<View>
+
<ErrorMessage
+
message={_(msg`document doesn't contain #bsky_appview service`)}
+
/>
+
</View>
+
)}
+
+
{bskyAppViewService && (
+
<Text style={[a.text_sm, a.leading_snug]}>
+
{JSON.stringify(bskyAppViewService, null, 2)}
+
</Text>
+
)}
+
+
<View style={isWeb && [a.flex_row, a.justify_end]}>
+
<Button
+
label={_(msg`Save`)}
+
size="large"
+
onPress={submit}
+
variant="solid"
+
color={did.length > 0 ? 'primary' : 'secondary'}
+
disabled={
+
did.length !== 0 && !bskyAppViewService?.serviceEndpoint
+
}>
+
<ButtonText>
+
{did.length > 0 ? <Trans>Save</Trans> : <Trans>Reset</Trans>}
+
</ButtonText>
+
</Button>
+
</View>
+
</View>
+
+
<Dialog.Close />
+
</Dialog.ScrollableInner>
+
</Dialog.Outer>
+
)
+
}
+
function TrustedVerifiersDialog({
control,
}: {
···
[gate]: value,
})
}
+
const [customAppViewDid] = useCustomAppViewDid()
+
const setCustomAppViewDidControl = Dialog.useDialogControl()
return (
<Layout.Screen>
···
</Admonition>
</SettingsList.Item>
+
<SettingsList.Item>
+
<SettingsList.ItemIcon icon={StarIcon} />
+
<SettingsList.ItemText>
+
<Trans>{`Custom AppView DID`}</Trans>
+
</SettingsList.ItemText>
+
<SettingsList.BadgeButton
+
label={customAppViewDid ? _(msg`Set`) : _(msg`Change`)}
+
onPress={() => setCustomAppViewDidControl.open()}
+
/>
+
</SettingsList.Item>
+
<SettingsList.Item>
+
<Admonition type="info" style={[a.flex_1]}>
+
<Trans>
+
Restart app after changing your AppView.
+
{customAppViewDid && _(` Currently ${customAppViewDid}`)}
+
</Trans>
+
</Admonition>
+
</SettingsList.Item>
+
<SettingsList.Group contentContainerStyle={[a.gap_sm]}>
<SettingsList.ItemIcon icon={PaintRollerIcon} />
<SettingsList.ItemText>
···
</SettingsList.Container>
</Layout.Content>
<ConstellationInstanceDialog control={setConstellationInstanceControl} />
+
<CustomAppViewDidDialog control={setCustomAppViewDidControl} />
<TrustedVerifiersDialog control={setTrustedVerifiersDialogControl} />
</Layout.Screen>
)
+21
src/state/preferences/custom-appview-did.tsx
···
+
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`
+
}
+58 -16
src/state/queries/resolve-identity.ts
···
+
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<Did, DidDocument>()
-
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<DidDocument | undefined>({
+
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('?')),
})
}
+1
src/storage/schema.ts
···
deerGateCache: string
activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
+
customAppViewDid: string | undefined
/**
* Policy update overlays. New IDs are required for each new announcement.
+1 -1
src/state/queries/preferences/index.ts
···
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'
+12 -1
src/state/session/index.tsx
···
-
import React from 'react'
+
import React, {useMemo} from 'react'
import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
import {isWeb} from '#/platform/detection'
···
}
return agent
}
+
+
export function useBlankPrefAuthedAgent(): BskyAgent {
+
const agent = React.useContext(AgentContext)
+
if (!agent) {
+
throw Error('useAgent() must be below <SessionProvider>.')
+
}
+
+
return useMemo(() => {
+
return (agent as BskyAppAgent).cloneWithoutProxy()
+
}, [agent])
+
}