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/Navigation.tsx | 9 + src/components/AccountList.tsx | 67 ++++-- src/components/Link.tsx | 6 +- src/components/ProfileCard.tsx | 25 +- src/components/ProfileHoverCard/index.web.tsx | 40 +++- src/components/Prompt.tsx | 26 ++- src/components/Typography.tsx | 4 +- .../nuxs/InitialVerificationAnnouncement.tsx | 194 ++++++++++++++++ src/components/dialogs/nuxs/index.tsx | 17 +- src/components/dms/MessagesListHeader.tsx | 49 ++-- src/components/icons/CircleCheck.tsx | 5 + src/components/icons/Sparkle.tsx | 5 + src/components/icons/VerifiedCheck.tsx | 30 +++ src/components/icons/VerifierCheck.tsx | 35 +++ 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 +++++++++ src/lib/constants.ts | 8 + src/lib/getUserDisplayName.ts | 10 + src/lib/routes/types.ts | 1 + src/logger/metrics.ts | 13 ++ src/routes.ts | 1 + src/screens/Messages/components/ChatListItem.tsx | 49 ++-- src/screens/Moderation/VerificationSettings.tsx | 96 ++++++++ src/screens/Moderation/index.tsx | 27 ++- src/screens/Profile/Header/EditProfileDialog.tsx | 27 ++- .../Profile/Header/ProfileHeaderStandard.tsx | 34 ++- .../Search/components/AutocompleteResults.tsx | 2 +- src/screens/Search/components/SearchHistory.tsx | 136 +++++++---- src/screens/Settings/Settings.tsx | 49 ++-- .../Settings/components/ChangeHandleDialog.tsx | 23 +- src/state/cache/profile-shadow.ts | 4 + src/state/queries/notifications/util.ts | 26 ++- src/state/queries/nuxs/definitions.ts | 6 + src/state/queries/preferences/const.ts | 7 +- src/state/queries/preferences/index.ts | 30 ++- src/state/queries/profile.ts | 31 +-- src/state/queries/useCurrentAccountProfile.tsx | 9 + .../useUpdateProfileVerificationCache.ts | 35 +++ .../verification/useVerificationCreateMutation.tsx | 53 +++++ .../useVerificationsRemoveMutation.tsx | 63 +++++ src/types/bsky/profile.ts | 2 +- src/view/com/composer/ComposerReplyTo.tsx | 110 ++++----- src/view/com/modals/EditProfile.tsx | 32 ++- .../com/notifications/NotificationFeedItem.tsx | 204 +++++++++++----- src/view/com/post-thread/PostThreadItem.tsx | 44 +++- src/view/com/profile/ProfileMenu.tsx | 52 ++++- src/view/com/util/PostMeta.tsx | 175 ++++++++------ src/view/shell/Drawer.tsx | 30 ++- 53 files changed, 2321 insertions(+), 390 deletions(-) create mode 100644 src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx create mode 100644 src/components/icons/CircleCheck.tsx create mode 100644 src/components/icons/Sparkle.tsx create mode 100644 src/components/icons/VerifiedCheck.tsx create mode 100644 src/components/icons/VerifierCheck.tsx 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 create mode 100644 src/lib/getUserDisplayName.ts create mode 100644 src/screens/Moderation/VerificationSettings.tsx create mode 100644 src/state/queries/useCurrentAccountProfile.tsx create mode 100644 src/state/queries/verification/useUpdateProfileVerificationCache.ts create mode 100644 src/state/queries/verification/useVerificationCreateMutation.tsx create mode 100644 src/state/queries/verification/useVerificationsRemoveMutation.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 277ffb07e..d4fdc4797 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -70,6 +70,7 @@ import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' +import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings' import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostQuotesScreen} from '#/screens/Post/PostQuotes' @@ -167,6 +168,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + ModerationVerificationSettings} + options={{ + title: title(msg`Verification Settings`), + requireAuth: true, + }} + /> SettingsScreen} diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 52d149eb5..f2e781ccf 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import {View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -12,6 +12,8 @@ import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' import {Button} from './Button' import {Text} from './Typography' @@ -74,11 +76,13 @@ export function AccountList({ ]}> {otherLabel ?? Other account} @@ -105,6 +109,7 @@ function AccountItem({ }) { const t = useTheme() const {_} = useLingui() + const verification = useSimpleVerificationState({profile}) const onPress = useCallback(() => { onSelect(account) @@ -114,7 +119,7 @@ function AccountItem({ + )} + + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 10cae887b..c8c539b85 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -1,16 +1,17 @@ import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' import { usePreferencesQuery, - UsePreferencesQueryResponse, + type UsePreferencesQueryResponse, } from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' -import {SessionAccount, useSession} from '#/state/session' +import {type SessionAccount, useSession} from '#/state/session' import {useOnboardingState} from '#/state/shell' +import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement' /* * NUXs */ @@ -29,7 +30,12 @@ const queuedNuxs: { currentProfile: AppBskyActorDefs.ProfileViewDetailed preferences: UsePreferencesQueryResponse }) => boolean -}[] = [] +}[] = [ + { + id: Nux.InitialVerificationAnnouncement, + enabled: () => true, + }, +] const Context = React.createContext({ activeNux: undefined, @@ -163,6 +169,9 @@ function Inner({ return ( {/*For example, activeNux === Nux.NeueTypography && */} + {activeNux === Nux.InitialVerificationAnnouncement && ( + + )} ) } diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 8da8c015f..c8ed98f88 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -1,9 +1,9 @@ import React, {useCallback} from 'react' import {TouchableOpacity, View} from 'react-native' import { - AppBskyActorDefs, - ModerationCause, - ModerationDecision, + type AppBskyActorDefs, + type ModerationCause, + type ModerationDecision, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' @@ -12,12 +12,12 @@ import {useNavigation} from '@react-navigation/native' import {BACK_HITSLOP} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {isWeb} from '#/platform/detection' -import {Shadow} from '#/state/cache/profile-shadow' +import {type Shadow} from '#/state/cache/profile-shadow' import {isConvoActive, useConvo} from '#/state/messages/convo' -import {ConvoItem} from '#/state/messages/convo/types' +import {type ConvoItem} from '#/state/messages/convo/types' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {ConvoMenu} from '#/components/dms/ConvoMenu' @@ -25,6 +25,8 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/ import {Link} from '#/components/Link' import {PostAlerts} from '#/components/moderation/PostAlerts' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' const PFP_SIZE = isWeb ? 40 : 34 @@ -149,6 +151,9 @@ function HeaderReady({ const {_} = useLingui() const t = useTheme() const convoState = useConvo() + const verification = useSimpleVerificationState({ + profile, + }) const isDeletedAccount = profile?.handle === 'missing.invalid' const displayName = isDeletedAccount @@ -185,17 +190,27 @@ function HeaderReady({ /> - - {displayName} - + + + {displayName} + + {verification.showBadge && ( + + + + )} + {!isDeletedAccount && ( (function LogoImpl( + props, + ref, +) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + + + + + ) +}) diff --git a/src/components/icons/VerifierCheck.tsx b/src/components/icons/VerifierCheck.tsx new file mode 100644 index 000000000..7c3a0149d --- /dev/null +++ b/src/components/icons/VerifierCheck.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import Svg, {Path} from 'react-native-svg' + +import {type Props, useCommonSVGProps} from '#/components/icons/common' + +export const VerifierCheck = React.forwardRef(function LogoImpl( + props, + ref, +) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + + + + + ) +}) 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]) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fe84f41b2..bb98f9fc8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -192,3 +192,11 @@ export const SUPPORTED_MIME_TYPES = [ export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number] export const EMOJI_REACTION_LIMIT = 5 + +export const urls = { + website: { + blog: { + initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`, + }, + }, +} diff --git a/src/lib/getUserDisplayName.ts b/src/lib/getUserDisplayName.ts new file mode 100644 index 000000000..790bb31e5 --- /dev/null +++ b/src/lib/getUserDisplayName.ts @@ -0,0 +1,10 @@ +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' + +export function getUserDisplayName< + T extends {displayName?: string; handle: string; [key: string]: any}, +>(props: T): string { + return sanitizeDisplayName( + props.displayName || sanitizeHandle(props.handle, '@'), + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 658b68db8..0bc85b630 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -13,6 +13,7 @@ export type CommonNavigatorParams = { ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined ModerationInteractionSettings: undefined + ModerationVerificationSettings: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 2b696c1e4..42b0d6ef3 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -370,4 +370,17 @@ export type MetricEvents = { targetLanguage: string textLength: number } + + 'verification:create': {} + 'verification:revoke': {} + 'verification:badge:click': {} + 'verification:learn-more': { + location: + | 'initialAnnouncementeNux' + | 'verificationsDialog' + | 'verifierDialog' + | 'verificationSettings' + } + 'verification:settings:hideBadges': {} + 'verification:settings:unHideBadges': {} } diff --git a/src/routes.ts b/src/routes.ts index 68c39e7fc..60bb65dd5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -14,6 +14,7 @@ export const router = new Router({ ModerationMutedAccounts: '/moderation/muted-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts', ModerationInteractionSettings: '/moderation/interaction-settings', + ModerationVerificationSettings: '/moderation/verification-settings', // profiles, threads, lists Profile: ['/profile/:name', '/profile/:name/rss'], ProfileFollowers: '/profile/:name/followers', diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 8a760e2c9..09cf2dccd 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -43,6 +43,8 @@ import {Link} from '#/components/Link' import {useMenuControl} from '#/components/Menu' import {PostAlerts} from '#/components/moderation/PostAlerts' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' import type * as bsky from '#/types/bsky' export let ChatListItem = ({ @@ -106,6 +108,9 @@ function ChatListItemReady({ const playHaptic = useHaptics() const queryClient = useQueryClient() const isUnread = convo.unreadCount > 0 + const verification = useSimpleVerificationState({ + profile, + }) const blockInfo = useMemo(() => { const modui = moderation.ui('profileView') @@ -385,11 +390,10 @@ function ChatListItemReady({ - + {displayName} - + + {verification.showBadge && ( + + + + )} {lastMessageSentAt && ( - - {({timeElapsed}) => ( - - {' '} - · {timeElapsed} - - )} - + + + {({timeElapsed}) => ( + + · {timeElapsed} + + )} + + )} {(convo.muted || moderation.blocked) && ( + + + + + Verification Settings + + + + + + + + + + Verifications on Bluesky work differently than on other + platforms.{' '} + { + logger.metric('verification:learn-more', { + location: 'verificationSettings', + }) + }}> + Learn more here. + + + + + {preferences ? ( + + ) : ( + + + + )} + + + + ) +} + +function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { + const {_} = useLingui() + const {hideBadges} = preferences.verificationPrefs + const {mutate: setVerificationPrefs, isPending} = + useSetVerificationPrefsMutation() + + return ( + { + setVerificationPrefs({hideBadges: value}) + }}> + + + + Hide verification badges + + + + + ) +} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 55cc67f8c..78b0a6ae9 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -6,19 +6,22 @@ import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import {getLabelingServiceTitle} from '#/lib/moderation' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' import { useMyLabelersQuery, usePreferencesQuery, - UsePreferencesQueryResponse, + type UsePreferencesQueryResponse, usePreferencesSetAdultContentMutation, } from '#/state/queries/preferences' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' import {ViewHeader} from '#/view/com/util/ViewHeader' -import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf' +import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' @@ -27,7 +30,8 @@ import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' -import {Props as SVGIconProps} from '#/components/icons/common' +import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' +import {type Props as SVGIconProps} from '#/components/icons/common' import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' @@ -274,6 +278,21 @@ export function ModerationScreenInner({ /> )} + + + {state => ( + + )} + + {verification.isVerified && + verification.role === 'default' && + displayName !== initialDisplayName && ( + + + You are verified. You will lose your verification status if you + change your display name.{' '} + + Learn more. + + + + )} + Description diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 773c296c9..1c4c4d9f3 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -10,6 +10,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {isIOS, isWeb} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -22,7 +23,7 @@ import { import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' -import {atoms as a} from '#/alf' +import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {MessageProfileButton} from '#/components/dms/MessageProfileButton' @@ -33,7 +34,8 @@ import { } from '#/components/KnownFollowers' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' -import {ProfileHeaderDisplayName} from './DisplayName' +import {Text} from '#/components/Typography' +import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {EditProfileDialog} from './EditProfileDialog' import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderMetrics} from './Metrics' @@ -54,6 +56,8 @@ let ProfileHeaderStandard = ({ hideBackButton = false, isPlaceholderProfile, }: Props): React.ReactNode => { + const t = useTheme() + const {gtMobile} = useBreakpoints() const profile: Shadow = useProfileShadow(profileUnshadowed) const {currentAccount, hasSession} = useSession() @@ -238,7 +242,31 @@ let ProfileHeaderStandard = ({ - + + + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + + + + + {!isPlaceholderProfile && !isBlockedUser && ( diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx index b3bccd1d4..2824ccc1b 100644 --- a/src/screens/Search/components/AutocompleteResults.tsx +++ b/src/screens/Search/components/AutocompleteResults.tsx @@ -49,7 +49,7 @@ let AutocompleteResults = ({ ? undefined : `/search?q=${encodeURIComponent(searchText)}` } - style={{borderBottomWidth: 1}} + style={a.border_b} /> {autocompleteData?.map(item => ( void }) { const {gtMobile} = useBreakpoints() - const t = useTheme() const {_} = useLingui() + const moderationOpts = useModerationOpts() return ( - {selectedProfiles.slice(0, 5).map((profile, index) => ( - - onProfileClick(profile)} - style={[a.align_center, a.w_full]}> - ( + onProfileClick(profile)} + onRemove={() => onRemoveProfileClick(profile)} /> - - {sanitizeDisplayName( - profile.displayName || profile.handle, - )} - - - onRemoveProfileClick(profile)} - hitSlop={createHitslop(6)} - style={styles.profileRemoveBtn}> - - - - ))} + ))} @@ -134,6 +111,81 @@ export function SearchHistory({ ) } +function RecentProfileItem({ + profile, + moderationOpts, + onPress, + onRemove, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + onPress: () => void + onRemove: () => void +}) { + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const t = useTheme() + + const moderation = moderateProfile(profile, moderationOpts) + const name = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + const verification = useSimpleVerificationState({profile}) + + return ( + + + + + + + {name} + + {verification.showBadge && ( + + + + )} + + + + + + + + ) +} + const styles = StyleSheet.create({ selectedProfilesContainer: { marginTop: 10, diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 956413a55..a723aaa37 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -29,7 +29,7 @@ import {useCloseAllActiveElements} from '#/state/util' import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import * as SettingsList from '#/screens/Settings/components/SettingsList' -import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' import {AvatarStackWithFetch} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' @@ -55,6 +55,11 @@ import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' +import {useFullVerificationState} from '#/components/verification' +import { + shouldShowVerificationCheckButton, + VerificationCheckButton, +} from '#/components/verification/VerificationCheckButton' type Props = NativeStackScreenProps export function SettingsScreen({}: Props) { @@ -278,6 +283,9 @@ function ProfilePreview({ const {gtMobile} = useBreakpoints() const shadow = useProfileShadow(profile) const moderationOpts = useModerationOpts() + const verificationState = useFullVerificationState({ + profile: shadow, + }) if (!moderationOpts) return null @@ -292,20 +300,33 @@ function ProfilePreview({ type={shadow.associated?.labeler ? 'labeler' : 'user'} /> - - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), + + + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + + {shouldShowVerificationCheckButton(verificationState) && ( + + + )} - + {sanitizeHandle(profile.handle, '@')} diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index b69713a10..a39d958ab 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -10,18 +10,19 @@ import Animated, { SlideOutLeft, SlideOutRight, } from 'react-native-reanimated' -import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useMutation, useQueryClient} from '@tanstack/react-query' -import {HITSLOP_10} from '#/lib/constants' +import {HITSLOP_10, urls} from '#/lib/constants' import {cleanError} from '#/lib/strings/errors' import {createFullHandle, validateServiceHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles' import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useServiceQuery} from '#/state/queries/service' +import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useAgent, useSession} from '#/state/session' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' @@ -40,6 +41,7 @@ import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/compone import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' import {CopyButton} from './CopyButton' export function ChangeHandleDialog({ @@ -152,6 +154,10 @@ function ProvidedHandlePage({ const control = Dialog.useDialogContext() const {currentAccount} = useSession() const queryClient = useQueryClient() + const profile = useCurrentAccountProfile() + const verification = useSimpleVerificationState({ + profile, + }) const { mutate: changeHandle, @@ -197,6 +203,19 @@ function ProvidedHandlePage({ + {verification.isVerified && verification.role === 'default' && ( + + + You are verified. You will lose your verification status if you + change your handle.{' '} + + Learn more. + + + + )} New handle diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 82ee44388..9c23e4550 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -1,4 +1,5 @@ import {useEffect, useMemo, useState} from 'react' +import {type AppBskyActorDefs} from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' @@ -29,6 +30,7 @@ export interface ProfileShadow { followingUri: string | undefined muted: boolean | undefined blockingUri: string | undefined + verification: AppBskyActorDefs.VerificationState } const shadows: WeakMap< @@ -134,6 +136,8 @@ function mergeShadow( blocking: 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking, }, + verification: + 'verification' in shadow ? shadow.verification : profile.verification, }) } diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index f6f53f58f..6bbf9b250 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -1,22 +1,26 @@ import { - AppBskyFeedDefs, + type AppBskyFeedDefs, AppBskyFeedLike, AppBskyFeedPost, AppBskyFeedRepost, - AppBskyGraphDefs, + type AppBskyGraphDefs, AppBskyGraphStarterpack, - AppBskyNotificationListNotifications, - BskyAgent, + type AppBskyNotificationListNotifications, + type BskyAgent, moderateNotification, - ModerationOpts, + type ModerationOpts, } from '@atproto/api' -import {QueryClient} from '@tanstack/react-query' +import {type QueryClient} from '@tanstack/react-query' import chunk from 'lodash.chunk' import {labelIsHideableOffense} from '#/lib/moderation' import * as bsky from '#/types/bsky' import {precacheProfile} from '../profile' -import {FeedNotification, FeedPage, NotificationType} from './types' +import { + type FeedNotification, + type FeedPage, + type NotificationType, +} from './types' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const MS_1HR = 1e3 * 60 * 60 @@ -155,14 +159,14 @@ export function groupNotifications( const type = toKnownType(notif) if (type !== 'starterpack-joined') { groupedNotifs.push({ - _reactKey: `notif-${notif.uri}`, + _reactKey: `notif-${notif.uri}-${notif.reason}`, type, notification: notif, subjectUri: getSubjectUri(type, notif), }) } else { groupedNotifs.push({ - _reactKey: `notif-${notif.uri}`, + _reactKey: `notif-${notif.uri}-${notif.reason}`, type: 'starterpack-joined', notification: notif, subjectUri: notif.uri, @@ -238,7 +242,9 @@ function toKnownType( notif.reason === 'reply' || notif.reason === 'quote' || notif.reason === 'follow' || - notif.reason === 'starterpack-joined' + notif.reason === 'starterpack-joined' || + notif.reason === 'verified' || + notif.reason === 'unverified' ) { return notif.reason as NotificationType } diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 8eb53a0a4..a44ffa4c5 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -5,6 +5,7 @@ import {type BaseNux} from '#/state/queries/nuxs/types' export enum Nux { NeueTypography = 'NeueTypography', ExploreInterestsCard = 'ExploreInterestsCard', + InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', } export const nuxNames = new Set(Object.values(Nux)) @@ -18,9 +19,14 @@ export type AppNux = BaseNux< id: Nux.ExploreInterestsCard data: undefined } + | { + id: Nux.InitialVerificationAnnouncement + data: undefined + } > export const NuxSchemas: Record | undefined> = { [Nux.NeueTypography]: undefined, [Nux.ExploreInterestsCard]: undefined, + [Nux.InitialVerificationAnnouncement]: undefined, } diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 3c1fead5e..84b208a9f 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -1,7 +1,7 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import { - ThreadViewPreferences, - UsePreferencesQueryResponse, + type ThreadViewPreferences, + type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = @@ -43,4 +43,7 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { threadgateAllowRules: undefined, postgateEmbeddingRules: [], }, + verificationPrefs: { + hideBadges: false, + }, } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 81b3dd086..daab5eca3 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,7 +1,7 @@ import { - AppBskyActorDefs, - BskyFeedViewPreference, - LabelPreference, + type AppBskyActorDefs, + type BskyFeedViewPreference, + type LabelPreference, } from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' @@ -16,8 +16,8 @@ import { DEFAULT_THREAD_VIEW_PREFS, } from '#/state/queries/preferences/const' import { - ThreadViewPreferences, - UsePreferencesQueryResponse, + type ThreadViewPreferences, + type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' import {useAgent} from '#/state/session' import {saveLabelers} from '#/state/session/agent-config' @@ -407,3 +407,23 @@ export function useSetActiveProgressGuideMutation() { }, }) } + +export function useSetVerificationPrefsMutation() { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async prefs => { + await agent.setVerificationPrefs(prefs) + if (prefs.hideBadges) { + logger.metric('verification:settings:hideBadges', {}) + } else { + logger.metric('verification:settings:unHideBadges', {}) + } + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 2cf144d3a..609a62e25 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -1,18 +1,18 @@ import {useCallback} from 'react' -import {Image as RNImage} from 'react-native-image-crop-picker' +import {type Image as RNImage} from 'react-native-image-crop-picker' import { - AppBskyActorDefs, - AppBskyActorGetProfile, - AppBskyActorGetProfiles, - AppBskyActorProfile, + type AppBskyActorDefs, + type AppBskyActorGetProfile, + type AppBskyActorGetProfiles, + type AppBskyActorProfile, AtUri, - BskyAgent, - ComAtprotoRepoUploadBlob, - Un$Typed, + type BskyAgent, + type ComAtprotoRepoUploadBlob, + type Un$Typed, } from '@atproto/api' import { keepPreviousData, - QueryClient, + type QueryClient, useMutation, useQuery, useQueryClient, @@ -21,16 +21,17 @@ import { import {uploadBlob} from '#/lib/api' import {until} from '#/lib/async/until' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' -import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig' -import {Shadow} from '#/state/cache/types' +import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' +import {type Shadow} from '#/state/cache/types' import {STALE} from '#/state/queries' import {resetProfilePostsQueries} from '#/state/queries/post-feed' import { unstableCacheProfileView, useUnstableProfileViewCache, } from '#/state/queries/unstable-profile-cache' +import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' import * as userActionHistory from '#/state/userActionHistory' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' import {updateProfileShadow} from '../cache/profile-shadow' import {useAgent, useSession} from '../session' import { @@ -50,7 +51,7 @@ export const precacheProfile = unstableCacheProfileView const RQKEY_ROOT = 'profile' export const RQKEY = (did: string) => [RQKEY_ROOT, did] -const profilesQueryKeyRoot = 'profiles' +export const profilesQueryKeyRoot = 'profiles' export const profilesQueryKey = (handles: string[]) => [ profilesQueryKeyRoot, handles, @@ -137,6 +138,7 @@ interface ProfileUpdateParams { export function useProfileUpdateMutation() { const queryClient = useQueryClient() const agent = useAgent() + const updateProfileVerificationCache = useUpdateProfileVerificationCache() return useMutation({ mutationFn: async ({ profile, @@ -223,7 +225,7 @@ export function useProfileUpdateMutation() { }), ) }, - onSuccess(data, variables) { + async onSuccess(_, variables) { // invalidate cache queryClient.invalidateQueries({ queryKey: RQKEY(variables.profile.did), @@ -231,6 +233,7 @@ export function useProfileUpdateMutation() { queryClient.invalidateQueries({ queryKey: [profilesQueryKeyRoot, [variables.profile.did]], }) + await updateProfileVerificationCache({profile: variables.profile}) }, }) } diff --git a/src/state/queries/useCurrentAccountProfile.tsx b/src/state/queries/useCurrentAccountProfile.tsx new file mode 100644 index 000000000..d1f562efc --- /dev/null +++ b/src/state/queries/useCurrentAccountProfile.tsx @@ -0,0 +1,9 @@ +import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' + +export function useCurrentAccountProfile() { + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + return useMaybeProfileShadow(profile) +} diff --git a/src/state/queries/verification/useUpdateProfileVerificationCache.ts b/src/state/queries/verification/useUpdateProfileVerificationCache.ts new file mode 100644 index 000000000..f5ccf1458 --- /dev/null +++ b/src/state/queries/verification/useUpdateProfileVerificationCache.ts @@ -0,0 +1,35 @@ +import {useCallback} from 'react' +import {useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useAgent} from '#/state/session' +import type * as bsky from '#/types/bsky' + +/** + * Fetches a fresh verification state from the app view and updates our profile + * cache. This state is computed using a variety of factors on the server, so + * we need to get this data from the server. + */ +export function useUpdateProfileVerificationCache() { + const qc = useQueryClient() + const agent = useAgent() + + return useCallback( + async ({profile}: {profile: bsky.profile.AnyProfileView}) => { + try { + const {data: updated} = await agent.getProfile({ + actor: profile.did ?? '', + }) + updateProfileShadow(qc, profile.did, { + verification: updated.verification, + }) + } catch (e) { + logger.error(`useUpdateProfileVerificationCache failed`, { + safeMessage: e, + }) + } + }, + [agent, qc], + ) +} diff --git a/src/state/queries/verification/useVerificationCreateMutation.tsx b/src/state/queries/verification/useVerificationCreateMutation.tsx new file mode 100644 index 000000000..1048eb9d2 --- /dev/null +++ b/src/state/queries/verification/useVerificationCreateMutation.tsx @@ -0,0 +1,53 @@ +import {type AppBskyActorGetProfile} from '@atproto/api' +import {useMutation} from '@tanstack/react-query' + +import {until} from '#/lib/async/until' +import {logger} from '#/logger' +import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' +import {useAgent, useSession} from '#/state/session' +import type * as bsky from '#/types/bsky' + +export function useVerificationCreateMutation() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateProfileVerificationCache = useUpdateProfileVerificationCache() + + return useMutation({ + async mutationFn({profile}: {profile: bsky.profile.AnyProfileView}) { + if (!currentAccount) { + throw new Error('User not logged in') + } + + const {uri} = await agent.app.bsky.graph.verification.create( + {repo: currentAccount.did}, + { + subject: profile.did, + createdAt: new Date().toISOString(), + handle: profile.handle, + 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 ?? ''}) + }, + ) + }, + async onSuccess(_, {profile}) { + logger.metric('verification:create', {}) + await updateProfileVerificationCache({profile}) + }, + }) +} diff --git a/src/state/queries/verification/useVerificationsRemoveMutation.tsx b/src/state/queries/verification/useVerificationsRemoveMutation.tsx new file mode 100644 index 000000000..936c786c9 --- /dev/null +++ b/src/state/queries/verification/useVerificationsRemoveMutation.tsx @@ -0,0 +1,63 @@ +import { + type AppBskyActorDefs, + type AppBskyActorGetProfile, + AtUri, +} from '@atproto/api' +import {useMutation} from '@tanstack/react-query' + +import {until} from '#/lib/async/until' +import {logger} from '#/logger' +import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' +import {useAgent, useSession} from '#/state/session' +import type * as bsky from '#/types/bsky' + +export function useVerificationsRemoveMutation() { + const agent = useAgent() + const {currentAccount} = useSession() + const updateProfileVerificationCache = useUpdateProfileVerificationCache() + + return useMutation({ + async mutationFn({ + profile, + verifications, + }: { + profile: bsky.profile.AnyProfileView + verifications: AppBskyActorDefs.VerificationView[] + }) { + if (!currentAccount) { + throw new Error('User not logged in') + } + + const uris = verifications.map(v => v.uri) + + await Promise.all( + uris.map(uri => { + return agent.app.bsky.graph.verification.delete({ + repo: currentAccount.did, + rkey: new AtUri(uri).rkey, + }) + }), + ) + + 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 ?? ''}) + }, + ) + }, + async onSuccess(_, {profile}) { + logger.metric('verification:revoke', {}) + await updateProfileVerificationCache({profile}) + }, + }) +} diff --git a/src/types/bsky/profile.ts b/src/types/bsky/profile.ts index 7449f117e..12c8146ae 100644 --- a/src/types/bsky/profile.ts +++ b/src/types/bsky/profile.ts @@ -1,4 +1,4 @@ -import {AppBskyActorDefs, ChatBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api' /** * Matches any profile view exported by our SDK diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 2766fe625..5da530768 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native' +import {useCallback, useMemo, useState} from 'react' +import {LayoutAnimation, Pressable, View} from 'react-native' import {Image} from 'expo-image' import { AppBskyEmbedImages, @@ -12,20 +12,22 @@ import {useLingui} from '@lingui/react' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' -import {ComposerOptsPostRef} from '#/state/shell/composer' +import {type ComposerOptsPostRef} from '#/state/shell/composer' import {MaybeQuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed' -import {Text} from '#/view/com/util/text/Text' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const t = useTheme() const {_} = useLingui() const {embed} = replyTo - const [showFull, setShowFull] = React.useState(false) + const [showFull, setShowFull] = useState(false) - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { setShowFull(prev => !prev) LayoutAnimation.configureNext({ duration: 350, @@ -33,7 +35,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { }) }, []) - const quoteEmbed = React.useMemo(() => { + const quoteEmbed = useMemo(() => { if ( AppBskyEmbedRecord.isView(embed) && AppBskyEmbedRecord.isViewRecord(embed.record) && @@ -50,7 +52,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { return null }, [embed]) - const images = React.useMemo(() => { + const images = useMemo(() => { if (AppBskyEmbedImages.isView(embed)) { return embed.images } else if ( @@ -61,17 +63,26 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { } }, [embed]) + const verification = useSimpleVerificationState({profile: replyTo.author}) + return ( + accessibilityHint=""> - - - {sanitizeDisplayName( - replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), + + + + {sanitizeDisplayName( + replyTo.author.displayName || + sanitizeHandle(replyTo.author.handle), + )} + + {verification.showBadge && ( + + + )} - - - + + + {replyTo.text} @@ -112,7 +136,17 @@ function ComposerReplyToImages({ showFull: boolean }) { return ( - + {(images.length === 1 && ( ) } - -const styles = StyleSheet.create({ - replyToLayout: { - flexDirection: 'row', - alignItems: 'flex-start', - borderBottomWidth: StyleSheet.hairlineWidth, - paddingTop: 4, - paddingBottom: 16, - marginBottom: 12, - marginHorizontal: 16, - }, - replyToPost: { - flex: 1, - paddingLeft: 13, - paddingRight: 8, - }, - replyToBody: { - flexDirection: 'row', - gap: 10, - }, - replyToText: { - flex: 1, - flexGrow: 1, - }, - imagesContainer: { - borderRadius: 6, - overflow: 'hidden', - marginTop: 2, - height: 64, - width: 64, - }, -}) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 2b9969b54..8cc2d31ec 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -8,14 +8,14 @@ import { TouchableOpacity, View, } from 'react-native' -import {Image as RNImage} from 'react-native-image-crop-picker' +import {type Image as RNImage} from 'react-native-image-crop-picker' import Animated, {FadeOut} from 'react-native-reanimated' import {LinearGradient} from 'expo-linear-gradient' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from '#/lib/constants' +import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {compressIfNeeded} from '#/lib/media/manip' import {cleanError} from '#/lib/strings/errors' @@ -30,6 +30,9 @@ import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' import {EditableUserAvatar} from '#/view/com/util/UserAvatar' import {UserBanner} from '#/view/com/util/UserBanner' +import {Admonition} from '#/components/Admonition' +import {InlineLinkText} from '#/components/Link' +import {useSimpleVerificationState} from '#/components/verification' import {ErrorMessage} from '../util/error/ErrorMessage' const AnimatedTouchableOpacity = @@ -139,6 +142,10 @@ export function Component({ setImageError, _, ]) + const verification = useSimpleVerificationState({ + profile, + }) + const [touchedDisplayName, setTouchedDisplayName] = useState(false) return ( @@ -186,7 +193,26 @@ export function Component({ accessible={true} accessibilityLabel={_(msg`Display name`)} accessibilityHint={_(msg`Edit your display name`)} + onFocus={() => setTouchedDisplayName(true)} /> + + {verification.isVerified && + verification.role === 'default' && + touchedDisplayName && ( + + + + You are verified. You will lose your verification status + if you change your display name.{' '} + + Learn more. + + + + + )} diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 8875ec02e..1de0b67b3 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -49,7 +49,7 @@ import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, platform, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, @@ -59,12 +59,15 @@ import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/compon import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPack} from '#/components/icons/StarterPack' +import {VerifiedCheck} from '#/components/icons/VerifiedCheck' import {InlineLinkText, Link} from '#/components/Link' import * as MediaPreview from '#/components/MediaPreview' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {SubtleWebHover} from '#/components/SubtleWebHover' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' import * as bsky from '#/types/bsky' const MAX_AUTHORS = 5 @@ -145,6 +148,9 @@ let NotificationFeedItem = ({ const niceTimestamp = niceDate(i18n, item.notification.indexedAt) const firstAuthor = authors[0] + const firstAuthorVerification = useSimpleVerificationState({ + profile: firstAuthor.profile, + }) const firstAuthorName = sanitizeDisplayName( firstAuthor.profile.displayName || firstAuthor.profile.handle, ) @@ -186,6 +192,24 @@ let NotificationFeedItem = ({ emoji label={_(msg`Go to ${firstAuthorName}'s profile`)}> {forceLTR(firstAuthorName)} + {firstAuthorVerification.showBadge && ( + + + + )} ) const additionalAuthorsCount = authors.length - 1 @@ -366,6 +390,60 @@ let NotificationFeedItem = ({ ) + // @ts-ignore TODO + } else if (item.type === 'verified') { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} verified you`, + ) + : _(msg`${firstAuthorName} verified you`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + verified you + + ) : ( + {firstAuthorLink} verified you + ) + icon = + // @ts-ignore TODO + } else if (item.type === 'unverified') { + a11yLabel = hasMultipleAuthors + ? _( + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { + one: `${formattedAuthorsCount} other`, + other: `${formattedAuthorsCount} others`, + })} removed their verifications from your account`, + ) + : _(msg`${firstAuthorName} removed their verification from your account`) + notificationContent = hasMultipleAuthors ? ( + + {firstAuthorLink} and{' '} + + + {' '} + removed their verifications from your account + + ) : ( + + {firstAuthorLink} removed their verification from your account + + ) + icon = } else { return null } @@ -447,7 +525,6 @@ let NotificationFeedItem = ({ style={[ a.flex_row, a.flex_wrap, - a.pb_2xs, {paddingTop: 6}, a.self_start, a.text_md, @@ -475,7 +552,9 @@ let NotificationFeedItem = ({ {item.type === 'post-like' || item.type === 'repost' ? ( - + + + ) : null} {item.type === 'feedgen-like' && item.subjectUri ? ( {visible && authors.map(author => ( - - - - - - - - - - {sanitizeDisplayName( - author.profile.displayName || author.profile.handle, - )} - - - {sanitizeHandle(author.profile.handle, '@')} - - - - + ))} ) } +function ExpandedAuthorCard({author}: {author: Author}) { + const t = useTheme() + const {_} = useLingui() + const verification = useSimpleVerificationState({ + profile: author.profile, + }) + return ( + + + + + + + + + + {sanitizeDisplayName( + author.profile.displayName || author.profile.handle, + )} + + {verification.showBadge && ( + + + + )} + + {sanitizeHandle(author.profile.handle, '@')} + + + + + ) +} + function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const t = useTheme() if ( @@ -761,7 +857,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { {text?.length > 0 && ( + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> {text} )} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 3c8fa31ed..dfd641f66 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -32,6 +32,7 @@ import { type Shadow, usePostShadow, } from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {useLanguagePrefs} from '#/state/preferences' import {type ThreadPost} from '#/state/queries/post-thread' import {useSession} from '#/state/session' @@ -62,6 +63,7 @@ import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import {Text} from '#/components/Typography' +import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {WhoCanReply} from '#/components/WhoCanReply' import * as bsky from '#/types/bsky' @@ -207,6 +209,7 @@ let PostThreadItemLoaded = ({ () => countLines(richText?.text) >= MAX_POST_LINES, ) const {currentAccount} = useSession() + const shadowedPostAuthor = useProfileShadow(post.author) const rootUri = record.reply?.root?.uri || post.uri const postHref = React.useMemo(() => { const urip = new AtUri(post.uri) @@ -329,18 +332,35 @@ let PostThreadItemLoaded = ({ type={post.author.associated?.labeler ? 'labeler' : 'user'} /> - - - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} - - + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + + { + return v.issuer === currentAccount?.did + }) ?? [] + return ( @@ -277,6 +290,29 @@ let ProfileMenu = ({ + {verification.viewer.role === 'verifier' && + !verification.profile.isViewer && + (verification.viewer.hasIssuedVerification ? ( + verificationRemovePromptControl.open()}> + + Remove verification + + + + ) : ( + verificationCreatePromptControl.open()}> + + Verify account + + + + ))} {!isSelf && ( <> {!profile.viewer?.blocking && @@ -410,6 +446,16 @@ let ProfileMenu = ({ onConfirm={onPressShare} confirmButtonCta={_(msg`Share anyway`)} /> + + + ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 30180b889..d5af32236 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,9 +1,10 @@ -import React, {memo, useCallback} from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' -import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' +import {memo, useCallback} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import type React from 'react' import {makeProfileLink} from '#/lib/routes/links' import {forceLTR} from '#/lib/strings/bidi' @@ -12,11 +13,14 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {isAndroid} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {precacheProfile} from '#/state/queries/profile' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, platform, useTheme, web} from '#/alf' import {WebOnlyInlineLinkText} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' import {TimeElapsed} from './TimeElapsed' import {PreviewableUserAvatar} from './UserAvatar' @@ -35,20 +39,22 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { const t = useTheme() const {i18n, _} = useLingui() - const displayName = opts.author.displayName || opts.author.handle - const handle = opts.author.handle - const profileLink = makeProfileLink(opts.author) + const author = useProfileShadow(opts.author) + const displayName = author.displayName || author.handle + const handle = author.handle + const profileLink = makeProfileLink(author) const queryClient = useQueryClient() const onOpenAuthor = opts.onOpenAuthor const onBeforePressAuthor = useCallback(() => { - precacheProfile(queryClient, opts.author) + precacheProfile(queryClient, author) onOpenAuthor?.() - }, [queryClient, opts.author, onOpenAuthor]) + }, [queryClient, author, onOpenAuthor]) const onBeforePressPost = useCallback(() => { - precacheProfile(queryClient, opts.author) - }, [queryClient, opts.author]) + precacheProfile(queryClient, author) + }, [queryClient, author]) const timestampLabel = niceDate(i18n, opts.timestamp) + const verification = useSimpleVerificationState({profile: author}) return ( { a.flex_1, a.flex_row, a.align_center, - a.pb_2xs, + a.pb_xs, a.gap_xs, - a.z_10, + a.z_20, opts.style, ]}> {opts.showAvatar && ( )} - - - - + + + + {forceLTR( sanitizeDisplayName( displayName, opts.moderation?.ui('displayName'), ), )} - - - - + + {verification.showBadge && ( + + + + )} + {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')} - - - - + + + - {!isAndroid && ( - - · - - )} - - - {({timeElapsed}) => ( - - {timeElapsed} - - )} - + + {({timeElapsed}) => ( + + {!isAndroid && ( + + ·{' '} + + )} + {timeElapsed} + + )} + + ) } diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index e38cb217c..d51db3960 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -51,6 +51,8 @@ import { } from '#/components/icons/UserCircle' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' const iconWidth = 26 @@ -64,6 +66,7 @@ let DrawerProfileCard = ({ const {_, i18n} = useLingui() const t = useTheme() const {data: profile} = useProfileQuery({did: account.did}) + const verification = useSimpleVerificationState({profile}) return ( + style={[a.gap_sm, a.pr_lg]}> - - {profile?.displayName || account.handle} - + + + {profile?.displayName || account.handle} + + {verification.showBadge && ( + + + + )} +