From 0ac15920a477a5c8090fd2b929b36ac0b6e02c34 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 18 Apr 2025 21:15:32 -0500 Subject: Verification (#8226) * WIP * Alignment with icon * Add create/remove prompts * Fill out check dialog a bit * Reorg * Handle was verified state * Add warning to edit profile * Add warning to handle dialog * Decent alignment in posts on all platforms * Refactor alignment for posts, chatlist, hover card * Disable on profile * Convo header * Compute simple verification state * Add other icon, rename, integrate * Swap in simple state for profile edits * Clean up utility hooks * Add verifications UI to dialog * Add edu nux * Revert change * Fix wrapping of check on profile * Rename * Fix gap under PostMeta * Update check dialogs * Handle takendown verifiers in check dialog * alf composer reply to * Refactor verification state * Add create/remove mutations, non-functional for now * Fix up post-rebase * Add check to first author noty * Do cache updates after mutations * DRY up hook, add to profile updates too * Add to drawer * Update account list * Adapt to new types * Hook up mutations * Use profile shadow in feeds * Add to settings * Shadow currentAccountProfile * Add invalid state to verifications * Fix alignment and overflow in Settings and Drawer * Re-integrate post rebase * Remove debug code * Update copy * Add unverified notification support * Remove link * Make sure dialog closes * Update URL * Add settings screen * Integrate new setting into verification states * Add metrics, bump package, fix bad import * NUX fixes * Update copy * Fixes * Update types * fix search autocomplete * fix lint * add display name warning to new dialog * update default prefs * Add parsing support for notifications * Bump pkg * Tweak noty styles * Adjust check alignment * Tweak check alignment * Fix badge for verifier * Modify copy --------- Co-authored-by: Samuel Newman Co-authored-by: Paul Frazee --- src/components/verification/VerificationCheck.tsx | 12 + .../verification/VerificationCheckButton.tsx | 155 +++++++++++++ .../verification/VerificationCreatePrompt.tsx | 70 ++++++ .../verification/VerificationRemovePrompt.tsx | 50 ++++ .../verification/VerificationsDialog.tsx | 257 +++++++++++++++++++++ src/components/verification/VerifierDialog.tsx | 153 ++++++++++++ src/components/verification/index.ts | 113 +++++++++ 7 files changed, 810 insertions(+) create mode 100644 src/components/verification/VerificationCheck.tsx create mode 100644 src/components/verification/VerificationCheckButton.tsx create mode 100644 src/components/verification/VerificationCreatePrompt.tsx create mode 100644 src/components/verification/VerificationRemovePrompt.tsx create mode 100644 src/components/verification/VerificationsDialog.tsx create mode 100644 src/components/verification/VerifierDialog.tsx create mode 100644 src/components/verification/index.ts (limited to 'src/components/verification') diff --git a/src/components/verification/VerificationCheck.tsx b/src/components/verification/VerificationCheck.tsx new file mode 100644 index 000000000..4f41c6682 --- /dev/null +++ b/src/components/verification/VerificationCheck.tsx @@ -0,0 +1,12 @@ +import {type Props} from '#/components/icons/common' +import {VerifiedCheck} from '#/components/icons/VerifiedCheck' +import {VerifierCheck} from '#/components/icons/VerifierCheck' + +export function VerificationCheck({ + verifier, + ...rest +}: Props & { + verifier?: boolean +}) { + return verifier ? : +} diff --git a/src/components/verification/VerificationCheckButton.tsx b/src/components/verification/VerificationCheckButton.tsx new file mode 100644 index 000000000..1b66cd90e --- /dev/null +++ b/src/components/verification/VerificationCheckButton.tsx @@ -0,0 +1,155 @@ +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/types' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {useFullVerificationState} from '#/components/verification' +import {type FullVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {VerificationsDialog} from '#/components/verification/VerificationsDialog' +import {VerifierDialog} from '#/components/verification/VerifierDialog' +import type * as bsky from '#/types/bsky' + +export function shouldShowVerificationCheckButton( + state: FullVerificationState, +) { + let ok = false + + if (state.profile.role === 'default') { + if (state.profile.isVerified) { + ok = true + } else if (state.profile.isViewer && state.profile.wasVerified) { + ok = true + } else if ( + state.viewer.role === 'verifier' && + state.viewer.hasIssuedVerification + ) { + ok = true + } + } else if (state.profile.role === 'verifier') { + if (state.profile.isViewer) { + ok = true + } else if (state.profile.isVerified) { + ok = true + } + } + + if ( + !state.profile.showBadge && + !state.profile.isViewer && + !(state.viewer.role === 'verifier' && state.viewer.hasIssuedVerification) + ) { + ok = false + } + + return ok +} + +export function VerificationCheckButton({ + profile, + size, +}: { + profile: Shadow + size: 'lg' | 'md' | 'sm' +}) { + const state = useFullVerificationState({ + profile, + }) + + if (shouldShowVerificationCheckButton(state)) { + return + } + + return null +} + +export function Badge({ + profile, + verificationState: state, + size, +}: { + profile: Shadow + verificationState: FullVerificationState + size: 'lg' | 'md' | 'sm' +}) { + const t = useTheme() + const {_} = useLingui() + const verificationsDialogControl = useDialogControl() + const verifierDialogControl = useDialogControl() + const {gtPhone} = useBreakpoints() + let dimensions = 12 + if (size === 'lg') { + dimensions = gtPhone ? 20 : 18 + } else if (size === 'md') { + dimensions = 16 + } + + const verifiedByHidden = !state.profile.showBadge && state.profile.isViewer + + return ( + <> + + + + + + + ) +} diff --git a/src/components/verification/VerificationCreatePrompt.tsx b/src/components/verification/VerificationCreatePrompt.tsx new file mode 100644 index 000000000..39ac6dbf6 --- /dev/null +++ b/src/components/verification/VerificationCreatePrompt.tsx @@ -0,0 +1,70 @@ +import {useCallback} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useVerificationCreateMutation} from '#/state/queries/verification/useVerificationCreateMutation' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {type DialogControlProps} from '#/components/Dialog' +import {VerifiedCheck} from '#/components/icons/VerifiedCheck' +import * as ProfileCard from '#/components/ProfileCard' +import * as Prompt from '#/components/Prompt' +import type * as bsky from '#/types/bsky' + +export function VerificationCreatePrompt({ + control, + profile, +}: { + control: DialogControlProps + profile: bsky.profile.AnyProfileView +}) { + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const {mutateAsync: create} = useVerificationCreateMutation() + const onConfirm = useCallback(async () => { + try { + await create({profile}) + Toast.show(_(msg`Successfully verified`)) + } catch (e) { + Toast.show(_(msg`Failed to create a verification`), 'xmark') + logger.error('Failed to create a verification', { + safeMessage: e, + }) + } + }, [_, profile, create]) + + return ( + + + + + {_(msg`Verify this account?`)} + + + + {_(msg`This action can be undone at any time.`)} + + + {moderationOpts ? ( + + + + + ) : null} + + + + + + + ) +} diff --git a/src/components/verification/VerificationRemovePrompt.tsx b/src/components/verification/VerificationRemovePrompt.tsx new file mode 100644 index 000000000..470b61c19 --- /dev/null +++ b/src/components/verification/VerificationRemovePrompt.tsx @@ -0,0 +1,50 @@ +import {useCallback} from 'react' +import {type AppBskyActorDefs} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useVerificationsRemoveMutation} from '#/state/queries/verification/useVerificationsRemoveMutation' +import * as Toast from '#/view/com/util/Toast' +import {type DialogControlProps} from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' +import type * as bsky from '#/types/bsky' + +export {useDialogControl as usePromptControl} from '#/components/Dialog' + +export function VerificationRemovePrompt({ + control, + profile, + verifications, + onConfirm: onConfirmInner, +}: { + control: DialogControlProps + profile: bsky.profile.AnyProfileView + verifications: AppBskyActorDefs.VerificationView[] + onConfirm?: () => void +}) { + const {_} = useLingui() + const {mutateAsync: remove} = useVerificationsRemoveMutation() + const onConfirm = useCallback(async () => { + onConfirmInner?.() + try { + await remove({profile, verifications}) + Toast.show(_(msg`Removed verification`)) + } catch (e) { + Toast.show(_(msg`Failed to remove verification`), 'xmark') + logger.error('Failed to remove verification', { + safeMessage: e, + }) + } + }, [_, profile, verifications, remove, onConfirmInner]) + + return ( + + ) +} diff --git a/src/components/verification/VerificationsDialog.tsx b/src/components/verification/VerificationsDialog.tsx new file mode 100644 index 000000000..d61823968 --- /dev/null +++ b/src/components/verification/VerificationsDialog.tsx @@ -0,0 +1,257 @@ +import {View} from 'react-native' +import {type AppBskyActorDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {urls} from '#/lib/constants' +import {getUserDisplayName} from '#/lib/getUserDisplayName' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Link} from '#/components/Link' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import {type FullVerificationState} from '#/components/verification' +import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' +import type * as bsky from '#/types/bsky' + +export {useDialogControl} from '#/components/Dialog' + +export function VerificationsDialog({ + control, + profile, + verificationState, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + verificationState: FullVerificationState +}) { + return ( + + + + + ) +} + +function Inner({ + profile, + control, + verificationState: state, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + verificationState: FullVerificationState +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + const userName = getUserDisplayName(profile) + const label = state.profile.isViewer + ? state.profile.isVerified + ? _(msg`You are verified`) + : _(msg`Your verifications`) + : state.profile.isVerified + ? _(msg`${userName} is verified`) + : _( + msg({ + message: `${userName}'s verifications`, + comment: `Possessive, meaning "the verifications of {userName}"`, + }), + ) + + return ( + + + + + + {label} + + + {state.profile.isVerified ? ( + + This account has a checkmark because it's been verified by trusted + sources. + + ) : ( + + This account has one or more verifications, but it is not + currently verified. + + )} + + + + {profile.verification ? ( + + + Verified by: + + + + {profile.verification.verifications.map(v => ( + + ))} + + + {profile.verification.verifications.some(v => !v.isValid) && + state.profile.isViewer && ( + + Some of your verifications are invalid. + + )} + + ) : null} + + + + { + logger.metric('verification:learn-more', { + location: 'verificationsDialog', + }) + }}> + + Learn more + + + + + + + ) +} + +function VerifierCard({ + verification, + subject, + outerDialogControl, +}: { + verification: AppBskyActorDefs.VerificationView + subject: bsky.profile.AnyProfileView + outerDialogControl: Dialog.DialogControlProps +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + const {data: profile, error} = useProfileQuery({did: verification.issuer}) + const verificationRemovePromptControl = useDialogControl() + const canAdminister = verification.issuer === currentAccount?.did + + return ( + + + + {error ? ( + <> + + + + Unknown verifier + + + {verification.issuer} + + + + ) : profile && moderationOpts ? ( + <> + + + {canAdminister && ( + + + + )} + + ) : ( + <> + + + + )} + + + + outerDialogControl.close()} + /> + + ) +} diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx new file mode 100644 index 000000000..bfe49ec19 --- /dev/null +++ b/src/components/verification/VerifierDialog.tsx @@ -0,0 +1,153 @@ +import {Text as RNText, View} from 'react-native' +import {Image} from 'expo-image' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {urls} from '#/lib/constants' +import {getUserDisplayName} from '#/lib/getUserDisplayName' +import {NON_BREAKING_SPACE} from '#/lib/strings/constants' +import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {VerifierCheck} from '#/components/icons/VerifierCheck' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' +import {type FullVerificationState} from '#/components/verification' +import type * as bsky from '#/types/bsky' + +export {useDialogControl} from '#/components/Dialog' + +export function VerifierDialog({ + control, + profile, + verificationState, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + verificationState: FullVerificationState +}) { + return ( + + + + + ) +} + +function Inner({ + profile, + control, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + verificationState: FullVerificationState +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const {currentAccount} = useSession() + + const isSelf = profile.did === currentAccount?.did + const userName = getUserDisplayName(profile) + const label = isSelf + ? _(msg`You are a trusted verifier`) + : _(msg`${userName} is a trusted verifier`) + + return ( + + + + + + {_( + + + + + {label} + + + + Accounts with a scalloped blue check mark + + {NON_BREAKING_SPACE} + + {NON_BREAKING_SPACE} + + can verify others. These trusted verifiers are selected by + Bluesky. + + + + + + { + logger.metric('verification:learn-more', { + location: 'verifierDialog', + }) + }}> + + Learn more + + + + + + + + + ) +} diff --git a/src/components/verification/index.ts b/src/components/verification/index.ts new file mode 100644 index 000000000..7a83a160a --- /dev/null +++ b/src/components/verification/index.ts @@ -0,0 +1,113 @@ +import {useMemo} from 'react' + +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' +import {useSession} from '#/state/session' +import type * as bsky from '#/types/bsky' + +export type FullVerificationState = { + profile: { + role: 'default' | 'verifier' + isVerified: boolean + wasVerified: boolean + isViewer: boolean + showBadge: boolean + } + viewer: + | { + role: 'default' + isVerified: boolean + } + | { + role: 'verifier' + isVerified: boolean + hasIssuedVerification: boolean + } +} + +export function useFullVerificationState({ + profile, +}: { + profile: bsky.profile.AnyProfileView +}): FullVerificationState { + const {currentAccount} = useSession() + const currentAccountProfile = useCurrentAccountProfile() + const profileState = useSimpleVerificationState({profile}) + const viewerState = useSimpleVerificationState({ + profile: currentAccountProfile, + }) + + return useMemo(() => { + const verifications = profile.verification?.verifications || [] + const wasVerified = + profileState.role === 'default' && + !profileState.isVerified && + verifications.length > 0 + const hasIssuedVerification = Boolean( + viewerState && + viewerState.role === 'verifier' && + profileState.role === 'default' && + verifications.find(v => v.issuer === currentAccount?.did), + ) + + return { + profile: { + ...profileState, + wasVerified, + isViewer: profile.did === currentAccount?.did, + showBadge: profileState.showBadge, + }, + viewer: + viewerState.role === 'verifier' + ? { + role: 'verifier', + isVerified: viewerState.isVerified, + hasIssuedVerification, + } + : { + role: 'default', + isVerified: viewerState.isVerified, + }, + } + }, [profile, currentAccount, profileState, viewerState]) +} + +export type SimpleVerificationState = { + role: 'default' | 'verifier' + isVerified: boolean + showBadge: boolean +} + +export function useSimpleVerificationState({ + profile, +}: { + profile?: bsky.profile.AnyProfileView +}): SimpleVerificationState { + const preferences = usePreferencesQuery() + const prefs = useMemo( + () => preferences.data?.verificationPrefs || {hideBadges: false}, + [preferences.data?.verificationPrefs], + ) + return useMemo(() => { + if (!profile || !profile.verification) { + return { + role: 'default', + isVerified: false, + showBadge: false, + } + } + + const {verifiedStatus, trustedVerifierStatus} = profile.verification + const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus) + const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus) + const isVerified = + (isVerifiedUser && verifiedStatus === 'valid') || + (isVerifierUser && trustedVerifierStatus === 'valid') + + return { + role: isVerifierUser ? 'verifier' : 'default', + isVerified, + showBadge: prefs.hideBadges ? false : isVerified, + } + }, [profile, prefs]) +} -- cgit 1.4.1