Bluesky app fork with some witchin' additions 💫

custom user controlled verifiers (mvp)

commit cde467e57e5da3862f3394d15c6ff64c248445f5
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 04:34:58 2025 -0500

correct empty display name handling

commit 002f6bb99cebf31769c5cc628d329b86e9c63675
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 04:30:16 2025 -0500

fix list padding

commit ae984e2568a9ddf2717db02c784ac71c46312f55
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 04:15:34 2025 -0500

drop log and custom remove mutation

commit acddc1c704377e85b734bd1876e2a2cc6002a41f
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 04:07:48 2025 -0500

custom verification add mutation logic for deer

commit a4b3d31ea9ee2e6eab85cc321e654d4c76bea5e7
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 03:26:35 2025 -0500

lru cache for verification and service url

commit 6249d5bb424b8dac53c13c9e6aeb164e052dfc8d
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 03:12:15 2025 -0500

make size power of 2

commit 1dc64ebeb1158cf77cdedb32c976e3719aa7973e
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 03:05:10 2025 -0500

lru cache for did documents

commit 9f75bee6f668bac6f58415ebf3eb5d9b70f11be8
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Fri Apr 25 02:05:35 2025 -0500

simplify a bit

commit 413141195bd5ccbb39229064c61f2b6c2190c981
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 16:59:13 2025 -0500

lift verification logic to not require refetch on handle or name change

commit 27c0700e3ab8e6343af265eb5baa8b3e9d0185d9
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 16:44:05 2025 -0500

factor trusted verifiers into query key

commit b358f3d5de24412882177b1e8e0e683b61803ef7
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 16:22:58 2025 -0500

filter duplicate verifications

commit 9dbffdcf3d5e065398172e0f92ec1bda7e2fc370
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 02:04:02 2025 -0500

yay!

commit e2b9ed195acb66bf2d3b893140b816ea0846ef75
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 01:43:57 2025 -0500

allow configuring verifiers

commit 1b447da3466be249b46bc8516454b19c1f512ba2
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 01:17:51 2025 -0500

remove leafs (not needed?)

commit 949e91d4617c248e69e22c22dde1243f63a8cd19
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 01:08:52 2025 -0500

maybe viable method pt2?

commit 18a785124f9f1feabfb1f863699cebb54c5c5548
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Thu Apr 24 00:33:19 2025 -0500

maybe viable method

commit 91af3752e5cd888631018c9a34db1005847ec0e3
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 22:51:51 2025 -0500

v0.25

commit 1dd0b2f6ed05f23da24ccd20d39af64fbee480f1
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 22:34:35 2025 -0500

v0.2

commit 42fb2c22ce001e1e09c7571c34c535583eb015fe
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 21:52:57 2025 -0500

v0.1

commit a04a3c4983158147bec550c5dd5eee39d24cc546
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 21:08:25 2025 -0500

v0

commit d7218cd9373ae612143ef43aaae65da88bb3461d
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 17:33:52 2025 -0500

maybe working query?

commit de2dc7800f513a89511fa07626fc67095bed9cd7
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 16:05:22 2025 -0500

trusted list lol

commit 6966ebd76fd7df12105139046dfdfeae164652c0
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 15:51:56 2025 -0500

verification setting

commit 01fcb9d8e86449353e30aa743d4b74b2feba996e
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 14:55:56 2025 -0500

store constellations preference

commit d56d5ac4fea4a292586ac2e3d3057d479534fcc9
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 13:45:36 2025 -0500

pick the v0 constellations client

commit cd989cb2160df045ed9f190c87c41c02c6e81d64
Author: Aviva Ruben <aviva@rubenfamily.com>
Date: Wed Apr 23 13:44:44 2025 -0500

pick refactor from threading branch

+27 -22
src/components/verification/VerifierDialog.tsx
··· 7 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 8 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 9 9 import {logger} from '#/logger' 10 + import {useDeerVerificationEnabled} from '#/state/preferences/deer-verification' 10 11 import {useSession} from '#/state/session' 11 12 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 13 import {Button, ButtonText} from '#/components/Button' ··· 59 60 ? _(msg`You are a trusted verifier`) 60 61 : _(msg`${userName} is a trusted verifier`) 61 62 63 + const deerVerificationEnabled = useDeerVerificationEnabled() 64 + 62 65 return ( 63 66 <Dialog.ScrollableInner 64 67 label={label} ··· 68 71 <Dialog.Handle /> 69 72 70 73 <View style={[a.gap_lg]}> 71 - <View 72 - style={[ 73 - a.w_full, 74 - a.rounded_md, 75 - a.overflow_hidden, 76 - t.atoms.bg_contrast_25, 77 - {minHeight: 100}, 78 - ]}> 79 - <Image 80 - accessibilityIgnoresInvertColors 81 - source={require('../../../assets/images/initial_verification_announcement_1.png')} 74 + {!deerVerificationEnabled && ( 75 + <View 82 76 style={[ 83 - { 84 - aspectRatio: 353 / 160, 85 - }, 86 - ]} 87 - alt={_( 88 - msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, 89 - )} 90 - /> 91 - </View> 77 + a.w_full, 78 + a.rounded_md, 79 + a.overflow_hidden, 80 + t.atoms.bg_contrast_25, 81 + {minHeight: 100}, 82 + ]}> 83 + <Image 84 + accessibilityIgnoresInvertColors 85 + source={require('../../../assets/images/initial_verification_announcement_1.png')} 86 + style={[ 87 + { 88 + aspectRatio: 353 / 160, 89 + }, 90 + ]} 91 + alt={_( 92 + msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, 93 + )} 94 + /> 95 + </View> 96 + )} 92 97 93 98 <View style={[a.gap_sm]}> 94 99 <Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}> ··· 102 107 <VerifierCheck width={14} /> 103 108 {NON_BREAKING_SPACE} 104 109 </RNText> 105 - can verify others. These trusted verifiers are selected by 106 - Bluesky. 110 + can verify others. These trusted verifiers are selected by{' '} 111 + {deerVerificationEnabled ? 'you' : 'Bluesky'}. 107 112 </Trans> 108 113 </Text> 109 114 </View>
+4 -1
src/components/verification/index.ts
··· 1 1 import {useMemo} from 'react' 2 2 3 + import {useMaybeDeerVerificationProfileOverlay} from '#/state/queries/deer-verification' 3 4 import {usePreferencesQuery} from '#/state/queries/preferences' 4 5 import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 5 6 import {useSession} from '#/state/session' ··· 79 80 } 80 81 81 82 export function useSimpleVerificationState({ 82 - profile, 83 + profile: baseProfile, 83 84 }: { 84 85 profile?: bsky.profile.AnyProfileView 85 86 }): SimpleVerificationState { ··· 88 89 () => preferences.data?.verificationPrefs || {hideBadges: false}, 89 90 [preferences.data?.verificationPrefs], 90 91 ) 92 + const profile = useMaybeDeerVerificationProfileOverlay(baseProfile) 93 + 91 94 return useMemo(() => { 92 95 if (!profile || !profile.verification) { 93 96 return {
+169
src/screens/Settings/DeerSettings.tsx
··· 14 14 } from '#/lib/statsig/statsig' 15 15 import {isWeb} from '#/platform/detection' 16 16 import {setGeolocation, useGeolocation} from '#/state/geolocation' 17 + import * as persisted from '#/state/persisted' 17 18 import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' 18 19 import { 19 20 useConstellationEnabled, 20 21 useSetConstellationEnabled, 21 22 } from '#/state/preferences/constellation-enabled' 22 23 import { 24 + useConstellationInstance, 25 + useSetConstellationInstance, 26 + } from '#/state/preferences/constellation-instance' 27 + import { 28 + useDeerVerificationEnabled, 29 + useDeerVerificationTrusted, 30 + useSetDeerVerificationEnabled, 31 + } from '#/state/preferences/deer-verification' 32 + import { 23 33 useDirectFetchRecords, 24 34 useSetDirectFetchRecords, 25 35 } from '#/state/preferences/direct-fetch-records' ··· 27 37 useHideFollowNotifications, 28 38 useSetHideFollowNotifications, 29 39 } from '#/state/preferences/hide-follow-notifications' 40 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 30 41 import { 31 42 useNoAppLabelers, 32 43 useSetNoAppLabelers, ··· 39 50 useRepostCarouselEnabled, 40 51 useSetRepostCarouselEnabled, 41 52 } from '#/state/preferences/repost-carousel-enabled' 53 + import {useProfilesQuery} from '#/state/queries/profile' 42 54 import {TextInput} from '#/view/com/modals/util' 55 + import {List} from '#/view/com/util/List' 43 56 import * as SettingsList from '#/screens/Settings/components/SettingsList' 44 57 import {atoms as a} from '#/alf' 45 58 import {Admonition} from '#/components/Admonition' ··· 53 66 import {Lab_Stroke2_Corner0_Rounded as BeakerIcon} from '#/components/icons/Lab' 54 67 import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' 55 68 import {RaisingHand4Finger_Stroke2_Corner0_Rounded as RaisingHandIcon} from '#/components/icons/RaisingHand' 69 + import {Star_Stroke2_Corner0_Rounded as StarIcon} from '#/components/icons/Star' 70 + import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified' 56 71 import * as Layout from '#/components/Layout' 57 72 import {Text} from '#/components/Typography' 73 + import {SearchProfileCard} from '../Search/components/SearchProfileCard' 58 74 59 75 type Props = NativeStackScreenProps<CommonNavigatorParams> 60 76 ··· 123 139 ) 124 140 } 125 141 142 + function ConstellationInstanceDialog({ 143 + control, 144 + }: { 145 + control: Dialog.DialogControlProps 146 + }) { 147 + const pal = usePalette('default') 148 + const {_} = useLingui() 149 + 150 + const [url, setUrl] = useState('') 151 + const setConstellationInstance = useSetConstellationInstance() 152 + 153 + const submit = () => { 154 + setConstellationInstance(url) 155 + control.close() 156 + // need to clear since we don't set value of input and component may be reused 157 + setUrl('') 158 + } 159 + 160 + return ( 161 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 162 + <Dialog.Handle /> 163 + <Dialog.ScrollableInner label={_(msg`Constellations instance URL`)}> 164 + <View style={[a.gap_sm, a.pb_lg]}> 165 + <Text style={[a.text_2xl, a.font_bold]}> 166 + <Trans>Constellations instance URL</Trans> 167 + </Text> 168 + </View> 169 + 170 + <View style={a.gap_lg}> 171 + <TextInput 172 + accessibilityLabel="Text input field" 173 + autoFocus 174 + style={[styles.textInput, pal.border, pal.text]} 175 + onChangeText={value => { 176 + setUrl(value) 177 + }} 178 + placeholder={persisted.defaults.constellationInstance} 179 + placeholderTextColor={pal.colors.textLight} 180 + onSubmitEditing={submit} 181 + accessibilityHint={_( 182 + msg`Input the url of the constellations instance to use`, 183 + )} 184 + /> 185 + 186 + <View style={isWeb && [a.flex_row, a.justify_end]}> 187 + <Button 188 + label={_(msg`Save`)} 189 + size="large" 190 + onPress={submit} 191 + variant="solid" 192 + color="primary" 193 + disabled={!URL.canParse(url)}> 194 + <ButtonText> 195 + <Trans>Save</Trans> 196 + </ButtonText> 197 + </Button> 198 + </View> 199 + </View> 200 + 201 + <Dialog.Close /> 202 + </Dialog.ScrollableInner> 203 + </Dialog.Outer> 204 + ) 205 + } 206 + 207 + const TrustedVerifiers = (): React.ReactNode => { 208 + const trusted = useDeerVerificationTrusted() 209 + const moderationOpts = useModerationOpts() 210 + 211 + const results = useProfilesQuery({ 212 + handles: Array.from(trusted), 213 + }) 214 + 215 + return ( 216 + results.data && 217 + moderationOpts !== undefined && ( 218 + <List 219 + data={results.data.profiles} 220 + renderItem={({item}) => ( 221 + <SearchProfileCard profile={item} moderationOpts={moderationOpts} /> 222 + )} 223 + keyExtractor={item => item.did} 224 + contentContainerStyle={[a.pl_xl, a.pb_sm]} 225 + /> 226 + ) 227 + ) 228 + } 229 + 126 230 export function DeerSettingsScreen({}: Props) { 127 231 const {_} = useLingui() 128 232 ··· 146 250 147 251 const location = useGeolocation() 148 252 const setLocationControl = Dialog.useDialogControl() 253 + 254 + const constellationInstance = useConstellationInstance() 255 + const setConstellationInstanceControl = Dialog.useDialogControl() 256 + 257 + const deerVerificationEnabled = useDeerVerificationEnabled() 258 + const setDeerVerificationEnabled = useSetDeerVerificationEnabled() 149 259 150 260 const repostCarouselEnabled = useRepostCarouselEnabled() 151 261 const setRepostCarouselEnabled = useSetRepostCarouselEnabled() ··· 230 340 </Toggle.Item> 231 341 </SettingsList.Group> 232 342 343 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 344 + <SettingsList.ItemIcon icon={VerifiedIcon} /> 345 + <SettingsList.ItemText> 346 + <Trans>Verification</Trans> 347 + </SettingsList.ItemText> 348 + <Toggle.Item 349 + name="custom_verifications" 350 + label={_( 351 + msg`Select your own set of trusted verifiers, and operate as a verifier`, 352 + )} 353 + value={deerVerificationEnabled} 354 + onChange={value => setDeerVerificationEnabled(value)} 355 + style={[a.w_full]}> 356 + <Toggle.LabelText style={[a.flex_1]}> 357 + <Trans> 358 + Select your own set of trusted verifiers, and operate as a 359 + verifier 360 + </Trans> 361 + </Toggle.LabelText> 362 + <Toggle.Platform /> 363 + </Toggle.Item> 364 + </SettingsList.Group> 365 + 366 + <SettingsList.Item> 367 + <Admonition type="warning" style={[a.flex_1]}> 368 + <Trans> 369 + WIP. May slow down the client or fail to find all labels. Revoke 370 + and grant trust in the meatball menu on a profile.{' '} 371 + {deerVerificationEnabled 372 + ? 'You currently' 373 + : 'If enabled, you would'}{' '} 374 + trust the following verifiers: 375 + </Trans> 376 + </Admonition> 377 + </SettingsList.Item> 378 + 379 + <TrustedVerifiers /> 380 + 381 + <SettingsList.Item> 382 + <SettingsList.ItemIcon icon={StarIcon} /> 383 + <SettingsList.ItemText> 384 + <Trans>{`Constellation Instance`}</Trans> 385 + </SettingsList.ItemText> 386 + <SettingsList.BadgeButton 387 + label={_(msg`Change`)} 388 + onPress={() => setConstellationInstanceControl.open()} 389 + /> 390 + </SettingsList.Item> 391 + <SettingsList.Item> 392 + <Admonition type="info" style={[a.flex_1]}> 393 + <Trans> 394 + Constellation is used to supplement AppView responses for custom 395 + verifications and nuclear block bypass, via backlinks. Current 396 + instance: {constellationInstance} 397 + </Trans> 398 + </Admonition> 399 + </SettingsList.Item> 400 + 233 401 <SettingsList.Item> 234 402 <SettingsList.ItemIcon icon={GlobeIcon} /> 235 403 <SettingsList.ItemText> ··· 377 545 </SettingsList.Container> 378 546 </Layout.Content> 379 547 <GeolocationSettingsDialog control={setLocationControl} /> 548 + <ConstellationInstanceDialog control={setConstellationInstanceControl} /> 380 549 </Layout.Screen> 381 550 ) 382 551 }
+3 -1
src/state/cache/profile-shadow.ts
··· 22 22 import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' 23 23 import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' 24 24 import type * as bsky from '#/types/bsky' 25 + import {useDeerVerificationProfileOverlay} from '../queries/deer-verification' 25 26 import {castAsShadow, type Shadow} from './types' 26 27 27 28 export type {Shadow} from './types' ··· 59 60 } 60 61 }, [profile]) 61 62 62 - return useMemo(() => { 63 + const shadowed = useMemo(() => { 63 64 if (shadow) { 64 65 return mergeShadow(profile, shadow) 65 66 } else { 66 67 return castAsShadow(profile) 67 68 } 68 69 }, [profile, shadow]) 70 + return useDeerVerificationProfileOverlay(shadowed) 69 71 } 70 72 71 73 /**
+18
src/state/persisted/schema.ts
··· 132 132 noDiscoverFallback: z.boolean().optional(), 133 133 repostCarouselEnabled: z.boolean().optional(), 134 134 hideFollowNotifications: z.boolean().optional(), 135 + constellationInstance: z.string().optional(), 136 + deerVerification: z 137 + .object({ 138 + enabled: z.boolean(), 139 + trusted: z.array(z.string()), 140 + }) 141 + .optional(), 135 142 136 143 /** @deprecated */ 137 144 mutedThreads: z.array(z.string()), ··· 193 200 noDiscoverFallback: false, 194 201 repostCarouselEnabled: false, 195 202 hideFollowNotifications: false, 203 + constellationInstance: 'https://constellation.microcosm.blue/', 204 + deerVerification: { 205 + enabled: false, 206 + // https://deer.social/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k 207 + trusted: [ 208 + 'did:plc:z72i7hdynmk6r22z27h6tvur', 209 + 'did:plc:eclio37ymobqex2ncko63h4r', 210 + 'did:plc:inz4fkbbp7ms3ixufw6xuvdi', 211 + 'did:plc:b2kutgxqlltwc6lhs724cfwr', 212 + ], 213 + }, 196 214 } 197 215 198 216 export function tryParse(rawData: string): Schema | undefined {
+54
src/state/preferences/constellation-instance.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = persisted.Schema['constellationInstance'] 6 + type SetContext = (v: persisted.Schema['constellationInstance']) => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + persisted.defaults.constellationInstance, 10 + ) 11 + const setContext = React.createContext<SetContext>( 12 + (_: persisted.Schema['constellationInstance']) => {}, 13 + ) 14 + 15 + export function Provider({children}: React.PropsWithChildren<{}>) { 16 + const [state, setState] = React.useState( 17 + persisted.get('constellationInstance'), 18 + ) 19 + 20 + const setStateWrapped = React.useCallback( 21 + (constellationInstance: persisted.Schema['constellationInstance']) => { 22 + setState(constellationInstance) 23 + persisted.write('constellationInstance', constellationInstance) 24 + }, 25 + [setState], 26 + ) 27 + 28 + React.useEffect(() => { 29 + return persisted.onUpdate( 30 + 'constellationInstance', 31 + nextConstellationInstance => { 32 + setState(nextConstellationInstance) 33 + }, 34 + ) 35 + }, [setStateWrapped]) 36 + 37 + return ( 38 + <stateContext.Provider value={state}> 39 + <setContext.Provider value={setStateWrapped}> 40 + {children} 41 + </setContext.Provider> 42 + </stateContext.Provider> 43 + ) 44 + } 45 + 46 + export function useConstellationInstance() { 47 + return ( 48 + React.useContext(stateContext) ?? persisted.defaults.constellationInstance! 49 + ) 50 + } 51 + 52 + export function useSetConstellationInstance() { 53 + return React.useContext(setContext) 54 + }
+93
src/state/preferences/deer-verification.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = persisted.Schema['deerVerification'] 6 + type SetContext = (v: persisted.Schema['deerVerification']) => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + persisted.defaults.deerVerification, 10 + ) 11 + const setContext = React.createContext<SetContext>( 12 + (_: persisted.Schema['deerVerification']) => {}, 13 + ) 14 + 15 + export function Provider({children}: React.PropsWithChildren<{}>) { 16 + const [state, setState] = React.useState(persisted.get('deerVerification')) 17 + 18 + const setStateWrapped = React.useCallback( 19 + (deerVerification: persisted.Schema['deerVerification']) => { 20 + setState(deerVerification) 21 + persisted.write('deerVerification', deerVerification) 22 + }, 23 + [setState], 24 + ) 25 + 26 + React.useEffect(() => { 27 + return persisted.onUpdate('deerVerification', nextDeerVerification => { 28 + setState(nextDeerVerification) 29 + }) 30 + }, [setStateWrapped]) 31 + 32 + return ( 33 + <stateContext.Provider value={state}> 34 + <setContext.Provider value={setStateWrapped}> 35 + {children} 36 + </setContext.Provider> 37 + </stateContext.Provider> 38 + ) 39 + } 40 + 41 + export function useDeerVerification() { 42 + return React.useContext(stateContext) ?? persisted.defaults.deerVerification! 43 + } 44 + 45 + export function useDeerVerificationEnabled() { 46 + return useDeerVerification().enabled 47 + } 48 + 49 + export function useDeerVerificationTrusted( 50 + mandatory: string | undefined = undefined, 51 + ) { 52 + const trusted = new Set(useDeerVerification().trusted) 53 + if (mandatory) { 54 + trusted.add(mandatory) 55 + } 56 + return trusted 57 + } 58 + 59 + export function useSetDeerVerification() { 60 + return React.useContext(setContext) 61 + } 62 + 63 + export function useSetDeerVerificationEnabled() { 64 + const deerVerification = useDeerVerification() 65 + const setDeerVerification = useSetDeerVerification() 66 + 67 + return React.useMemo( 68 + () => (enabled: boolean) => 69 + setDeerVerification({...deerVerification, enabled}), 70 + [deerVerification, setDeerVerification], 71 + ) 72 + } 73 + 74 + export function useSetDeerVerificationTrust() { 75 + const deerVerification = useDeerVerification() 76 + const setDeerVerification = useSetDeerVerification() 77 + 78 + return React.useMemo( 79 + () => ({ 80 + add: (add: string) => { 81 + const trusted = new Set(deerVerification.trusted) 82 + trusted.add(add) 83 + setDeerVerification({...deerVerification, trusted: Array.from(trusted)}) 84 + }, 85 + remove: (remove: string) => { 86 + const trusted = new Set(deerVerification.trusted) 87 + trusted.delete(remove) 88 + setDeerVerification({...deerVerification, trusted: Array.from(trusted)}) 89 + }, 90 + }), 91 + [deerVerification, setDeerVerification], 92 + ) 93 + }
+31 -23
src/state/preferences/index.tsx
··· 3 3 import {Provider as AltTextRequiredProvider} from './alt-text-required' 4 4 import {Provider as AutoplayProvider} from './autoplay' 5 5 import {Provider as ConstellationProvider} from './constellation-enabled' 6 + import {Provider as ConstellationInstanceProvider} from './constellation-instance' 7 + import {Provider as DeerVerificationProvider} from './deer-verification' 6 8 import {Provider as DirectFetchRecordsProvider} from './direct-fetch-records' 7 9 import {Provider as DisableHapticsProvider} from './disable-haptics' 8 10 import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' ··· 45 47 <FollowNotificationsProvider> 46 48 <DirectFetchRecordsProvider> 47 49 <ConstellationProvider> 48 - <NoDiscoverProvider> 49 - <LargeAltBadgeProvider> 50 - <ExternalEmbedsProvider> 51 - <HiddenPostsProvider> 52 - <InAppBrowserProvider> 53 - <DisableHapticsProvider> 54 - <AutoplayProvider> 55 - <UsedStarterPacksProvider> 56 - <SubtitlesProvider> 57 - <TrendingSettingsProvider> 58 - <RepostCarouselProvider> 59 - <KawaiiProvider>{children}</KawaiiProvider> 60 - </RepostCarouselProvider> 61 - </TrendingSettingsProvider> 62 - </SubtitlesProvider> 63 - </UsedStarterPacksProvider> 64 - </AutoplayProvider> 65 - </DisableHapticsProvider> 66 - </InAppBrowserProvider> 67 - </HiddenPostsProvider> 68 - </ExternalEmbedsProvider> 69 - </LargeAltBadgeProvider> 70 - </NoDiscoverProvider> 50 + <ConstellationInstanceProvider> 51 + <DeerVerificationProvider> 52 + <NoDiscoverProvider> 53 + <LargeAltBadgeProvider> 54 + <ExternalEmbedsProvider> 55 + <HiddenPostsProvider> 56 + <InAppBrowserProvider> 57 + <DisableHapticsProvider> 58 + <AutoplayProvider> 59 + <UsedStarterPacksProvider> 60 + <SubtitlesProvider> 61 + <TrendingSettingsProvider> 62 + <RepostCarouselProvider> 63 + <KawaiiProvider> 64 + {children} 65 + </KawaiiProvider> 66 + </RepostCarouselProvider> 67 + </TrendingSettingsProvider> 68 + </SubtitlesProvider> 69 + </UsedStarterPacksProvider> 70 + </AutoplayProvider> 71 + </DisableHapticsProvider> 72 + </InAppBrowserProvider> 73 + </HiddenPostsProvider> 74 + </ExternalEmbedsProvider> 75 + </LargeAltBadgeProvider> 76 + </NoDiscoverProvider> 77 + </DeerVerificationProvider> 78 + </ConstellationInstanceProvider> 71 79 </ConstellationProvider> 72 80 </DirectFetchRecordsProvider> 73 81 </FollowNotificationsProvider>
+194
src/state/queries/constellation.ts
··· 1 + export type ConstellationLink = { 2 + did: `did:${string}` 3 + collection: string 4 + rkey: string 5 + } 6 + 7 + type Collection = 8 + | 'app.bsky.actor.profile' 9 + | 'app.bsky.feed.generator' 10 + | 'app.bsky.feed.like' 11 + | 'app.bsky.feed.post' 12 + | 'app.bsky.feed.repost' 13 + | 'app.bsky.feed.threadgate' 14 + | 'app.bsky.graph.block' 15 + | 'app.bsky.graph.follow' 16 + | 'app.bsky.graph.list' 17 + | 'app.bsky.graph.listblock' 18 + | 'app.bsky.graph.listitem' 19 + | 'app.bsky.graph.starterpack' 20 + | 'app.bsky.graph.verification' 21 + | 'chat.bsky.actor.declaration' 22 + 23 + const headers = new Headers({ 24 + Accept: 'application/json', 25 + 'User-Agent': 'deer.social (contact @aviva.gay)', 26 + }) 27 + 28 + const makeReqUrl = ( 29 + instance: string, 30 + route: string, 31 + params: Record<string, string>, 32 + ) => { 33 + const url = new URL(instance) 34 + url.pathname = route 35 + for (const [k, v] of Object.entries(params)) { 36 + url.searchParams.set(k, v) 37 + } 38 + return url 39 + } 40 + 41 + // using an async generator lets us kick off dependent requests before finishing pagination 42 + // this doesn't solve the gross N+1 queries thing going on here to get records, but it should make it faster :3 43 + export async function* constellationLinks( 44 + instance: string, 45 + params: { 46 + target: string 47 + collection: Collection 48 + path: string 49 + }, 50 + ) { 51 + const url = makeReqUrl(instance, 'links', params) 52 + 53 + const req = async () => 54 + (await (await fetch(url, {method: 'GET', headers})).json()) as { 55 + total: number 56 + linking_records: ConstellationLink[] 57 + cursor: string | null 58 + } 59 + 60 + let cursor: string | null = null 61 + while (true) { 62 + const resp = await req() 63 + 64 + for (const link of resp.linking_records) { 65 + yield link 66 + } 67 + 68 + cursor = resp.cursor 69 + if (cursor === null) break 70 + url.searchParams.set('cursor', cursor) 71 + } 72 + } 73 + 74 + export async function constellationCounts( 75 + instance: string, 76 + params: {target: string}, 77 + ) { 78 + const url = makeReqUrl(instance, 'links/all', params) 79 + const json = (await (await fetch(url, {method: 'GET', headers})).json()) as { 80 + links: { 81 + [P in Collection]?: { 82 + [k: string]: {distinct_dids: number; records: number} | undefined 83 + } 84 + } 85 + } 86 + const links = json.links 87 + return { 88 + likeCount: 89 + links?.['app.bsky.feed.like']?.['.subject.uri']?.distinct_dids ?? 0, 90 + repostCount: 91 + links?.['app.bsky.feed.repost']?.['.subject.uri']?.distinct_dids ?? 0, 92 + replyCount: 93 + links?.['app.bsky.feed.post']?.['.reply.parent.uri']?.records ?? 0, 94 + } 95 + } 96 + 97 + export function asUri(link: ConstellationLink): string { 98 + return `at://${link.did}/${link.collection}/${link.rkey}` 99 + } 100 + 101 + export async function* asyncGenMap<K, V>( 102 + gen: AsyncGenerator<K, void, unknown>, 103 + fn: (val: K) => V, 104 + ) { 105 + for await (const v of gen) { 106 + yield fn(v) 107 + } 108 + } 109 + 110 + export async function* asyncGenTryMap<K, V>( 111 + gen: AsyncGenerator<K, void, unknown>, 112 + fn: (val: K) => Promise<V>, 113 + err: (val: K, e: unknown) => void, 114 + ) { 115 + for await (const v of gen) { 116 + try { 117 + // make sure we resolve inside the try catch 118 + yield await fn(v) 119 + } catch (e) { 120 + err(v, e) 121 + } 122 + } 123 + } 124 + 125 + export function asyncGenFilter<K, V extends K>( 126 + gen: AsyncGenerator<K, void, unknown>, 127 + predicate: (item: K) => item is V, 128 + ): AsyncGenerator<Awaited<V>, void, unknown> 129 + 130 + export function asyncGenFilter<K>( 131 + gen: AsyncGenerator<K, void, unknown>, 132 + predicate: (item: K) => boolean, 133 + ): AsyncGenerator<Awaited<K>, void, unknown> 134 + 135 + export async function* asyncGenFilter<K>( 136 + gen: AsyncGenerator<K, void, unknown>, 137 + predicate: (item: K) => boolean, 138 + ) { 139 + for await (const v of gen) { 140 + if (predicate(v)) yield v 141 + } 142 + } 143 + 144 + export async function* asyncGenTake<V>( 145 + gen: AsyncGenerator<V, void, unknown>, 146 + n: number, 147 + ) { 148 + if (n <= 0) return 149 + 150 + let taken = 0 151 + for await (const v of gen) { 152 + yield v 153 + if (++taken >= n) break 154 + } 155 + } 156 + 157 + export async function* asyncGenDedupe<V, K>( 158 + gen: AsyncGenerator<V, void, unknown>, 159 + keyFn: (_: V) => K, 160 + ) { 161 + const seen = new Set<K>() 162 + for await (const v of gen) { 163 + const key = keyFn(v) 164 + if (!seen.has(key)) { 165 + seen.add(key) 166 + yield v 167 + } 168 + } 169 + } 170 + 171 + export async function asyncGenCollect<V>( 172 + gen: AsyncGenerator<V, void, unknown>, 173 + ) { 174 + const out = [] 175 + for await (const v of gen) { 176 + out.push(v) 177 + } 178 + return out 179 + } 180 + 181 + export async function asyncGenFind<V>( 182 + gen: AsyncGenerator<V, void, unknown>, 183 + predicate: (item: V) => boolean, 184 + ) { 185 + for await (const v of gen) { 186 + if (predicate(v)) return v 187 + } 188 + return undefined 189 + } 190 + 191 + export function dbg<V>(v: V): V { 192 + console.log(v) 193 + return v 194 + }
+244
src/state/queries/deer-verification.ts
··· 1 + import {AppBskyGraphVerification, AtUri} from '@atproto/api' 2 + import { 3 + type VerificationState, 4 + type VerificationView, 5 + } from '@atproto/api/dist/client/types/app/bsky/actor/defs' 6 + import {useQuery} from '@tanstack/react-query' 7 + 8 + import {STALE} from '#/state/queries' 9 + import * as bsky from '#/types/bsky' 10 + import {type AnyProfileView} from '#/types/bsky/profile' 11 + import {useConstellationInstance} from '../preferences/constellation-instance' 12 + import { 13 + useDeerVerificationEnabled, 14 + useDeerVerificationTrusted, 15 + } from '../preferences/deer-verification' 16 + import { 17 + asUri, 18 + asyncGenCollect, 19 + asyncGenDedupe, 20 + asyncGenFilter, 21 + asyncGenTake, 22 + asyncGenTryMap, 23 + type ConstellationLink, 24 + constellationLinks, 25 + } from './constellation' 26 + import {LRU} from './direct-fetch-record' 27 + import {useCurrentAccountProfile} from './useCurrentAccountProfile' 28 + 29 + const RQKEY_ROOT = 'deer-verification' 30 + export const RQKEY = (did: string, trusted: Set<string>) => [ 31 + RQKEY_ROOT, 32 + did, 33 + Array.from(trusted).sort(), 34 + ] 35 + 36 + type LinkedRecord = { 37 + link: ConstellationLink 38 + record: AppBskyGraphVerification.Record 39 + } 40 + 41 + // TODO: lift this into direct fetch to share cache 42 + const serviceCache = new LRU<`did:${string}`, string>() 43 + 44 + const verificationCache = new LRU<string, any>() 45 + 46 + export function getTrustedConstellationVerifications( 47 + instance: string, 48 + did: string, 49 + trusted: Set<string>, 50 + ) { 51 + const urip = new AtUri(did) 52 + const verificationLinks = asyncGenTake( 53 + constellationLinks(instance, { 54 + target: urip.host, 55 + collection: 'app.bsky.graph.verification', 56 + path: '.subject', 57 + // TODO: remove this when constellation supports filtering 58 + // without a max here, a malicious user could create thousands of verification records and hang a client 59 + // since we can't filter to only trusted verifiers before searching for backlinks yet 60 + }), 61 + 100, 62 + ) 63 + return asyncGenDedupe( 64 + asyncGenFilter(verificationLinks, ({did}) => trusted.has(did)), 65 + ({did}) => did, 66 + ) 67 + } 68 + 69 + async function getDeerVerificationLinkedRecords( 70 + instance: string, 71 + did: string, 72 + trusted: Set<string>, 73 + ): Promise<LinkedRecord[] | undefined> { 74 + try { 75 + const trustedVerificationLinks = getTrustedConstellationVerifications( 76 + instance, 77 + did, 78 + trusted, 79 + ) 80 + 81 + const verificationRecords = asyncGenFilter( 82 + asyncGenTryMap<ConstellationLink, {link: ConstellationLink; record: any}>( 83 + trustedVerificationLinks, 84 + // using try map lets us: 85 + // - cache the service url and verificatin record in independent lrus 86 + // - clear the promise from the lru on failure 87 + // - skip links that cause errors 88 + async link => { 89 + const {did, rkey} = link 90 + 91 + let service = await serviceCache.getOrTryInsertWith(did, async () => { 92 + const docUrl = did.startsWith('did:plc:') 93 + ? `https://plc.directory/${did}` 94 + : `https://${did.substring(8)}/.well-known/did.json` 95 + 96 + // TODO: validate! 97 + const doc: { 98 + service: { 99 + serviceEndpoint: string 100 + type: string 101 + }[] 102 + } = await (await fetch(docUrl)).json() 103 + const service = doc.service.find( 104 + s => s.type === 'AtprotoPersonalDataServer', 105 + )?.serviceEndpoint 106 + 107 + if (service === undefined) 108 + throw new Error(`could not find a service for ${did}`) 109 + return service 110 + }) 111 + 112 + const request = `${service}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.graph.verification&rkey=${rkey}` 113 + const record = await verificationCache.getOrTryInsertWith( 114 + request, 115 + async () => { 116 + const resp = await (await fetch(request)).json() 117 + return resp.value 118 + }, 119 + ) 120 + return {link, record} 121 + }, 122 + (_, e) => { 123 + console.error(e) 124 + }, 125 + ), 126 + // the explicit return type shouldn't be needed... 127 + (d: {link: ConstellationLink; record: unknown}): d is LinkedRecord => 128 + bsky.validate<AppBskyGraphVerification.Record>( 129 + d.record, 130 + AppBskyGraphVerification.validateRecord, 131 + ), 132 + ) 133 + 134 + // Array.fromAsync will do this but not available everywhere yet 135 + return asyncGenCollect(verificationRecords) 136 + } catch (e) { 137 + console.error(e) 138 + return undefined 139 + } 140 + } 141 + 142 + function createVerificationViews( 143 + linkedRecords: LinkedRecord[], 144 + profile: AnyProfileView, 145 + ): VerificationView[] { 146 + return linkedRecords.map(({link, record}) => ({ 147 + issuer: link.did, 148 + isValid: 149 + (profile.displayName ?? '') === record.displayName && 150 + profile.handle === record.handle, 151 + createdAt: record.createdAt, 152 + uri: asUri(link), 153 + })) 154 + } 155 + 156 + function createVerificationState( 157 + verifications: VerificationView[], 158 + profile: AnyProfileView, 159 + trusted: Set<string>, 160 + ): VerificationState { 161 + return { 162 + verifications, 163 + verifiedStatus: 164 + verifications.length > 0 165 + ? verifications.findIndex(v => v.isValid) !== -1 166 + ? 'valid' 167 + : 'invalid' 168 + : 'none', 169 + trustedVerifierStatus: trusted.has(profile.did) ? 'valid' : 'none', 170 + } 171 + } 172 + 173 + export function useDeerVerificationState({ 174 + profile, 175 + enabled, 176 + }: { 177 + profile: AnyProfileView | undefined 178 + enabled?: boolean 179 + }) { 180 + const instance = useConstellationInstance() 181 + const currentAccountProfile = useCurrentAccountProfile() 182 + const trusted = useDeerVerificationTrusted(currentAccountProfile?.did) 183 + 184 + const linkedRecords = useQuery<LinkedRecord[] | undefined>({ 185 + staleTime: STALE.HOURS.ONE, 186 + queryKey: RQKEY(profile?.did || '', trusted), 187 + async queryFn() { 188 + if (!profile) return undefined 189 + 190 + return await getDeerVerificationLinkedRecords( 191 + instance, 192 + profile.did, 193 + trusted, 194 + ) 195 + }, 196 + enabled: enabled && profile !== undefined, 197 + }) 198 + 199 + if (linkedRecords.data === undefined || profile === undefined) return 200 + const verifications = createVerificationViews(linkedRecords.data, profile) 201 + const verificationState = createVerificationState( 202 + verifications, 203 + profile, 204 + trusted, 205 + ) 206 + 207 + return verificationState 208 + } 209 + 210 + export function useDeerVerificationProfileOverlay<V extends AnyProfileView>( 211 + profile: V, 212 + ): V { 213 + const enabled = useDeerVerificationEnabled() 214 + const verificationState = useDeerVerificationState({ 215 + profile, 216 + enabled, 217 + }) 218 + 219 + return enabled 220 + ? { 221 + ...profile, 222 + verification: verificationState, 223 + } 224 + : profile 225 + } 226 + 227 + export function useMaybeDeerVerificationProfileOverlay< 228 + V extends AnyProfileView, 229 + >(profile: V | undefined): V | undefined { 230 + const enabled = useDeerVerificationEnabled() 231 + const verificationState = useDeerVerificationState({ 232 + profile, 233 + enabled, 234 + }) 235 + 236 + if (!profile) return undefined 237 + 238 + return enabled 239 + ? { 240 + ...profile, 241 + verification: verificationState, 242 + } 243 + : profile 244 + }
+157 -50
src/state/queries/direct-fetch-record.ts
··· 1 - import {type AppBskyEmbedRecord, AppBskyFeedPost, AtUri} from '@atproto/api' 1 + import { 2 + type AppBskyEmbedRecord, 3 + type AppBskyFeedDefs, 4 + AppBskyFeedPost, 5 + AtUri, 6 + type BskyAgent, 7 + } from '@atproto/api' 2 8 import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 3 9 import {useQuery} from '@tanstack/react-query' 4 10 ··· 10 16 const RQKEY_ROOT = 'direct-fetch-record' 11 17 export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] 12 18 13 - export function useDirectFetchRecord({ 19 + export async function directFetchRecordAndProfile( 20 + agent: BskyAgent, 21 + uri: string, 22 + ) { 23 + const urip = new AtUri(uri) 24 + 25 + if (!urip.host.startsWith('did:')) { 26 + const res = await agent.resolveHandle({ 27 + handle: urip.host, 28 + }) 29 + urip.host = res.data.did 30 + } 31 + 32 + try { 33 + const [profile, record] = await Promise.all([ 34 + (async () => (await agent.getProfile({actor: urip.host})).data)(), 35 + (async () => 36 + ( 37 + await retry( 38 + 2, 39 + e => { 40 + if (e.message.includes(`Could not locate record:`)) { 41 + return false 42 + } 43 + return true 44 + }, 45 + () => 46 + agent.api.com.atproto.repo.getRecord({ 47 + repo: urip.host, 48 + collection: 'app.bsky.feed.post', 49 + rkey: urip.rkey, 50 + }), 51 + ) 52 + ).data.value)(), 53 + ]) 54 + 55 + return {profile, record} 56 + } catch (e) { 57 + console.error(e) 58 + return undefined 59 + } 60 + } 61 + 62 + export async function directFetchEmbedRecord( 63 + agent: BskyAgent, 64 + uri: string, 65 + ): Promise<AppBskyEmbedRecord.ViewRecord | undefined> { 66 + const res = await directFetchRecordAndProfile(agent, uri) 67 + if (res === undefined) return undefined 68 + const {profile, record} = res 69 + 70 + if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { 71 + return { 72 + $type: 'app.bsky.embed.record#viewRecord', 73 + uri, 74 + author: profile as ProfileViewBasic, 75 + cid: 'directfetch', 76 + value: record, 77 + indexedAt: new Date().toISOString(), 78 + } satisfies AppBskyEmbedRecord.ViewRecord 79 + } else { 80 + return undefined 81 + } 82 + } 83 + 84 + export function useDirectFetchEmbedRecord({ 14 85 uri, 15 86 enabled, 16 87 }: { ··· 22 93 staleTime: STALE.HOURS.ONE, 23 94 queryKey: RQKEY(uri || ''), 24 95 async queryFn() { 25 - const urip = new AtUri(uri) 96 + return directFetchEmbedRecord(agent, uri) 97 + }, 98 + enabled: enabled && !!uri, 99 + }) 100 + } 101 + 102 + export async function directFetchPostRecord( 103 + agent: BskyAgent, 104 + uri: string, 105 + ): Promise<AppBskyFeedDefs.PostView | undefined> { 106 + const res = await directFetchRecordAndProfile(agent, uri) 107 + if (res === undefined) return undefined 108 + const {profile, record} = res 109 + 110 + if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { 111 + return { 112 + $type: 'app.bsky.feed.defs#postView', 113 + uri, 114 + author: profile as ProfileViewBasic, 115 + cid: 'directfetch', 116 + record, 117 + indexedAt: new Date().toISOString(), 118 + } satisfies AppBskyFeedDefs.PostView 119 + } else { 120 + return undefined 121 + } 122 + } 123 + 124 + // based on https://stackoverflow.com/a/46432113 125 + export class LRU<K, V> { 126 + max: number 127 + private cache: Map<K, Promise<V>> 128 + constructor(max = 1_024) { 129 + this.max = max 130 + this.cache = new Map() 131 + } 26 132 27 - if (!urip.host.startsWith('did:')) { 28 - const res = await agent.resolveHandle({ 29 - handle: urip.host, 30 - }) 31 - urip.host = res.data.did 32 - } 133 + get(key: K) { 134 + let item = this.cache.get(key) 135 + if (item !== undefined) { 136 + // refresh key 137 + this.cache.delete(key) 138 + this.cache.set(key, item) 139 + } 140 + return item 141 + } 33 142 34 - try { 35 - const [profile, record] = await Promise.all([ 36 - (async () => (await agent.getProfile({actor: urip.host})).data)(), 37 - (async () => 38 - ( 39 - await retry( 40 - 2, 41 - e => { 42 - if (e.message.includes(`Could not locate record:`)) { 43 - return false 44 - } 45 - return true 46 - }, 47 - () => 48 - agent.api.com.atproto.repo.getRecord({ 49 - repo: urip.host, 50 - collection: 'app.bsky.feed.post', 51 - rkey: urip.rkey, 52 - }), 53 - ) 54 - ).data.value)(), 55 - ]) 143 + set(key: K, val: Promise<V>) { 144 + // refresh key 145 + if (this.cache.has(key)) this.cache.delete(key) 146 + // evict oldest 147 + else if (this.cache.size >= this.max) 148 + this.cache.delete(this.nonemptyFirst()) 149 + this.cache.set(key, val) 150 + } 151 + 152 + delete(key: K) { 153 + return this.cache.delete(key) 154 + } 155 + 156 + private nonemptyFirst() { 157 + return this.cache.keys().next().value! 158 + } 159 + 160 + async getOrInsertWith(key: K, fn: () => Promise<V>): Promise<V> { 161 + const val = this.get(key) 162 + if (val !== undefined) return val 163 + 164 + const promise = fn() 165 + this.set(key, promise) 166 + return promise 167 + } 168 + 169 + // try to insert, but remove from cache on error and bubble 170 + async getOrTryInsertWith(key: K, fn: () => Promise<V>): Promise<V> { 171 + const val = this.get(key) 172 + if (val !== undefined) return val 56 173 57 - if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { 58 - return { 59 - $type: 'app.bsky.embed.record#viewRecord', 60 - uri, 61 - author: profile as ProfileViewBasic, 62 - cid: '', 63 - value: record, 64 - indexedAt: record.createdAt, 65 - } satisfies AppBskyEmbedRecord.ViewRecord 66 - } else { 67 - return undefined 68 - } 69 - } catch (e) { 70 - console.error(e) 71 - return undefined 72 - } 73 - }, 74 - enabled: enabled && !!uri, 75 - }) 174 + const promise = fn() 175 + this.set(key, promise) 176 + try { 177 + return await promise 178 + } catch (e) { 179 + this.delete(key) 180 + throw e 181 + } 182 + } 76 183 }
+57 -17
src/state/queries/verification/useVerificationCreateMutation.tsx
··· 1 1 import {type AppBskyActorGetProfile} from '@atproto/api' 2 - import {useMutation} from '@tanstack/react-query' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 3 4 4 import {until} from '#/lib/async/until' 5 5 import {logger} from '#/logger' 6 + import {useConstellationInstance} from '#/state/preferences/constellation-instance' 7 + import { 8 + useDeerVerificationEnabled, 9 + useDeerVerificationTrusted, 10 + } from '#/state/preferences/deer-verification' 6 11 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 7 12 import {useAgent, useSession} from '#/state/session' 8 13 import type * as bsky from '#/types/bsky' 14 + import {asUri, asyncGenFind, type ConstellationLink} from '../constellation' 15 + import { 16 + getTrustedConstellationVerifications, 17 + RQKEY as DEER_VERIFICATION_RQKEY, 18 + } from '../deer-verification' 9 19 10 20 export function useVerificationCreateMutation() { 11 21 const agent = useAgent() 12 22 const {currentAccount} = useSession() 13 23 const updateProfileVerificationCache = useUpdateProfileVerificationCache() 24 + 25 + const qc = useQueryClient() 26 + const deerVerificationEnabled = useDeerVerificationEnabled() 27 + const deerVerificationTrusted = useDeerVerificationTrusted( 28 + currentAccount?.did, 29 + ) 30 + const constellationInstance = useConstellationInstance() 14 31 15 32 return useMutation({ 16 33 async mutationFn({profile}: {profile: bsky.profile.AnyProfileView}) { ··· 28 45 }, 29 46 ) 30 47 31 - await until( 32 - 5, 33 - 1e3, 34 - ({data: profile}: AppBskyActorGetProfile.Response) => { 35 - if ( 36 - profile.verification && 37 - profile.verification.verifications.find(v => v.uri === uri) 38 - ) { 39 - return true 40 - } 41 - return false 42 - }, 43 - () => { 44 - return agent.getProfile({actor: profile.did ?? ''}) 45 - }, 46 - ) 48 + if (deerVerificationEnabled) { 49 + await until( 50 + 10, 51 + 2e3, 52 + (link: ConstellationLink | undefined) => { 53 + return link !== undefined 54 + }, 55 + () => { 56 + return asyncGenFind( 57 + getTrustedConstellationVerifications( 58 + constellationInstance, 59 + profile.did, 60 + deerVerificationTrusted, 61 + ), 62 + link => asUri(link) === uri, 63 + ) 64 + }, 65 + ) 66 + } else { 67 + await until( 68 + 5, 69 + 1e3, 70 + ({data: profile}: AppBskyActorGetProfile.Response) => { 71 + if ( 72 + profile.verification && 73 + profile.verification.verifications.find(v => v.uri === uri) 74 + ) { 75 + return true 76 + } 77 + return false 78 + }, 79 + () => { 80 + return agent.getProfile({actor: profile.did ?? ''}) 81 + }, 82 + ) 83 + } 47 84 }, 48 85 async onSuccess(_, {profile}) { 49 86 logger.metric('verification:create', {}) 50 87 await updateProfileVerificationCache({profile}) 88 + qc.invalidateQueries({ 89 + queryKey: DEER_VERIFICATION_RQKEY(profile.did, deerVerificationTrusted), 90 + }) 51 91 }, 52 92 }) 53 93 }
+64 -18
src/state/queries/verification/useVerificationsRemoveMutation.tsx
··· 3 3 type AppBskyActorGetProfile, 4 4 AtUri, 5 5 } from '@atproto/api' 6 - import {useMutation} from '@tanstack/react-query' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 7 8 8 import {until} from '#/lib/async/until' 9 9 import {logger} from '#/logger' 10 + import {useConstellationInstance} from '#/state/preferences/constellation-instance' 11 + import { 12 + useDeerVerificationEnabled, 13 + useDeerVerificationTrusted, 14 + } from '#/state/preferences/deer-verification' 10 15 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 11 16 import {useAgent, useSession} from '#/state/session' 12 17 import type * as bsky from '#/types/bsky' 18 + import { 19 + asUri, 20 + asyncGenCollect, 21 + asyncGenFilter, 22 + type ConstellationLink, 23 + } from '../constellation' 24 + import { 25 + getTrustedConstellationVerifications, 26 + RQKEY as DEER_VERIFICATION_RQKEY, 27 + } from '../deer-verification' 13 28 14 29 export function useVerificationsRemoveMutation() { 15 30 const agent = useAgent() 16 31 const {currentAccount} = useSession() 17 32 const updateProfileVerificationCache = useUpdateProfileVerificationCache() 18 33 34 + const qc = useQueryClient() 35 + const deerVerificationEnabled = useDeerVerificationEnabled() 36 + const deerVerificationTrusted = useDeerVerificationTrusted( 37 + currentAccount?.did, 38 + ) 39 + const constellationInstance = useConstellationInstance() 40 + 19 41 return useMutation({ 20 42 async mutationFn({ 21 43 profile, ··· 28 50 throw new Error('User not logged in') 29 51 } 30 52 31 - const uris = verifications.map(v => v.uri) 53 + const uris = new Set(verifications.map(v => v.uri)) 32 54 33 55 await Promise.all( 34 - uris.map(uri => { 56 + Array.from(uris).map(uri => { 35 57 return agent.app.bsky.graph.verification.delete({ 36 58 repo: currentAccount.did, 37 59 rkey: new AtUri(uri).rkey, ··· 39 61 }), 40 62 ) 41 63 42 - await until( 43 - 5, 44 - 1e3, 45 - ({data: profile}: AppBskyActorGetProfile.Response) => { 46 - if ( 47 - !profile.verification?.verifications.some(v => uris.includes(v.uri)) 48 - ) { 49 - return true 50 - } 51 - return false 52 - }, 53 - () => { 54 - return agent.getProfile({actor: profile.did ?? ''}) 55 - }, 56 - ) 64 + if (deerVerificationEnabled) { 65 + await until( 66 + 10, 67 + 2e3, 68 + (link: ConstellationLink[]) => { 69 + return link.length === 0 70 + }, 71 + () => 72 + asyncGenCollect( 73 + asyncGenFilter( 74 + getTrustedConstellationVerifications( 75 + constellationInstance, 76 + profile.did, 77 + deerVerificationTrusted, 78 + ), 79 + link => uris.has(asUri(link)), 80 + ), 81 + ), 82 + ) 83 + } else { 84 + await until( 85 + 5, 86 + 1e3, 87 + ({data: profile}: AppBskyActorGetProfile.Response) => { 88 + if ( 89 + !profile.verification?.verifications.some(v => uris.has(v.uri)) 90 + ) { 91 + return true 92 + } 93 + return false 94 + }, 95 + () => { 96 + return agent.getProfile({actor: profile.did ?? ''}) 97 + }, 98 + ) 99 + } 57 100 }, 58 101 async onSuccess(_, {profile}) { 59 102 logger.metric('verification:revoke', {}) 60 103 await updateProfileVerificationCache({profile}) 104 + qc.invalidateQueries({ 105 + queryKey: DEER_VERIFICATION_RQKEY(profile.did, deerVerificationTrusted), 106 + }) 61 107 }, 62 108 }) 63 109 }
+34
src/view/com/profile/ProfileMenu.tsx
··· 13 13 import {logger} from '#/logger' 14 14 import {type Shadow} from '#/state/cache/types' 15 15 import {useModalControls} from '#/state/modals' 16 + import { 17 + useDeerVerificationEnabled, 18 + useDeerVerificationTrusted, 19 + useSetDeerVerificationTrust, 20 + } from '#/state/preferences/deer-verification' 16 21 import {useDevModeEnabled} from '#/state/preferences/dev-mode' 17 22 import { 18 23 RQKEY as profileQueryKey, ··· 67 72 const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked 68 73 const [devModeEnabled] = useDevModeEnabled() 69 74 const verification = useFullVerificationState({profile}) 75 + 76 + const deerVerificationEnabled = useDeerVerificationEnabled() 77 + const deerVerificationTrusted = useDeerVerificationTrusted().has(profile.did) 78 + const setDeerVerificationTrust = useSetDeerVerificationTrust() 70 79 71 80 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 72 81 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) ··· 290 299 </Menu.ItemText> 291 300 <Menu.ItemIcon icon={List} /> 292 301 </Menu.Item> 302 + {!isSelf && 303 + deerVerificationEnabled && 304 + (deerVerificationTrusted ? ( 305 + <Menu.Item 306 + testID="profileHeaderDropdownVerificationTrustRemoveButton" 307 + label={_(msg`Remove trust`)} 308 + onPress={() => 309 + setDeerVerificationTrust.remove(profile.did) 310 + }> 311 + <Menu.ItemText> 312 + <Trans>Remove trust</Trans> 313 + </Menu.ItemText> 314 + <Menu.ItemIcon icon={CircleX} /> 315 + </Menu.Item> 316 + ) : ( 317 + <Menu.Item 318 + testID="profileHeaderDropdownVerificationTrustAddButton" 319 + label={_(msg`Trust verifier`)} 320 + onPress={() => setDeerVerificationTrust.add(profile.did)}> 321 + <Menu.ItemText> 322 + <Trans>Trust verifier</Trans> 323 + </Menu.ItemText> 324 + <Menu.ItemIcon icon={CircleCheck} /> 325 + </Menu.Item> 326 + ))} 293 327 {verification.viewer.role === 'verifier' && 294 328 !verification.profile.isViewer && 295 329 (verification.viewer.hasIssuedVerification ? (
+2 -2
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 31 31 import {s} from '#/lib/styles' 32 32 import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' 33 33 import {useModerationOpts} from '#/state/preferences/moderation-opts' 34 - import {useDirectFetchRecord} from '#/state/queries/direct-fetch-record' 34 + import {useDirectFetchEmbedRecord} from '#/state/queries/direct-fetch-record' 35 35 import {precacheProfile} from '#/state/queries/profile' 36 36 import {useResolveLinkQuery} from '#/state/queries/resolve-link' 37 37 import {useSession} from '#/state/session' ··· 73 73 AppBskyEmbedRecord.isViewDetached(embed.record)) && 74 74 directFetchEnabled 75 75 76 - const directRecord = useDirectFetchRecord({ 76 + const directRecord = useDirectFetchEmbedRecord({ 77 77 uri: 78 78 AppBskyEmbedRecord.isViewBlocked(embed.record) || 79 79 AppBskyEmbedRecord.isViewDetached(embed.record)