diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx --- a/src/components/verification/VerifierDialog.tsx +++ b/src/components/verification/VerifierDialog.tsx @@ -7,6 +7,7 @@ import {urls} from '#/lib/constants' import {getUserDisplayName} from '#/lib/getUserDisplayName' import {NON_BREAKING_SPACE} from '#/lib/strings/constants' import {logger} from '#/logger' +import {useDeerVerificationEnabled} from '#/state/preferences/deer-verification' import {useSession} from '#/state/session' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -59,6 +60,8 @@ const label = isSelf ? _(msg`You are a trusted verifier`) : _(msg`${userName} is a trusted verifier`) + const deerVerificationEnabled = useDeerVerificationEnabled() + return ( - - {_( - + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + {minHeight: 100}, + ]}> + {_( + + )} @@ -102,8 +107,8 @@ {NON_BREAKING_SPACE} {NON_BREAKING_SPACE} - can verify others. These trusted verifiers are selected by - Bluesky. + can verify others. These trusted verifiers are selected by{' '} + {deerVerificationEnabled ? 'you' : 'Bluesky'}. diff --git a/src/components/verification/index.ts b/src/components/verification/index.ts --- a/src/components/verification/index.ts +++ b/src/components/verification/index.ts @@ -1,5 +1,6 @@ import {useMemo} from 'react' +import {useMaybeDeerVerificationProfileOverlay} from '#/state/queries/deer-verification' import {usePreferencesQuery} from '#/state/queries/preferences' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useSession} from '#/state/session' @@ -79,7 +80,7 @@ showBadge: boolean } export function useSimpleVerificationState({ - profile, + profile: baseProfile, }: { profile?: bsky.profile.AnyProfileView }): SimpleVerificationState { @@ -88,6 +89,8 @@ const prefs = useMemo( () => preferences.data?.verificationPrefs || {hideBadges: false}, [preferences.data?.verificationPrefs], ) + const profile = useMaybeDeerVerificationProfileOverlay(baseProfile) + return useMemo(() => { if (!profile || !profile.verification) { return { diff --git a/src/screens/Settings/DeerSettings.tsx b/src/screens/Settings/DeerSettings.tsx --- a/src/screens/Settings/DeerSettings.tsx +++ b/src/screens/Settings/DeerSettings.tsx @@ -14,12 +14,22 @@ useGatesCache, } from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {setGeolocation, useGeolocation} from '#/state/geolocation' +import * as persisted from '#/state/persisted' import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' import { useConstellationEnabled, useSetConstellationEnabled, } from '#/state/preferences/constellation-enabled' import { + useConstellationInstance, + useSetConstellationInstance, +} from '#/state/preferences/constellation-instance' +import { + useDeerVerificationEnabled, + useDeerVerificationTrusted, + useSetDeerVerificationEnabled, +} from '#/state/preferences/deer-verification' +import { useDirectFetchRecords, useSetDirectFetchRecords, } from '#/state/preferences/direct-fetch-records' @@ -27,6 +37,7 @@ import { useHideFollowNotifications, useSetHideFollowNotifications, } from '#/state/preferences/hide-follow-notifications' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import { useNoAppLabelers, useSetNoAppLabelers, @@ -39,7 +50,9 @@ import { useRepostCarouselEnabled, useSetRepostCarouselEnabled, } from '#/state/preferences/repost-carousel-enabled' +import {useProfilesQuery} from '#/state/queries/profile' import {TextInput} from '#/view/com/modals/util' +import {List} from '#/view/com/util/List' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -53,8 +66,11 @@ import {Earth_Stroke2_Corner2_Rounded as GlobeIcon} from '#/components/icons/Globe' import {Lab_Stroke2_Corner0_Rounded as BeakerIcon} from '#/components/icons/Lab' import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' import {RaisingHand4Finger_Stroke2_Corner0_Rounded as RaisingHandIcon} from '#/components/icons/RaisingHand' +import {Star_Stroke2_Corner0_Rounded as StarIcon} from '#/components/icons/Star' +import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' +import {SearchProfileCard} from '../Search/components/SearchProfileCard' type Props = NativeStackScreenProps @@ -123,6 +139,94 @@ ) } +function ConstellationInstanceDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const pal = usePalette('default') + const {_} = useLingui() + + const [url, setUrl] = useState('') + const setConstellationInstance = useSetConstellationInstance() + + const submit = () => { + setConstellationInstance(url) + control.close() + // need to clear since we don't set value of input and component may be reused + setUrl('') + } + + return ( + + + + + + Constellations instance URL + + + + + { + setUrl(value) + }} + placeholder={persisted.defaults.constellationInstance} + placeholderTextColor={pal.colors.textLight} + onSubmitEditing={submit} + accessibilityHint={_( + msg`Input the url of the constellations instance to use`, + )} + /> + + + + + + + + + + ) +} + +const TrustedVerifiers = (): React.ReactNode => { + const trusted = useDeerVerificationTrusted() + const moderationOpts = useModerationOpts() + + const results = useProfilesQuery({ + handles: Array.from(trusted), + }) + + return ( + results.data && + moderationOpts !== undefined && ( + ( + + )} + keyExtractor={item => item.did} + contentContainerStyle={[a.pl_xl, a.pb_sm]} + /> + ) + ) +} + export function DeerSettingsScreen({}: Props) { const {_} = useLingui() @@ -146,6 +250,12 @@ const setHideFollowNotifications = useSetHideFollowNotifications() const location = useGeolocation() const setLocationControl = Dialog.useDialogControl() + + const constellationInstance = useConstellationInstance() + const setConstellationInstanceControl = Dialog.useDialogControl() + + const deerVerificationEnabled = useDeerVerificationEnabled() + const setDeerVerificationEnabled = useSetDeerVerificationEnabled() const repostCarouselEnabled = useRepostCarouselEnabled() const setRepostCarouselEnabled = useSetRepostCarouselEnabled() @@ -230,6 +340,64 @@ + + + + Verification + + setDeerVerificationEnabled(value)} + style={[a.w_full]}> + + + Select your own set of trusted verifiers, and operate as a + verifier + + + + + + + + + + WIP. May slow down the client or fail to find all labels. Revoke + and grant trust in the meatball menu on a profile.{' '} + {deerVerificationEnabled + ? 'You currently' + : 'If enabled, you would'}{' '} + trust the following verifiers: + + + + + + + + + + {`Constellation Instance`} + + setConstellationInstanceControl.open()} + /> + + + + + Constellation is used to supplement AppView responses for custom + verifications and nuclear block bypass, via backlinks. Current + instance: {constellationInstance} + + + + @@ -377,6 +545,7 @@ + ) } diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -22,6 +22,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' import type * as bsky from '#/types/bsky' +import {useDeerVerificationProfileOverlay} from '../queries/deer-verification' import {castAsShadow, type Shadow} from './types' export type {Shadow} from './types' @@ -59,13 +60,14 @@ emitter.removeListener(profile.did, onUpdate) } }, [profile]) - return useMemo(() => { + const shadowed = useMemo(() => { if (shadow) { return mergeShadow(profile, shadow) } else { return castAsShadow(profile) } }, [profile, shadow]) + return useDeerVerificationProfileOverlay(shadowed) } /** diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -132,6 +132,13 @@ noAppLabelers: z.boolean().optional(), noDiscoverFallback: z.boolean().optional(), repostCarouselEnabled: z.boolean().optional(), hideFollowNotifications: z.boolean().optional(), + constellationInstance: z.string().optional(), + deerVerification: z + .object({ + enabled: z.boolean(), + trusted: z.array(z.string()), + }) + .optional(), /** @deprecated */ mutedThreads: z.array(z.string()), @@ -193,6 +200,17 @@ noAppLabelers: false, noDiscoverFallback: false, repostCarouselEnabled: false, hideFollowNotifications: false, + constellationInstance: 'https://constellation.microcosm.blue/', + deerVerification: { + enabled: false, + // https://deer.social/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k + trusted: [ + 'did:plc:z72i7hdynmk6r22z27h6tvur', + 'did:plc:eclio37ymobqex2ncko63h4r', + 'did:plc:inz4fkbbp7ms3ixufw6xuvdi', + 'did:plc:b2kutgxqlltwc6lhs724cfwr', + ], + }, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/constellation-instance.tsx b/src/state/preferences/constellation-instance.tsx new file mode 100644 --- /dev/null +++ b/src/state/preferences/constellation-instance.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['constellationInstance'] +type SetContext = (v: persisted.Schema['constellationInstance']) => void + +const stateContext = React.createContext( + persisted.defaults.constellationInstance, +) +const setContext = React.createContext( + (_: persisted.Schema['constellationInstance']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState( + persisted.get('constellationInstance'), + ) + + const setStateWrapped = React.useCallback( + (constellationInstance: persisted.Schema['constellationInstance']) => { + setState(constellationInstance) + persisted.write('constellationInstance', constellationInstance) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate( + 'constellationInstance', + nextConstellationInstance => { + setState(nextConstellationInstance) + }, + ) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useConstellationInstance() { + return ( + React.useContext(stateContext) ?? persisted.defaults.constellationInstance! + ) +} + +export function useSetConstellationInstance() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/deer-verification.tsx b/src/state/preferences/deer-verification.tsx new file mode 100644 --- /dev/null +++ b/src/state/preferences/deer-verification.tsx @@ -0,0 +1,93 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['deerVerification'] +type SetContext = (v: persisted.Schema['deerVerification']) => void + +const stateContext = React.createContext( + persisted.defaults.deerVerification, +) +const setContext = React.createContext( + (_: persisted.Schema['deerVerification']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('deerVerification')) + + const setStateWrapped = React.useCallback( + (deerVerification: persisted.Schema['deerVerification']) => { + setState(deerVerification) + persisted.write('deerVerification', deerVerification) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('deerVerification', nextDeerVerification => { + setState(nextDeerVerification) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useDeerVerification() { + return React.useContext(stateContext) ?? persisted.defaults.deerVerification! +} + +export function useDeerVerificationEnabled() { + return useDeerVerification().enabled +} + +export function useDeerVerificationTrusted( + mandatory: string | undefined = undefined, +) { + const trusted = new Set(useDeerVerification().trusted) + if (mandatory) { + trusted.add(mandatory) + } + return trusted +} + +export function useSetDeerVerification() { + return React.useContext(setContext) +} + +export function useSetDeerVerificationEnabled() { + const deerVerification = useDeerVerification() + const setDeerVerification = useSetDeerVerification() + + return React.useMemo( + () => (enabled: boolean) => + setDeerVerification({...deerVerification, enabled}), + [deerVerification, setDeerVerification], + ) +} + +export function useSetDeerVerificationTrust() { + const deerVerification = useDeerVerification() + const setDeerVerification = useSetDeerVerification() + + return React.useMemo( + () => ({ + add: (add: string) => { + const trusted = new Set(deerVerification.trusted) + trusted.add(add) + setDeerVerification({...deerVerification, trusted: Array.from(trusted)}) + }, + remove: (remove: string) => { + const trusted = new Set(deerVerification.trusted) + trusted.delete(remove) + setDeerVerification({...deerVerification, trusted: Array.from(trusted)}) + }, + }), + [deerVerification, setDeerVerification], + ) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -3,6 +3,8 @@ import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' import {Provider as ConstellationProvider} from './constellation-enabled' +import {Provider as ConstellationInstanceProvider} from './constellation-instance' +import {Provider as DeerVerificationProvider} from './deer-verification' import {Provider as DirectFetchRecordsProvider} from './direct-fetch-records' import {Provider as DisableHapticsProvider} from './disable-haptics' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' @@ -45,29 +47,35 @@ - - - - - - - - - - - - {children} - - - - - - - - - - - + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + diff --git a/src/state/queries/constellation.ts b/src/state/queries/constellation.ts new file mode 100644 --- /dev/null +++ b/src/state/queries/constellation.ts @@ -0,0 +1,194 @@ +export type ConstellationLink = { + did: `did:${string}` + collection: string + rkey: string +} + +type Collection = + | 'app.bsky.actor.profile' + | 'app.bsky.feed.generator' + | 'app.bsky.feed.like' + | 'app.bsky.feed.post' + | 'app.bsky.feed.repost' + | 'app.bsky.feed.threadgate' + | 'app.bsky.graph.block' + | 'app.bsky.graph.follow' + | 'app.bsky.graph.list' + | 'app.bsky.graph.listblock' + | 'app.bsky.graph.listitem' + | 'app.bsky.graph.starterpack' + | 'app.bsky.graph.verification' + | 'chat.bsky.actor.declaration' + +const headers = new Headers({ + Accept: 'application/json', + 'User-Agent': 'deer.social (contact @aviva.gay)', +}) + +const makeReqUrl = ( + instance: string, + route: string, + params: Record, +) => { + const url = new URL(instance) + url.pathname = route + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v) + } + return url +} + +// using an async generator lets us kick off dependent requests before finishing pagination +// this doesn't solve the gross N+1 queries thing going on here to get records, but it should make it faster :3 +export async function* constellationLinks( + instance: string, + params: { + target: string + collection: Collection + path: string + }, +) { + const url = makeReqUrl(instance, 'links', params) + + const req = async () => + (await (await fetch(url, {method: 'GET', headers})).json()) as { + total: number + linking_records: ConstellationLink[] + cursor: string | null + } + + let cursor: string | null = null + while (true) { + const resp = await req() + + for (const link of resp.linking_records) { + yield link + } + + cursor = resp.cursor + if (cursor === null) break + url.searchParams.set('cursor', cursor) + } +} + +export async function constellationCounts( + instance: string, + params: {target: string}, +) { + const url = makeReqUrl(instance, 'links/all', params) + const json = (await (await fetch(url, {method: 'GET', headers})).json()) as { + links: { + [P in Collection]?: { + [k: string]: {distinct_dids: number; records: number} | undefined + } + } + } + const links = json.links + return { + likeCount: + links?.['app.bsky.feed.like']?.['.subject.uri']?.distinct_dids ?? 0, + repostCount: + links?.['app.bsky.feed.repost']?.['.subject.uri']?.distinct_dids ?? 0, + replyCount: + links?.['app.bsky.feed.post']?.['.reply.parent.uri']?.records ?? 0, + } +} + +export function asUri(link: ConstellationLink): string { + return `at://${link.did}/${link.collection}/${link.rkey}` +} + +export async function* asyncGenMap( + gen: AsyncGenerator, + fn: (val: K) => V, +) { + for await (const v of gen) { + yield fn(v) + } +} + +export async function* asyncGenTryMap( + gen: AsyncGenerator, + fn: (val: K) => Promise, + err: (val: K, e: unknown) => void, +) { + for await (const v of gen) { + try { + // make sure we resolve inside the try catch + yield await fn(v) + } catch (e) { + err(v, e) + } + } +} + +export function asyncGenFilter( + gen: AsyncGenerator, + predicate: (item: K) => item is V, +): AsyncGenerator, void, unknown> + +export function asyncGenFilter( + gen: AsyncGenerator, + predicate: (item: K) => boolean, +): AsyncGenerator, void, unknown> + +export async function* asyncGenFilter( + gen: AsyncGenerator, + predicate: (item: K) => boolean, +) { + for await (const v of gen) { + if (predicate(v)) yield v + } +} + +export async function* asyncGenTake( + gen: AsyncGenerator, + n: number, +) { + if (n <= 0) return + + let taken = 0 + for await (const v of gen) { + yield v + if (++taken >= n) break + } +} + +export async function* asyncGenDedupe( + gen: AsyncGenerator, + keyFn: (_: V) => K, +) { + const seen = new Set() + for await (const v of gen) { + const key = keyFn(v) + if (!seen.has(key)) { + seen.add(key) + yield v + } + } +} + +export async function asyncGenCollect( + gen: AsyncGenerator, +) { + const out = [] + for await (const v of gen) { + out.push(v) + } + return out +} + +export async function asyncGenFind( + gen: AsyncGenerator, + predicate: (item: V) => boolean, +) { + for await (const v of gen) { + if (predicate(v)) return v + } + return undefined +} + +export function dbg(v: V): V { + console.log(v) + return v +} diff --git a/src/state/queries/deer-verification.ts b/src/state/queries/deer-verification.ts new file mode 100644 --- /dev/null +++ b/src/state/queries/deer-verification.ts @@ -0,0 +1,244 @@ +import {AppBskyGraphVerification, AtUri} from '@atproto/api' +import { + type VerificationState, + type VerificationView, +} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {useQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import * as bsky from '#/types/bsky' +import {type AnyProfileView} from '#/types/bsky/profile' +import {useConstellationInstance} from '../preferences/constellation-instance' +import { + useDeerVerificationEnabled, + useDeerVerificationTrusted, +} from '../preferences/deer-verification' +import { + asUri, + asyncGenCollect, + asyncGenDedupe, + asyncGenFilter, + asyncGenTake, + asyncGenTryMap, + type ConstellationLink, + constellationLinks, +} from './constellation' +import {LRU} from './direct-fetch-record' +import {useCurrentAccountProfile} from './useCurrentAccountProfile' + +const RQKEY_ROOT = 'deer-verification' +export const RQKEY = (did: string, trusted: Set) => [ + RQKEY_ROOT, + did, + Array.from(trusted).sort(), +] + +type LinkedRecord = { + link: ConstellationLink + record: AppBskyGraphVerification.Record +} + +// TODO: lift this into direct fetch to share cache +const serviceCache = new LRU<`did:${string}`, string>() + +const verificationCache = new LRU() + +export function getTrustedConstellationVerifications( + instance: string, + did: string, + trusted: Set, +) { + const urip = new AtUri(did) + const verificationLinks = asyncGenTake( + constellationLinks(instance, { + target: urip.host, + collection: 'app.bsky.graph.verification', + path: '.subject', + // TODO: remove this when constellation supports filtering + // without a max here, a malicious user could create thousands of verification records and hang a client + // since we can't filter to only trusted verifiers before searching for backlinks yet + }), + 100, + ) + return asyncGenDedupe( + asyncGenFilter(verificationLinks, ({did}) => trusted.has(did)), + ({did}) => did, + ) +} + +async function getDeerVerificationLinkedRecords( + instance: string, + did: string, + trusted: Set, +): Promise { + try { + const trustedVerificationLinks = getTrustedConstellationVerifications( + instance, + did, + trusted, + ) + + const verificationRecords = asyncGenFilter( + asyncGenTryMap( + trustedVerificationLinks, + // using try map lets us: + // - cache the service url and verificatin record in independent lrus + // - clear the promise from the lru on failure + // - skip links that cause errors + async link => { + const {did, rkey} = link + + let service = 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 + }) + + const request = `${service}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.graph.verification&rkey=${rkey}` + const record = await verificationCache.getOrTryInsertWith( + request, + async () => { + const resp = await (await fetch(request)).json() + return resp.value + }, + ) + return {link, record} + }, + (_, e) => { + console.error(e) + }, + ), + // the explicit return type shouldn't be needed... + (d: {link: ConstellationLink; record: unknown}): d is LinkedRecord => + bsky.validate( + d.record, + AppBskyGraphVerification.validateRecord, + ), + ) + + // Array.fromAsync will do this but not available everywhere yet + return asyncGenCollect(verificationRecords) + } catch (e) { + console.error(e) + return undefined + } +} + +function createVerificationViews( + linkedRecords: LinkedRecord[], + profile: AnyProfileView, +): VerificationView[] { + return linkedRecords.map(({link, record}) => ({ + issuer: link.did, + isValid: + (profile.displayName ?? '') === record.displayName && + profile.handle === record.handle, + createdAt: record.createdAt, + uri: asUri(link), + })) +} + +function createVerificationState( + verifications: VerificationView[], + profile: AnyProfileView, + trusted: Set, +): VerificationState { + return { + verifications, + verifiedStatus: + verifications.length > 0 + ? verifications.findIndex(v => v.isValid) !== -1 + ? 'valid' + : 'invalid' + : 'none', + trustedVerifierStatus: trusted.has(profile.did) ? 'valid' : 'none', + } +} + +export function useDeerVerificationState({ + profile, + enabled, +}: { + profile: AnyProfileView | undefined + enabled?: boolean +}) { + const instance = useConstellationInstance() + const currentAccountProfile = useCurrentAccountProfile() + const trusted = useDeerVerificationTrusted(currentAccountProfile?.did) + + const linkedRecords = useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY(profile?.did || '', trusted), + async queryFn() { + if (!profile) return undefined + + return await getDeerVerificationLinkedRecords( + instance, + profile.did, + trusted, + ) + }, + enabled: enabled && profile !== undefined, + }) + + if (linkedRecords.data === undefined || profile === undefined) return + const verifications = createVerificationViews(linkedRecords.data, profile) + const verificationState = createVerificationState( + verifications, + profile, + trusted, + ) + + return verificationState +} + +export function useDeerVerificationProfileOverlay( + profile: V, +): V { + const enabled = useDeerVerificationEnabled() + const verificationState = useDeerVerificationState({ + profile, + enabled, + }) + + return enabled + ? { + ...profile, + verification: verificationState, + } + : profile +} + +export function useMaybeDeerVerificationProfileOverlay< + V extends AnyProfileView, +>(profile: V | undefined): V | undefined { + const enabled = useDeerVerificationEnabled() + const verificationState = useDeerVerificationState({ + profile, + enabled, + }) + + if (!profile) return undefined + + return enabled + ? { + ...profile, + verification: verificationState, + } + : profile +} diff --git a/src/state/queries/direct-fetch-record.ts b/src/state/queries/direct-fetch-record.ts --- a/src/state/queries/direct-fetch-record.ts +++ b/src/state/queries/direct-fetch-record.ts @@ -1,4 +1,10 @@ -import {type AppBskyEmbedRecord, AppBskyFeedPost, AtUri} from '@atproto/api' +import { + type AppBskyEmbedRecord, + type AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + type BskyAgent, +} from '@atproto/api' import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' import {useQuery} from '@tanstack/react-query' @@ -10,7 +16,72 @@ const RQKEY_ROOT = 'direct-fetch-record' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] -export function useDirectFetchRecord({ +export async function directFetchRecordAndProfile( + agent: BskyAgent, + uri: string, +) { + const urip = new AtUri(uri) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + try { + const [profile, record] = await Promise.all([ + (async () => (await agent.getProfile({actor: urip.host})).data)(), + (async () => + ( + await retry( + 2, + e => { + if (e.message.includes(`Could not locate record:`)) { + return false + } + return true + }, + () => + agent.api.com.atproto.repo.getRecord({ + repo: urip.host, + collection: 'app.bsky.feed.post', + rkey: urip.rkey, + }), + ) + ).data.value)(), + ]) + + return {profile, record} + } catch (e) { + console.error(e) + return undefined + } +} + +export async function directFetchEmbedRecord( + agent: BskyAgent, + uri: string, +): Promise { + const res = await directFetchRecordAndProfile(agent, uri) + if (res === undefined) return undefined + const {profile, record} = res + + if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { + return { + $type: 'app.bsky.embed.record#viewRecord', + uri, + author: profile as ProfileViewBasic, + cid: 'directfetch', + value: record, + indexedAt: new Date().toISOString(), + } satisfies AppBskyEmbedRecord.ViewRecord + } else { + return undefined + } +} + +export function useDirectFetchEmbedRecord({ uri, enabled, }: { @@ -22,55 +93,91 @@ return useQuery({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY(uri || ''), async queryFn() { - const urip = new AtUri(uri) + return directFetchEmbedRecord(agent, uri) + }, + enabled: enabled && !!uri, + }) +} + +export async function directFetchPostRecord( + agent: BskyAgent, + uri: string, +): Promise { + const res = await directFetchRecordAndProfile(agent, uri) + if (res === undefined) return undefined + const {profile, record} = res + + if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { + return { + $type: 'app.bsky.feed.defs#postView', + uri, + author: profile as ProfileViewBasic, + cid: 'directfetch', + record, + indexedAt: new Date().toISOString(), + } satisfies AppBskyFeedDefs.PostView + } else { + return undefined + } +} + +// based on https://stackoverflow.com/a/46432113 +export class LRU { + max: number + private cache: Map> + constructor(max = 1_024) { + this.max = max + this.cache = new Map() + } - if (!urip.host.startsWith('did:')) { - const res = await agent.resolveHandle({ - handle: urip.host, - }) - urip.host = res.data.did - } + get(key: K) { + let item = this.cache.get(key) + if (item !== undefined) { + // refresh key + this.cache.delete(key) + this.cache.set(key, item) + } + return item + } - try { - const [profile, record] = await Promise.all([ - (async () => (await agent.getProfile({actor: urip.host})).data)(), - (async () => - ( - await retry( - 2, - e => { - if (e.message.includes(`Could not locate record:`)) { - return false - } - return true - }, - () => - agent.api.com.atproto.repo.getRecord({ - repo: urip.host, - collection: 'app.bsky.feed.post', - rkey: urip.rkey, - }), - ) - ).data.value)(), - ]) + set(key: K, val: Promise) { + // refresh key + if (this.cache.has(key)) this.cache.delete(key) + // evict oldest + else if (this.cache.size >= this.max) + this.cache.delete(this.nonemptyFirst()) + this.cache.set(key, val) + } + + delete(key: K) { + return this.cache.delete(key) + } + + private nonemptyFirst() { + return this.cache.keys().next().value! + } + + async getOrInsertWith(key: K, fn: () => Promise): Promise { + const val = this.get(key) + if (val !== undefined) return val + + const promise = fn() + this.set(key, promise) + return promise + } + + // try to insert, but remove from cache on error and bubble + async getOrTryInsertWith(key: K, fn: () => Promise): Promise { + const val = this.get(key) + if (val !== undefined) return val - if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { - return { - $type: 'app.bsky.embed.record#viewRecord', - uri, - author: profile as ProfileViewBasic, - cid: '', - value: record, - indexedAt: record.createdAt, - } satisfies AppBskyEmbedRecord.ViewRecord - } else { - return undefined - } - } catch (e) { - console.error(e) - return undefined - } - }, - enabled: enabled && !!uri, - }) + const promise = fn() + this.set(key, promise) + try { + return await promise + } catch (e) { + this.delete(key) + throw e + } + } } diff --git a/src/state/queries/verification/useVerificationCreateMutation.tsx b/src/state/queries/verification/useVerificationCreateMutation.tsx --- a/src/state/queries/verification/useVerificationCreateMutation.tsx +++ b/src/state/queries/verification/useVerificationCreateMutation.tsx @@ -1,16 +1,33 @@ import {type AppBskyActorGetProfile} from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {until} from '#/lib/async/until' import {logger} from '#/logger' +import {useConstellationInstance} from '#/state/preferences/constellation-instance' +import { + useDeerVerificationEnabled, + useDeerVerificationTrusted, +} from '#/state/preferences/deer-verification' import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' import {useAgent, useSession} from '#/state/session' import type * as bsky from '#/types/bsky' +import {asUri, asyncGenFind, type ConstellationLink} from '../constellation' +import { + getTrustedConstellationVerifications, + RQKEY as DEER_VERIFICATION_RQKEY, +} from '../deer-verification' export function useVerificationCreateMutation() { const agent = useAgent() const {currentAccount} = useSession() const updateProfileVerificationCache = useUpdateProfileVerificationCache() + + const qc = useQueryClient() + const deerVerificationEnabled = useDeerVerificationEnabled() + const deerVerificationTrusted = useDeerVerificationTrusted( + currentAccount?.did, + ) + const constellationInstance = useConstellationInstance() return useMutation({ async mutationFn({profile}: {profile: bsky.profile.AnyProfileView}) { @@ -28,26 +45,49 @@ displayName: profile.displayName || '', }, ) - await until( - 5, - 1e3, - ({data: profile}: AppBskyActorGetProfile.Response) => { - if ( - profile.verification && - profile.verification.verifications.find(v => v.uri === uri) - ) { - return true - } - return false - }, - () => { - return agent.getProfile({actor: profile.did ?? ''}) - }, - ) + if (deerVerificationEnabled) { + await until( + 10, + 2e3, + (link: ConstellationLink | undefined) => { + return link !== undefined + }, + () => { + return asyncGenFind( + getTrustedConstellationVerifications( + constellationInstance, + profile.did, + deerVerificationTrusted, + ), + link => asUri(link) === uri, + ) + }, + ) + } else { + await until( + 5, + 1e3, + ({data: profile}: AppBskyActorGetProfile.Response) => { + if ( + profile.verification && + profile.verification.verifications.find(v => v.uri === uri) + ) { + return true + } + return false + }, + () => { + return agent.getProfile({actor: profile.did ?? ''}) + }, + ) + } }, async onSuccess(_, {profile}) { logger.metric('verification:create', {}) await updateProfileVerificationCache({profile}) + qc.invalidateQueries({ + queryKey: DEER_VERIFICATION_RQKEY(profile.did, deerVerificationTrusted), + }) }, }) } diff --git a/src/state/queries/verification/useVerificationsRemoveMutation.tsx b/src/state/queries/verification/useVerificationsRemoveMutation.tsx --- a/src/state/queries/verification/useVerificationsRemoveMutation.tsx +++ b/src/state/queries/verification/useVerificationsRemoveMutation.tsx @@ -3,19 +3,41 @@ type AppBskyActorDefs, type AppBskyActorGetProfile, AtUri, } from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {until} from '#/lib/async/until' import {logger} from '#/logger' +import {useConstellationInstance} from '#/state/preferences/constellation-instance' +import { + useDeerVerificationEnabled, + useDeerVerificationTrusted, +} from '#/state/preferences/deer-verification' import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' import {useAgent, useSession} from '#/state/session' import type * as bsky from '#/types/bsky' +import { + asUri, + asyncGenCollect, + asyncGenFilter, + type ConstellationLink, +} from '../constellation' +import { + getTrustedConstellationVerifications, + RQKEY as DEER_VERIFICATION_RQKEY, +} from '../deer-verification' export function useVerificationsRemoveMutation() { const agent = useAgent() const {currentAccount} = useSession() const updateProfileVerificationCache = useUpdateProfileVerificationCache() + const qc = useQueryClient() + const deerVerificationEnabled = useDeerVerificationEnabled() + const deerVerificationTrusted = useDeerVerificationTrusted( + currentAccount?.did, + ) + const constellationInstance = useConstellationInstance() + return useMutation({ async mutationFn({ profile, @@ -28,10 +50,10 @@ if (!currentAccount) { throw new Error('User not logged in') } - const uris = verifications.map(v => v.uri) + const uris = new Set(verifications.map(v => v.uri)) await Promise.all( - uris.map(uri => { + Array.from(uris).map(uri => { return agent.app.bsky.graph.verification.delete({ repo: currentAccount.did, rkey: new AtUri(uri).rkey, @@ -39,25 +61,49 @@ }) }), ) - await until( - 5, - 1e3, - ({data: profile}: AppBskyActorGetProfile.Response) => { - if ( - !profile.verification?.verifications.some(v => uris.includes(v.uri)) - ) { - return true - } - return false - }, - () => { - return agent.getProfile({actor: profile.did ?? ''}) - }, - ) + if (deerVerificationEnabled) { + await until( + 10, + 2e3, + (link: ConstellationLink[]) => { + return link.length === 0 + }, + () => + asyncGenCollect( + asyncGenFilter( + getTrustedConstellationVerifications( + constellationInstance, + profile.did, + deerVerificationTrusted, + ), + link => uris.has(asUri(link)), + ), + ), + ) + } else { + await until( + 5, + 1e3, + ({data: profile}: AppBskyActorGetProfile.Response) => { + if ( + !profile.verification?.verifications.some(v => uris.has(v.uri)) + ) { + return true + } + return false + }, + () => { + return agent.getProfile({actor: profile.did ?? ''}) + }, + ) + } }, async onSuccess(_, {profile}) { logger.metric('verification:revoke', {}) await updateProfileVerificationCache({profile}) + qc.invalidateQueries({ + queryKey: DEER_VERIFICATION_RQKEY(profile.did, deerVerificationTrusted), + }) }, }) } diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -13,6 +13,11 @@ import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/types' import {useModalControls} from '#/state/modals' +import { + useDeerVerificationEnabled, + useDeerVerificationTrusted, + useSetDeerVerificationTrust, +} from '#/state/preferences/deer-verification' import {useDevModeEnabled} from '#/state/preferences/dev-mode' import { RQKEY as profileQueryKey, @@ -67,6 +72,10 @@ const isFollowingBlockedAccount = isFollowing && isBlocked const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked const [devModeEnabled] = useDevModeEnabled() const verification = useFullVerificationState({profile}) + + const deerVerificationEnabled = useDeerVerificationEnabled() + const deerVerificationTrusted = useDeerVerificationTrusted().has(profile.did) + const setDeerVerificationTrust = useSetDeerVerificationTrust() const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) @@ -290,6 +299,31 @@ Add to lists + {!isSelf && + deerVerificationEnabled && + (deerVerificationTrusted ? ( + + setDeerVerificationTrust.remove(profile.did) + }> + + Remove trust + + + + ) : ( + setDeerVerificationTrust.add(profile.did)}> + + Trust verifier + + + + ))} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -31,7 +31,7 @@ import {makeProfileLink} from '#/lib/routes/links' import {s} from '#/lib/styles' import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useDirectFetchRecord} from '#/state/queries/direct-fetch-record' +import {useDirectFetchEmbedRecord} from '#/state/queries/direct-fetch-record' import {precacheProfile} from '#/state/queries/profile' import {useResolveLinkQuery} from '#/state/queries/resolve-link' import {useSession} from '#/state/session' @@ -73,7 +73,7 @@ (AppBskyEmbedRecord.isViewBlocked(embed.record) || AppBskyEmbedRecord.isViewDetached(embed.record)) && directFetchEnabled - const directRecord = useDirectFetchRecord({ + const directRecord = useDirectFetchEmbedRecord({ uri: AppBskyEmbedRecord.isViewBlocked(embed.record) || AppBskyEmbedRecord.isViewDetached(embed.record)