Bluesky app fork with some witchin' additions 💫

add deer custom-appview

authored by whey.party and committed by Tangled 6b305f26 bd05b175

Changed files
+282 -30
src
components
PostControls
env
lib
screens
Settings
state
storage
+14 -5
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 17 17 type AppBskyFeedThreadgate, 18 18 AtUri, 19 19 type BlobRef, 20 + isDid, 20 21 type RichText as RichTextAPI, 21 22 } from '@atproto/api' 22 23 import {msg} from '@lingui/macro' ··· 313 314 } 314 315 } 315 316 316 - let videoUri: {uri: string; width: number; height: number; blobRef?: BlobRef; altText?: string} | undefined 317 + let videoUri: 318 + | { 319 + uri: string 320 + width: number 321 + height: number 322 + blobRef?: BlobRef 323 + altText?: string 324 + } 325 + | undefined 317 326 let recordVideo: AppBskyEmbedVideo.Main | undefined 318 - 327 + 319 328 if (recordEmbed?.$type === 'app.bsky.embed.video') { 320 329 recordVideo = recordEmbed as AppBskyEmbedVideo.Main 321 330 } else if (recordEmbed?.$type === 'app.bsky.embed.recordWithMedia') { ··· 324 333 recordVideo = media as AppBskyEmbedVideo.Main 325 334 } 326 335 } 327 - 336 + 328 337 if (post.embed?.$type === 'app.bsky.embed.video#view') { 329 338 const embed = post.embed as AppBskyEmbedVideo.View 330 339 if (recordVideo) { ··· 569 578 if (!videoEmbed) return 570 579 const did = post.author.did 571 580 const cid = videoEmbed.cid 572 - if (!did.startsWith('did:')) return 573 - const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}`) 581 + if (!isDid(did)) return 582 + const pdsUrl = await resolvePdsServiceUrl(did as `did:${string}:${string}`) 574 583 const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` 575 584 576 585 Toast.show(_(msg({message: 'Downloading video...', context: 'toast'})))
+5
src/env/common.ts
··· 111 111 */ 112 112 export const BAPP_CONFIG_DEV_BYPASS_SECRET: string = 113 113 process.env.BAPP_CONFIG_DEV_BYPASS_SECRET 114 + 115 + export const ENV_PUBLIC_BSKY_SERVICE: string | undefined = 116 + process.env.EXPO_PUBLIC_PUBLIC_BSKY_SERVICE 117 + export const ENV_APPVIEW_DID_PROXY: `did:${string}#bsky_appview` | undefined = 118 + process.env.EXPO_PUBLIC_APPVIEW_DID_PROXY
+3 -2
src/lib/api/feed/custom.ts
··· 5 5 jsonStringToLex, 6 6 } from '@atproto/api' 7 7 8 + import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' 8 9 import { 9 10 getAppLanguageAsContentLanguage, 10 11 getContentLanguages, ··· 120 121 121 122 // manually construct fetch call so we can add the `lang` cache-busting param 122 123 let res = await fetch( 123 - `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ 124 + `${PUBLIC_BSKY_SERVICE}/xrpc/app.bsky.feed.getFeed?feed=${feed}${ 124 125 cursor ? `&cursor=${cursor}` : '' 125 126 }&limit=${limit}&lang=${contentLangs}`, 126 127 { ··· 140 141 141 142 // no data, try again with language headers removed 142 143 res = await fetch( 143 - `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ 144 + `${PUBLIC_BSKY_SERVICE}/xrpc/app.bsky.feed.getFeed?feed=${feed}${ 144 145 cursor ? `&cursor=${cursor}` : '' 145 146 }&limit=${limit}`, 146 147 {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}},
+4 -2
src/lib/constants.ts
··· 3 3 4 4 import {type ProxyHeaderValue} from '#/state/session/agent' 5 5 import {BLUESKY_PROXY_DID, CHAT_PROXY_DID} from '#/env' 6 - 6 + import {ENV_APPVIEW_DID_PROXY, ENV_PUBLIC_BSKY_SERVICE} from '#/env' 7 7 export const LOCAL_DEV_SERVICE = 8 8 Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' 9 9 export const STAGING_SERVICE = 'https://staging.bsky.dev' 10 10 export const BSKY_SERVICE = 'https://bsky.social' 11 11 export const BSKY_SERVICE_DID = 'did:web:bsky.social' 12 - export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app' 12 + export const PUBLIC_BSKY_SERVICE = 13 + ENV_PUBLIC_BSKY_SERVICE || 'https://public.api.bsky.app' 13 14 export const DEFAULT_SERVICE = BSKY_SERVICE 14 15 export const HELP_DESK_URL = `https://tangled.org/jollywhoppers.com/witchsky.app/` 15 16 export const EMBED_SERVICE = 'https://embed.bsky.app' 16 17 export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` 17 18 export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download' 19 + export const APPVIEW_DID_PROXY = ENV_APPVIEW_DID_PROXY 18 20 export const STARTER_PACK_MAX_SIZE = 150 19 21 export const CARD_ASPECT_RATIO = 1200 / 630 20 22
+2 -1
src/lib/react-query.tsx
··· 11 11 12 12 import {isNative} from '#/platform/detection' 13 13 import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events' 14 + import {PUBLIC_BSKY_SERVICE} from './constants' 14 15 15 16 // any query keys in this array will be persisted to AsyncStorage 16 17 export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' ··· 22 23 setTimeout(() => { 23 24 controller.abort() 24 25 }, 15e3) 25 - const res = await fetch('https://public.api.bsky.app/xrpc/_health', { 26 + const res = await fetch(`${PUBLIC_BSKY_SERVICE}/xrpc/_health`, { 26 27 cache: 'no-store', 27 28 signal: controller.signal, 28 29 })
+157 -1
src/screens/Settings/DeerSettings.tsx
··· 1 1 import {useState} from 'react' 2 2 import {View} from 'react-native' 3 + import {isDid} from '@atproto/api' 3 4 import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 4 5 import {msg, Trans} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' 6 7 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 7 8 9 + import {APPVIEW_DID_PROXY} from '#/lib/constants' 8 10 import {usePalette} from '#/lib/hooks/usePalette' 9 11 import {type CommonNavigatorParams} from '#/lib/routes/types' 10 12 import {type Gate} from '#/lib/statsig/gates' ··· 20 22 useConstellationInstance, 21 23 useSetConstellationInstance, 22 24 } from '#/state/preferences/constellation-instance' 25 + import {useCustomAppViewDid} from '#/state/preferences/custom-appview-did' 23 26 import { 24 27 useDeerVerificationEnabled, 25 28 useDeerVerificationTrusted, ··· 107 110 useShowLinkInHandle, 108 111 } from '#/state/preferences/show-link-in-handle.tsx' 109 112 import {useProfilesQuery} from '#/state/queries/profile' 113 + import {findService, useDidDocument} from '#/state/queries/resolve-identity' 114 + import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 110 115 import * as SettingsList from '#/screens/Settings/components/SettingsList' 111 116 import {atoms as a, useBreakpoints} from '#/alf' 112 117 import {Admonition} from '#/components/Admonition' ··· 124 129 import * as Layout from '#/components/Layout' 125 130 import {Text} from '#/components/Typography' 126 131 import {SearchProfileCard} from '../Search/components/SearchProfileCard' 127 - 128 132 type Props = NativeStackScreenProps<CommonNavigatorParams> 129 133 130 134 function ConstellationInstanceDialog({ ··· 201 205 ) 202 206 } 203 207 208 + function CustomAppViewDidDialog({ 209 + control, 210 + }: { 211 + control: Dialog.DialogControlProps 212 + }) { 213 + const pal = usePalette('default') 214 + const {_} = useLingui() 215 + 216 + const [did, setDid] = useState('') 217 + const [, setCustomAppViewDid] = useCustomAppViewDid() 218 + 219 + const doc = useDidDocument({did}) 220 + const bskyAppViewService = 221 + doc.data && findService(doc.data, '#bsky_appview', 'BskyAppView') 222 + 223 + const submit = () => { 224 + if (did.length === 0) { 225 + setCustomAppViewDid(undefined) 226 + control.close() 227 + return 228 + } 229 + if (!bskyAppViewService?.serviceEndpoint) return 230 + setCustomAppViewDid(did) 231 + control.close() 232 + } 233 + 234 + return ( 235 + <Dialog.Outer 236 + control={control} 237 + nativeOptions={{preventExpansion: true}} 238 + onClose={() => setDid('')}> 239 + <Dialog.Handle /> 240 + <Dialog.ScrollableInner label={_(msg`Custom AppView Proxy DID`)}> 241 + <View style={[a.gap_sm, a.pb_lg]}> 242 + <Text style={[a.text_2xl, a.font_bold]}> 243 + <Trans>Custom AppView Proxy DID</Trans> 244 + </Text> 245 + </View> 246 + 247 + <View style={a.gap_lg}> 248 + <Dialog.Input 249 + label="Text input field" 250 + autoFocus 251 + style={[styles.textInput, pal.border, pal.text]} 252 + onChangeText={value => { 253 + setDid(value) 254 + }} 255 + placeholder={ 256 + APPVIEW_DID_PROXY?.substring(0, APPVIEW_DID_PROXY.indexOf('#')) || 257 + `did:web:api.bsky.app` 258 + } 259 + placeholderTextColor={pal.colors.textLight} 260 + onSubmitEditing={submit} 261 + accessibilityHint={_( 262 + msg`Input the DID of the AppView to proxy requests through`, 263 + )} 264 + isInvalid={ 265 + !!did && !bskyAppViewService?.serviceEndpoint && !doc.isLoading 266 + } 267 + /> 268 + 269 + {did && !isDid(did) && ( 270 + <View> 271 + <ErrorMessage message={_(msg`must enter a DID`)} /> 272 + </View> 273 + )} 274 + 275 + {did && (did.includes('#') || did.includes('?')) && ( 276 + <View> 277 + <ErrorMessage message={_(msg`don't include the service id`)} /> 278 + </View> 279 + )} 280 + 281 + {doc.isError && ( 282 + <View> 283 + <ErrorMessage 284 + message={ 285 + doc.error.message || _(msg`document resolution failure`) 286 + } 287 + /> 288 + </View> 289 + )} 290 + 291 + {doc.data && 292 + !bskyAppViewService && 293 + (doc.data as {message?: string}).message && ( 294 + <View> 295 + <ErrorMessage 296 + message={(doc.data as {message: string}).message} 297 + /> 298 + </View> 299 + )} 300 + 301 + {doc.data && !bskyAppViewService && ( 302 + <View> 303 + <ErrorMessage 304 + message={_(msg`document doesn't contain #bsky_appview service`)} 305 + /> 306 + </View> 307 + )} 308 + 309 + {bskyAppViewService && ( 310 + <Text style={[a.text_sm, a.leading_snug]}> 311 + {JSON.stringify(bskyAppViewService, null, 2)} 312 + </Text> 313 + )} 314 + 315 + <View style={isWeb && [a.flex_row, a.justify_end]}> 316 + <Button 317 + label={_(msg`Save`)} 318 + size="large" 319 + onPress={submit} 320 + variant="solid" 321 + color={did.length > 0 ? 'primary' : 'secondary'} 322 + disabled={ 323 + did.length !== 0 && !bskyAppViewService?.serviceEndpoint 324 + }> 325 + <ButtonText> 326 + {did.length > 0 ? <Trans>Save</Trans> : <Trans>Reset</Trans>} 327 + </ButtonText> 328 + </Button> 329 + </View> 330 + </View> 331 + 332 + <Dialog.Close /> 333 + </Dialog.ScrollableInner> 334 + </Dialog.Outer> 335 + ) 336 + } 337 + 204 338 function TrustedVerifiersDialog({ 205 339 control, 206 340 }: { ··· 335 469 [gate]: value, 336 470 }) 337 471 } 472 + const [customAppViewDid] = useCustomAppViewDid() 473 + const setCustomAppViewDidControl = Dialog.useDialogControl() 338 474 339 475 return ( 340 476 <Layout.Screen> ··· 475 611 Constellation is used to supplement AppView responses for custom 476 612 verifications and nuclear block bypass, via backlinks. Current 477 613 instance: {constellationInstance} 614 + </Trans> 615 + </Admonition> 616 + </SettingsList.Item> 617 + 618 + <SettingsList.Item> 619 + <SettingsList.ItemIcon icon={StarIcon} /> 620 + <SettingsList.ItemText> 621 + <Trans>{`Custom AppView DID`}</Trans> 622 + </SettingsList.ItemText> 623 + <SettingsList.BadgeButton 624 + label={customAppViewDid ? _(msg`Set`) : _(msg`Change`)} 625 + onPress={() => setCustomAppViewDidControl.open()} 626 + /> 627 + </SettingsList.Item> 628 + <SettingsList.Item> 629 + <Admonition type="info" style={[a.flex_1]}> 630 + <Trans> 631 + Restart app after changing your AppView. 632 + {customAppViewDid && _(` Currently ${customAppViewDid}`)} 478 633 </Trans> 479 634 </Admonition> 480 635 </SettingsList.Item> ··· 801 956 </SettingsList.Container> 802 957 </Layout.Content> 803 958 <ConstellationInstanceDialog control={setConstellationInstanceControl} /> 959 + <CustomAppViewDidDialog control={setCustomAppViewDidControl} /> 804 960 <TrustedVerifiersDialog control={setTrustedVerifiersDialogControl} /> 805 961 </Layout.Screen> 806 962 )
+21
src/state/preferences/custom-appview-did.tsx
··· 1 + import {isDid} from '@atproto/api' 2 + 3 + import {device, useStorage} from '#/storage' 4 + 5 + export function useCustomAppViewDid() { 6 + const [customAppViewDid = undefined, setCustomAppViewDid] = useStorage( 7 + device, 8 + ['customAppViewDid'], 9 + ) 10 + 11 + return [customAppViewDid, setCustomAppViewDid] as const 12 + } 13 + 14 + export function readCustomAppViewDidUri() { 15 + const maybeDid = device.get(['customAppViewDid']) 16 + if (!maybeDid || !isDid(maybeDid)) { 17 + return undefined 18 + } 19 + 20 + return `${maybeDid}#bsky_appview` as `did:${string}#bsky_appview` 21 + }
+57 -15
src/state/queries/resolve-identity.ts
··· 1 + import {type Did, isDid} from '@atproto/api' 2 + import {useQuery} from '@tanstack/react-query' 3 + 4 + import {STALE} from '.' 1 5 import {LRU} from './direct-fetch-record' 6 + const RQKEY_ROOT = 'resolve-identity' 7 + export const RQKEY = (did: string) => [RQKEY_ROOT, did] 2 8 3 - const serviceCache = new LRU<`did:${string}`, string>() 9 + // this isn't trusted... 10 + export type DidDocument = { 11 + '@context'?: string[] 12 + id?: string 13 + alsoKnownAs?: string[] 14 + verificationMethod?: VerificationMethod[] 15 + service?: Service[] 16 + } 4 17 5 - export async function resolvePdsServiceUrl(did: `did:${string}`) { 18 + export type VerificationMethod = { 19 + id?: string 20 + type?: string 21 + controller?: string 22 + publicKeyMultibase?: string 23 + } 24 + 25 + export type Service = { 26 + id?: string 27 + type?: string 28 + serviceEndpoint?: string 29 + } 30 + 31 + const serviceCache = new LRU<Did, DidDocument>() 32 + 33 + export async function resolveDidDocument(did: Did) { 6 34 return await serviceCache.getOrTryInsertWith(did, async () => { 7 35 const docUrl = did.startsWith('did:plc:') 8 36 ? `https://plc.directory/${did}` 9 37 : `https://${did.substring(8)}/.well-known/did.json` 10 38 11 - // TODO: validate! 12 - const doc: { 13 - service: { 14 - serviceEndpoint: string 15 - type: string 16 - }[] 17 - } = await (await fetch(docUrl)).json() 18 - const service = doc.service.find( 19 - s => s.type === 'AtprotoPersonalDataServer', 20 - )?.serviceEndpoint 39 + // TODO: we should probably validate this... 40 + return await (await fetch(docUrl)).json() 41 + }) 42 + } 21 43 22 - if (service === undefined) 23 - throw new Error(`could not find a service for ${did}`) 24 - return service 44 + export function findService(doc: DidDocument, id: string, type?: string) { 45 + // probably not defensive enough, but we don't have atproto/did as a dep... 46 + if (!Array.isArray(doc?.service)) return 47 + return doc.service.find( 48 + s => s?.serviceEndpoint && s?.id === id && (!type || s?.type === type), 49 + ) 50 + } 51 + 52 + export async function resolvePdsServiceUrl(did: Did) { 53 + const doc = await resolveDidDocument(did) 54 + return findService(doc, '#atproto_pds', 'AtprotoPersonalDataServer') 55 + ?.serviceEndpoint 56 + } 57 + 58 + export function useDidDocument({did}: {did: string}) { 59 + return useQuery<DidDocument | undefined>({ 60 + staleTime: STALE.HOURS.ONE, 61 + queryKey: RQKEY(did || ''), 62 + async queryFn() { 63 + if (!isDid(did)) return undefined 64 + return await resolveDidDocument(did) 65 + }, 66 + enabled: isDid(did) && !(did.includes('#') || did.includes('?')), 25 67 }) 26 68 }
+18 -4
src/state/session/agent.ts
··· 13 13 14 14 import {networkRetry} from '#/lib/async/retry' 15 15 import { 16 + APPVIEW_DID_PROXY, 16 17 BLUESKY_PROXY_HEADER, 17 18 BSKY_SERVICE, 18 19 DISCOVER_SAVED_FEED, ··· 25 26 import {logger} from '#/logger' 26 27 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 27 28 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 29 + import {readCustomAppViewDidUri} from '../preferences/custom-appview-did' 28 30 import {addSessionErrorLog} from './logging' 29 31 import { 30 32 configureModerationForAccount, ··· 39 41 configureModerationForGuest() // Side effect but only relevant for tests 40 42 41 43 const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE}) 42 - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 44 + const proxyDid = 45 + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 46 + agent.configureProxy(proxyDid) 43 47 return agent 44 48 } 45 49 ··· 77 81 } 78 82 } 79 83 80 - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 84 + const proxyDid = 85 + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 86 + agent.configureProxy(proxyDid) 81 87 82 88 return agent.prepare(gates, moderation, onSessionChange) 83 89 } ··· 112 118 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 113 119 const moderation = configureModerationForAccount(agent, account) 114 120 115 - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 121 + const proxyDid = 122 + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 123 + agent.configureProxy(proxyDid) 116 124 117 125 return agent.prepare(gates, moderation, onSessionChange) 118 126 } ··· 201 209 logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`}) 202 210 } 203 211 204 - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 212 + const proxyDid = 213 + readCustomAppViewDidUri() || BLUESKY_PROXY_HEADER.get() || APPVIEW_DID_PROXY 214 + agent.configureProxy(proxyDid) 205 215 206 216 return agent.prepare(gates, moderation, onSessionChange) 207 217 } ··· 304 314 } 305 315 }, 306 316 }) 317 + const proxyDid = readCustomAppViewDidUri() || APPVIEW_DID_PROXY 318 + if (proxyDid) { 319 + this.configureProxy(proxyDid) 320 + } 307 321 } 308 322 309 323 async prepare(
+1
src/storage/schema.ts
··· 41 41 deerGateCache: string 42 42 activitySubscriptionsNudged?: boolean 43 43 threadgateNudged?: boolean 44 + customAppViewDid: string | undefined 44 45 45 46 /** 46 47 * Policy update overlays. New IDs are required for each new announcement.