diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index 16ab49ea7..540ce29d4 100644 --- a/src/components/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -77,12 +77,14 @@ export function SearchablePeopleList({ title, showRecentConvos, sortByMessageDeclaration, + excludeSelf = true, onSelectChat, renderProfileCard, }: { title: string showRecentConvos?: boolean sortByMessageDeclaration?: boolean + excludeSelf?: boolean } & ( | { renderProfileCard: (item: ProfileItem) => React.ReactNode @@ -131,7 +133,7 @@ export function SearchablePeopleList({ } else if (searchText.length) { if (results?.length) { for (const profile of results) { - if (profile.did === currentAccount?.did) continue + if (excludeSelf && profile.did === currentAccount?.did) continue _items.push({ type: 'profile', key: profile.did, @@ -242,6 +244,7 @@ export function SearchablePeopleList({ results, isError, currentAccount?.did, + excludeSelf, follows, convos, showRecentConvos, diff --git a/src/screens/Settings/RunesSettings/BadgesSettings.tsx b/src/screens/Settings/RunesSettings/BadgesSettings.tsx index 96e381886..718ad4d6d 100644 --- a/src/screens/Settings/RunesSettings/BadgesSettings.tsx +++ b/src/screens/Settings/RunesSettings/BadgesSettings.tsx @@ -1,15 +1,21 @@ -import {useState} from 'react' -import {View} from 'react-native' -import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' -import {Trans, useLingui} from '@lingui/react/macro' +import {useCallback, useMemo, useState} from 'react' +import {LayoutAnimation, View} from 'react-native' +import {useReducedMotion} from 'react-native-reanimated' +import {type ModerationOpts} from '@atproto/api' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {usePalette} from '#/lib/hooks/usePalette' import * as persisted from '#/state/persisted' import { + useDeerVerification, useDeerVerificationEnabled, + useDeerVerificationTrustAppView, useDeerVerificationTrusted, + useSetDeerVerification, useSetDeerVerificationEnabled, + useSetDeerVerificationTrust, } from '#/state/preferences/deer-verification' +import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import { useFaviconService, useSetFaviconService, @@ -22,26 +28,43 @@ import { useSetPdsLabelHideBskyPds, } from '#/state/preferences/pds-label' import {useProfilesQuery} from '#/state/queries/profile' +import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' +import {useSession} from '#/state/session' import * as SettingsList from '#/screens/Settings/components/SettingsList' -import {atoms as a, useBreakpoints} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import { + type ProfileItem, + SearchablePeopleList, +} from '#/components/dialogs/SearchablePeopleList' import * as Toggle from '#/components/forms/Toggle' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, + ChevronTop_Stroke2_Corner0_Rounded as ChevronTopIcon, +} from '#/components/icons/Chevron' import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {Star_Stroke2_Corner0_Rounded as StarIcon} from '#/components/icons/Star' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified' +import * as ProfileCard from '#/components/ProfileCard' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' -import {SearchProfileCard} from '../../Search/components/SearchProfileCard' +import type * as bsky from '#/types/bsky' import {RunesScreenLayout} from './components/RunesScreenLayout' export function RunesBadgesSettingsScreen() { const {t: l} = useLingui() + const deerVerification = useDeerVerification() const deerVerificationEnabled = useDeerVerificationEnabled() + const trustAppView = useDeerVerificationTrustAppView() + const setDeerVerification = useSetDeerVerification() const setDeerVerificationEnabled = useSetDeerVerificationEnabled() - const setTrustedVerifiersDialogControl = Dialog.useDialogControl() const pdsLabelEnabled = usePdsLabelEnabled() const setPdsLabelEnabled = useSetPdsLabelEnabled() @@ -51,19 +74,38 @@ export function RunesBadgesSettingsScreen() { return ( - setDeerVerificationEnabled(value)}> - - - - Select trusted verifiers and act as one - + + + + Trusted verifiers + + setDeerVerificationEnabled(value)} + style={[a.w_full]}> + + Use own selection of trusted verifiers + - - + + {deerVerificationEnabled && ( + + setDeerVerification({...deerVerification, trustAppView: value}) + } + style={[a.w_full]}> + + Trust verifiers from current AppView + + + + )} + @@ -76,16 +118,7 @@ export function RunesBadgesSettingsScreen() { - - - - {`Trusted Verifiers`} - - setTrustedVerifiersDialogControl.open()} - /> - + @@ -133,7 +166,6 @@ export function RunesBadgesSettingsScreen() { )} - ) } @@ -231,53 +263,312 @@ function FaviconServiceDialog({control}: {control: Dialog.DialogControlProps}) { ) } -function TrustedVerifiersDialog({ - control, -}: { - control: Dialog.DialogControlProps -}) { +function TrustedVerifiersSection() { const {t: l} = useLingui() + const reducedMotion = useReducedMotion() + const addTrustedVerifierDialogControl = Dialog.useDialogControl() + const resetTrustedVerifiersPromptControl = Prompt.usePromptControl() + const deerVerification = useDeerVerification() + const setDeerVerification = useSetDeerVerification() + const currentAccountProfile = useCurrentAccountProfile() + const {currentAccount} = useSession() + const currentAccountDid = currentAccount?.did + const [isExpanded, setIsExpanded] = useState(false) + const trusted = useDeerVerificationTrusted() + + const results = useProfilesQuery({ + handles: Array.from(trusted).filter( + did => did !== currentAccountProfile?.did, + ), + maintainData: true, + }) + + const trustedProfiles = useMemo(() => { + const profiles: bsky.profile.AnyProfileView[] = [] + const seen = new Set() + + if (currentAccountProfile && trusted.has(currentAccountProfile.did)) { + profiles.push(currentAccountProfile) + seen.add(currentAccountProfile.did) + } + + for (const profile of results.data?.profiles ?? []) { + if (!trusted.has(profile.did)) continue + if (seen.has(profile.did)) continue + profiles.push(profile) + seen.add(profile.did) + } + + return profiles + }, [currentAccountProfile, results.data?.profiles, trusted]) + + const onToggleExpanded = useCallback(() => { + if (!reducedMotion) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + } + setIsExpanded(current => !current) + }, [reducedMotion]) + + const onResetAll = useCallback(() => { + setDeerVerification({ + ...deerVerification, + trustedSelf: true, + trusted: [], + }) + }, [deerVerification, setDeerVerification]) return ( - - - - - - Trusted Verifiers - - + <> + + + + {`Trusted Verifiers`} + + {!isExpanded && ( + + + + )} + + - + {isExpanded && ( + + {trustedProfiles.length > 0 ? ( + trustedProfiles.map(profile => ( + + )) + ) : ( + + + No trusted verifiers selected. + + + )} - - - + + + + + + )} + + + + ) } -function TrustedVerifiers() { - const trusted = useDeerVerificationTrusted() +function TrustedVerifierRow({ + profile, + isCurrentAccount, +}: { + profile: bsky.profile.AnyProfileView + isCurrentAccount: boolean +}) { + const {t: l} = useLingui() const moderationOpts = useModerationOpts() - const {gtMobile} = useBreakpoints() + const enableSquareButtons = useEnableSquareButtons() + const setDeerVerificationTrust = useSetDeerVerificationTrust() + const t = useTheme() - const results = useProfilesQuery({ - handles: Array.from(trusted), - }) + const onRemove = useCallback(() => { + setDeerVerificationTrust.remove(profile.did) + Toast.show(l`Removed trusted verifier`) + }, [l, profile.did, setDeerVerificationTrust]) - if (!results.data || moderationOpts === undefined) { - return null - } + if (!moderationOpts) return null + + return ( + + + + + + + + + {isCurrentAccount && ( + + Current account + + )} + + + + + + + + ) +} +function TrustedVerifiersAddDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { return ( - - {results.data.profiles.map(profile => ( - + + + + ) +} + +function TrustedVerifiersAddDialogInner() { + const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + const trusted = useDeerVerificationTrusted() + const setDeerVerificationTrust = useSetDeerVerificationTrust() + const {currentAccount} = useSession() + + const renderProfileCard = useCallback( + (item: ProfileItem) => { + if (!moderationOpts) return null + + return ( + { + if (trusted.has(item.profile.did)) { + setDeerVerificationTrust.remove(item.profile.did) + Toast.show(l`Removed trusted verifier`) + } else { + setDeerVerificationTrust.add(item.profile.did) + Toast.show(l`Added trusted verifier`) + } + }} /> - ))} + ) + }, + [currentAccount?.did, l, moderationOpts, setDeerVerificationTrust, trusted], + ) + + return ( + + ) +} + +function TrustedVerifierSearchResult({ + profile, + moderationOpts, + isTrusted, + isCurrentAccount, + onToggle, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + isTrusted: boolean + isCurrentAccount: boolean + onToggle: () => void +}) { + const {t: l} = useLingui() + const t = useTheme() + + return ( + + + + + + + + + {isCurrentAccount && ( + + Current account + + )} + + + + + + ) } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 62645fe55..e9d355ce5 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -200,6 +200,8 @@ const schema = z.object({ deerVerification: z .object({ enabled: z.boolean(), + trustAppView: z.boolean().optional(), + trustedSelf: z.boolean().optional(), trusted: z.array(z.string()), }) .optional(), @@ -330,30 +332,9 @@ export const defaults: Schema = { showViaClient: true, deerVerification: { enabled: false, - // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k - // using https://bverified.vercel.app/trusted as a source - trusted: [ - 'did:plc:z72i7hdynmk6r22z27h6tvur', - 'did:plc:b2kutgxqlltwc6lhs724cfwr', - 'did:plc:inz4fkbbp7ms3ixufw6xuvdi', - 'did:plc:eclio37ymobqex2ncko63h4r', - 'did:plc:dzezcmpb3fhcpns4n4xm4ur5', - 'did:plc:5u54z2qgkq43dh2nzwzdbbhb', - 'did:plc:wmho6q2uiyktkam3jsvrms3s', - 'did:plc:sqbswn3lalcc2dlh2k7zdpuw', - 'did:plc:k5nskatzhyxersjilvtnz4lh', - 'did:plc:d2jith367s6ybc3ldsusgdae', - 'did:plc:y3xrmnwvkvsq4tqcsgwch4na', - 'did:plc:i3fhjvvkbmirhyu4aeihhrnv', - 'did:plc:fivojrvylkim4nuo3pfqcf3k', - 'did:plc:ofbkqcjzvm6gtwuufsubnkaf', - 'did:plc:xwqgusybtrpm67tcwqdfmzvy', - 'did:plc:oxo226vi7t2btjokm2buusoy', - 'did:plc:r4ve5hjtfjubdwrvlxcad62e', - 'did:plc:j4eroku3volozvv6ljsnnfec', - 'did:plc:6q2thhy2ohzog26mmqm4pffk', - 'did:plc:rk25gdgk3cnnmtkvlae265nz', - ], + trustAppView: true, + trustedSelf: true, + trusted: [], }, highQualityImages: false, imageCdnHost: 'https://cdn.bsky.app', diff --git a/src/state/preferences/deer-verification.tsx b/src/state/preferences/deer-verification.tsx index 196b1565f..4ccc89154 100644 --- a/src/state/preferences/deer-verification.tsx +++ b/src/state/preferences/deer-verification.tsx @@ -9,6 +9,7 @@ import { } from 'react' import * as persisted from '#/state/persisted' +import {useSession} from '#/state/session' type StateContext = persisted.Schema['deerVerification'] type SetContext = (v: persisted.Schema['deerVerification']) => void @@ -26,7 +27,7 @@ export function Provider({children}: PropsWithChildren<{}>) { const setStateWrapped = useCallback( (deerVerification: persisted.Schema['deerVerification']) => { setState(deerVerification) - persisted.write('deerVerification', deerVerification) + void persisted.write('deerVerification', deerVerification) }, [setState], ) @@ -54,14 +55,23 @@ 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 useDeerVerificationTrustAppView() { + return useDeerVerification().trustAppView ?? true +} + +export function useDeerVerificationTrusted() { + const deerVerification = useDeerVerification() + const {currentAccount} = useSession() + const currentAccountDid = currentAccount?.did + const trustedSelf = deerVerification.trustedSelf ?? true + + return useMemo(() => { + const trusted = new Set(deerVerification.trusted) + if (trustedSelf && currentAccountDid) { + trusted.add(currentAccountDid) + } + return trusted + }, [currentAccountDid, deerVerification.trusted, trustedSelf]) } export function useSetDeerVerification() { @@ -82,20 +92,50 @@ export function useSetDeerVerificationEnabled() { export function useSetDeerVerificationTrust() { const deerVerification = useDeerVerification() const setDeerVerification = useSetDeerVerification() + const {currentAccount} = useSession() + const currentAccountDid = currentAccount?.did return useMemo( () => ({ add: (add: string) => { const trusted = new Set(deerVerification.trusted) + if (add === currentAccountDid) { + trusted.delete(add) + setDeerVerification({ + ...deerVerification, + trustedSelf: true, + trusted: Array.from(trusted), + }) + return + } + trusted.add(add) - setDeerVerification({...deerVerification, trusted: Array.from(trusted)}) + setDeerVerification({ + ...deerVerification, + trustedSelf: deerVerification.trustedSelf ?? true, + trusted: Array.from(trusted), + }) }, remove: (remove: string) => { const trusted = new Set(deerVerification.trusted) + if (remove === currentAccountDid) { + trusted.delete(remove) + setDeerVerification({ + ...deerVerification, + trustedSelf: false, + trusted: Array.from(trusted), + }) + return + } + trusted.delete(remove) - setDeerVerification({...deerVerification, trusted: Array.from(trusted)}) + setDeerVerification({ + ...deerVerification, + trustedSelf: deerVerification.trustedSelf ?? true, + trusted: Array.from(trusted), + }) }, }), - [deerVerification, setDeerVerification], + [currentAccountDid, deerVerification, setDeerVerification], ) } diff --git a/src/state/queries/deer-verification.ts b/src/state/queries/deer-verification.ts index 98fd706fc..29ed8ce81 100644 --- a/src/state/queries/deer-verification.ts +++ b/src/state/queries/deer-verification.ts @@ -11,6 +11,7 @@ import {type AnyProfileView} from '#/types/bsky/profile' import {useConstellationInstance} from '../preferences/constellation-instance' import { useDeerVerificationEnabled, + useDeerVerificationTrustAppView, useDeerVerificationTrusted, } from '../preferences/deer-verification' import { @@ -24,7 +25,6 @@ import { } from './constellation' import {LRU} from './direct-fetch-record' import {resolvePdsServiceUrl} from './resolve-identity' -import {useCurrentAccountProfile} from './useCurrentAccountProfile' const RQKEY_ROOT = 'deer-verification' export const RQKEY = (did: string, trusted: Set) => [ @@ -53,8 +53,8 @@ export function getTrustedConstellationVerifications( from_dids: Array.from(trusted), }) return asyncGenDedupe( - asyncGenFilter(verificationLinks, (link) => trusted.has(link.did)), - (link) => link.did, + asyncGenFilter(verificationLinks, link => trusted.has(link.did)), + link => link.did, ) } @@ -84,7 +84,9 @@ async function getDeerVerificationLinkedRecords( const record = await verificationCache.getOrTryInsertWith( request, async () => { - const resp = await (await fetch(request)).json() + const resp = (await (await fetch(request)).json()) as { + value?: unknown + } return resp.value }, ) @@ -124,11 +126,41 @@ function createVerificationViews( })) } +function mergeVerificationViews( + appViewVerifications: VerificationView[], + deerVerifications: VerificationView[], +) { + const merged = new Map() + + for (const verification of appViewVerifications) { + merged.set(verification.uri, verification) + } + + for (const verification of deerVerifications) { + merged.set(verification.uri, verification) + } + + return Array.from(merged.values()) +} + function createVerificationState( - verifications: VerificationView[], + appViewVerifications: VerificationView[], + deerVerifications: VerificationView[], profile: AnyProfileView, trusted: Set, + trustAppView: boolean, ): VerificationState { + const verifications = mergeVerificationViews( + appViewVerifications, + deerVerifications, + ) + const appViewTrustedVerifierStatus = trustAppView + ? profile.verification?.trustedVerifierStatus + : undefined + const trustedVerifierStatus = trusted.has(profile.did) + ? 'valid' + : (appViewTrustedVerifierStatus ?? 'none') + return { verifications, verifiedStatus: @@ -137,7 +169,7 @@ function createVerificationState( ? 'valid' : 'invalid' : 'none', - trustedVerifierStatus: trusted.has(profile.did) ? 'valid' : 'none', + trustedVerifierStatus, } } @@ -149,8 +181,8 @@ export function useDeerVerificationState({ enabled?: boolean }) { const instance = useConstellationInstance() - const currentAccountProfile = useCurrentAccountProfile() - const trusted = useDeerVerificationTrusted(currentAccountProfile?.did) + const trusted = useDeerVerificationTrusted() + const trustAppView = useDeerVerificationTrustAppView() const linkedRecords = useQuery({ staleTime: STALE.HOURS.ONE, @@ -168,11 +200,13 @@ export function useDeerVerificationState({ }) if (linkedRecords.data === undefined || profile === undefined) return - const verifications = createVerificationViews(linkedRecords.data, profile) + const deerVerifications = createVerificationViews(linkedRecords.data, profile) const verificationState = createVerificationState( - verifications, + trustAppView ? (profile.verification?.verifications ?? []) : [], + deerVerifications, profile, trusted, + trustAppView, ) return verificationState diff --git a/src/state/queries/verification/useVerificationCreateMutation.tsx b/src/state/queries/verification/useVerificationCreateMutation.tsx index 14eefb7c2..844b35271 100644 --- a/src/state/queries/verification/useVerificationCreateMutation.tsx +++ b/src/state/queries/verification/useVerificationCreateMutation.tsx @@ -25,9 +25,7 @@ export function useVerificationCreateMutation() { const qc = useQueryClient() const deerVerificationEnabled = useDeerVerificationEnabled() - const deerVerificationTrusted = useDeerVerificationTrusted( - currentAccount?.did, - ) + const deerVerificationTrusted = useDeerVerificationTrusted() const constellationInstance = useConstellationInstance() return useMutation({ @@ -86,7 +84,7 @@ export function useVerificationCreateMutation() { async onSuccess(_, {profile}) { ax.metric('verification:create', {}) await updateProfileVerificationCache({profile}) - qc.invalidateQueries({ + await 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 index fe4acbfbb..8fa548aaf 100644 --- a/src/state/queries/verification/useVerificationsRemoveMutation.tsx +++ b/src/state/queries/verification/useVerificationsRemoveMutation.tsx @@ -34,9 +34,7 @@ export function useVerificationsRemoveMutation() { const qc = useQueryClient() const deerVerificationEnabled = useDeerVerificationEnabled() - const deerVerificationTrusted = useDeerVerificationTrusted( - currentAccount?.did, - ) + const deerVerificationTrusted = useDeerVerificationTrusted() const constellationInstance = useConstellationInstance() return useMutation({ @@ -102,7 +100,7 @@ export function useVerificationsRemoveMutation() { async onSuccess(_, {profile}) { ax.metric('verification:revoke', {}) await updateProfileVerificationCache({profile}) - qc.invalidateQueries({ + await qc.invalidateQueries({ queryKey: DEER_VERIFICATION_RQKEY(profile.did, deerVerificationTrusted), }) },