diff options
author | Eric Bailey <git@esb.lol> | 2025-04-18 21:15:32 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-04-18 19:15:32 -0700 |
commit | 0ac15920a477a5c8090fd2b929b36ac0b6e02c34 (patch) | |
tree | debd067ccc0f3f5f814d8ec10082e41034d47c7c /src | |
parent | f1e44ee12e0ccde71e616121708e70462351f068 (diff) | |
download | voidsky-0ac15920a477a5c8090fd2b929b36ac0b6e02c34.tar.zst |
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 <mozzius@protonmail.com> Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to 'src')
53 files changed, 2321 insertions, 390 deletions
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' @@ -168,6 +169,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { }} /> <Stack.Screen + name="ModerationVerificationSettings" + getComponent={() => ModerationVerificationSettings} + options={{ + title: title(msg`Verification Settings`), + requireAuth: true, + }} + /> + <Stack.Screen name="Settings" getComponent={() => SettingsScreen} options={{title: title(msg`Settings`), requireAuth: true}} 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({ ]}> <Text style={[ - a.align_baseline, + a.font_bold, a.flex_1, a.flex_row, a.py_sm, - {paddingLeft: 48}, + a.leading_tight, + t.atoms.text_contrast_medium, + {paddingLeft: 56}, ]}> {otherLabel ?? <Trans>Other account</Trans>} </Text> @@ -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({ <Button testID={`chooseAccountBtn-${account.handle}`} key={account.did} - style={[a.flex_1]} + style={[a.w_full]} onPress={onPress} label={ isCurrentAccount @@ -127,33 +132,45 @@ function AccountItem({ a.flex_1, a.flex_row, a.align_center, - {height: 48}, + a.px_md, + a.gap_sm, + {height: 56}, (hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25, ]}> - <View style={a.p_md}> - <UserAvatar - avatar={profile?.avatar} - size={24} - type={profile?.associated?.labeler ? 'labeler' : 'user'} - /> - </View> - <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}> - <Text emoji style={[a.font_bold]}> - {sanitizeDisplayName( - profile?.displayName || profile?.handle || account.handle, + <UserAvatar + avatar={profile?.avatar} + size={36} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> + + <View style={[a.flex_1, a.gap_2xs, a.pr_2xl]}> + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <Text + emoji + style={[a.font_bold, a.leading_tight]} + numberOfLines={1}> + {sanitizeDisplayName( + profile?.displayName || profile?.handle || account.handle, + )} + </Text> + {verification.showBadge && ( + <View> + <VerificationCheck + width={12} + verifier={verification.role === 'verifier'} + /> + </View> )} - </Text>{' '} - <Text emoji style={[t.atoms.text_contrast_medium]}> - {sanitizeHandle(account.handle)} + </View> + <Text style={[a.leading_tight, t.atoms.text_contrast_medium]}> + {sanitizeHandle(account.handle, '@')} </Text> - </Text> + </View> + {isCurrentAccount ? ( - <Check - size="sm" - style={[{color: t.palette.positive_600}, a.mr_md]} - /> + <Check size="sm" style={[{color: t.palette.positive_600}]} /> ) : ( - <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + <Chevron size="sm" style={[t.atoms.text]} /> )} </View> )} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index b5f0bc958..cca93c0c8 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -210,7 +210,9 @@ export function useLink({ } export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> & - Omit<ButtonProps, 'onPress' | 'disabled'> + Omit<ButtonProps, 'onPress' | 'disabled'> & { + overridePresentation?: boolean + } /** * A interactive element that renders as a `<a>` tag on the web. On mobile it @@ -228,6 +230,7 @@ export function Link({ onLongPress: outerOnLongPress, download, shouldProxy, + overridePresentation, ...rest }: LinkProps) { const {href, isExternal, onPress, onLongPress} = useLink({ @@ -237,6 +240,7 @@ export function Link({ onPress: outerOnPress, onLongPress: outerOnLongPress, shouldProxy: shouldProxy, + overridePresentation, }) return ( diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 1a64c51d5..c97911a3f 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -30,6 +30,8 @@ import {Link as InternalLink, type LinkProps} from '#/components/Link' import * as Pills from '#/components/Pills' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' import type * as bsky from '#/types/bsky' export function Default({ @@ -186,13 +188,24 @@ export function Name({ profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), ) + const verification = useSimpleVerificationState({profile}) return ( - <Text - emoji - style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} - numberOfLines={1}> - {name} - </Text> + <View style={[a.flex_row, a.align_center]}> + <Text + emoji + style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]} + numberOfLines={1}> + {name} + </Text> + {verification.showBadge && ( + <View style={[a.pl_xs]}> + <VerificationCheck + width={14} + verifier={verification.role === 'verifier'} + /> + </View> + )} + </View> ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 3e58ced90..09b587c5e 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -1,6 +1,10 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import { + type AppBskyActorDefs, + moderateProfile, + type ModerationOpts, +} from '@atproto/api' import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -33,7 +37,9 @@ import * as Pills from '#/components/Pills' import {Portal} from '#/components/Portal' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {ProfileHoverCardProps} from './types' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' +import {type ProfileHoverCardProps} from './types' const floatingMiddlewares = [ offset(4), @@ -412,6 +418,7 @@ function Inner({ [currentAccount, profile], ) const isLabeler = profile.associated?.labeler + const verification = useSimpleVerificationState({profile}) return ( <View> @@ -465,13 +472,30 @@ function Inner({ <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> <View style={[a.pb_sm, a.flex_1]}> - <Text - style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), + <View style={[a.flex_row, a.align_center, a.pt_md, a.pb_xs]}> + <Text + numberOfLines={1} + style={[a.text_lg, a.font_bold, a.self_start]}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + {verification.showBadge && ( + <View + style={[ + a.pl_xs, + { + marginTop: -2, + }, + ]}> + <VerificationCheck + width={16} + verifier={verification.role === 'verifier'} + /> + </View> )} - </Text> + </View> <ProfileHeaderHandle profile={profileShadow} disableTaps /> </View> diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 404790462..ed8c15f15 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,13 +1,13 @@ import React from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonColor, ButtonText} from '#/components/Button' +import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' +import {Button, type ButtonColor, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Text} from '#/components/Typography' -import {BottomSheetViewProps} from '../../modules/bottom-sheet' +import {type BottomSheetViewProps} from '../../modules/bottom-sheet' export { type DialogControlProps as PromptControlProps, @@ -62,12 +62,22 @@ export function Outer({ ) } -export function TitleText({children}: React.PropsWithChildren<{}>) { +export function TitleText({ + children, + style, +}: React.PropsWithChildren<ViewStyleProp>) { const {titleId} = React.useContext(Context) return ( <Text nativeID={titleId} - style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}> + style={[ + a.flex_1, + a.text_2xl, + a.font_bold, + a.pb_sm, + a.leading_snug, + style, + ]}> {children} </Text> ) @@ -190,7 +200,7 @@ export function Basic({ }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] title: string - description: string + description?: string cancelButtonCta?: string confirmButtonCta?: string /** @@ -207,7 +217,7 @@ export function Basic({ return ( <Outer control={control} testID="confirmModal"> <TitleText>{title}</TitleText> - <DescriptionText>{description}</DescriptionText> + {description && <DescriptionText>{description}</DescriptionText>} <Actions> <Action cta={confirmButtonCta} diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 4ed7f8371..09d0eeaf0 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -6,9 +6,11 @@ import { childHasEmoji, normalizeTextStyles, renderChildrenWithEmoji, - TextProps, + type TextProps, } from '#/alf/typography' + export type {TextProps} +export {Text as Span} from 'react-native' /** * Our main text component. Use this most of the time. diff --git a/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx b/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx new file mode 100644 index 000000000..fb7550043 --- /dev/null +++ b/src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx @@ -0,0 +1,194 @@ +import {useCallback} from 'react' +import {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 {logger} from '#/logger' +import {isNative} from '#/platform/detection' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useNuxDialogContext} from '#/components/dialogs/nuxs' +import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' +import {VerifierCheck} from '#/components/icons/VerifierCheck' +import {Link} from '#/components/Link' +import {Span, Text} from '#/components/Typography' + +export function InitialVerificationAnnouncement() { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const nuxDialogs = useNuxDialogContext() + const control = Dialog.useDialogControl() + + Dialog.useAutoOpen(control) + + const onClose = useCallback(() => { + nuxDialogs.dismissActiveNux() + }, [nuxDialogs]) + + return ( + <Dialog.Outer control={control} onClose={onClose}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_(msg`Announcing verification on Bluesky`)} + style={[ + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, + ]}> + <View style={[a.align_start, a.gap_xl]}> + <View + style={[ + a.pl_sm, + a.pr_md, + a.py_sm, + a.rounded_full, + a.flex_row, + a.align_center, + a.gap_xs, + { + backgroundColor: t.palette.primary_25, + }, + ]}> + <SparkleIcon fill={t.palette.primary_700} size="sm" /> + <Text + style={[ + a.font_bold, + { + color: t.palette.primary_700, + }, + ]}> + <Trans>New Feature</Trans> + </Text> + </View> + + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + {minHeight: 100}, + ]}> + <Image + accessibilityIgnoresInvertColors + source={require('../../../../assets/images/initial_verification_announcement_1.png')} + style={[ + { + aspectRatio: 353 / 160, + }, + ]} + alt={_( + msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, + )} + /> + </View> + + <View style={[a.gap_xs]}> + <Text style={[a.text_2xl, a.font_bold, a.leading_snug]}> + <Trans>A new form of verification</Trans> + </Text> + <Text style={[a.leading_snug, a.text_md]}> + <Trans> + We’re introducing a new layer of verification on Bluesky — an + easy-to-see checkmark. + </Trans> + </Text> + </View> + + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + {minHeight: 100}, + ]}> + <Image + accessibilityIgnoresInvertColors + source={require('../../../../assets/images/initial_verification_announcement_2.png')} + style={[ + { + aspectRatio: 353 / 196, + }, + ]} + alt={_( + msg`An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name.`, + )} + /> + </View> + + <View style={[a.gap_sm]}> + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <VerifierCheck width={14} /> + <Text style={[a.text_lg, a.font_bold, a.leading_snug]}> + <Trans>Who can verify?</Trans> + </Text> + </View> + <View style={[a.gap_sm]}> + <Text style={[a.leading_snug, a.text_md]}> + <Trans> + Bluesky will proactively verify notable and authentic + accounts. + </Trans> + </Text> + <Text style={[a.leading_snug, a.text_md]}> + <Trans> + Trust emerges from relationships, communities, and shared + context, so we’re also enabling{' '} + <Span style={[a.font_bold]}>trusted verifiers</Span>: + organizations that can directly issue verification. + </Trans> + </Text> + <Text style={[a.leading_snug, a.text_md]}> + <Trans> + When you tap on a check, you’ll see which organizations have + granted verification. + </Trans> + </Text> + </View> + </View> + + <View style={[a.w_full, a.gap_md]}> + <Link + overridePresentation + to={urls.website.blog.initialVerificationAnnouncement} + label={_(msg`Read blog post`)} + size="small" + variant="solid" + color="primary" + style={[a.justify_center, a.w_full]} + onPress={() => { + logger.metric('verification:learn-more', { + location: 'initialAnnouncementeNux', + }) + }}> + <ButtonText> + <Trans>Read blog post</Trans> + </ButtonText> + </Link> + {isNative && ( + <Button + label={_(msg`Close`)} + size="small" + variant="solid" + color="secondary" + style={[a.justify_center, a.w_full]} + onPress={() => { + control.close() + }}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} 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<Context>({ activeNux: undefined, @@ -163,6 +169,9 @@ function Inner({ return ( <Context.Provider value={ctx}> {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} + {activeNux === Nux.InitialVerificationAnnouncement && ( + <InitialVerificationAnnouncement /> + )} </Context.Provider> ) } 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({ /> </View> <View style={a.flex_1}> - <Text - emoji - style={[ - a.text_md, - a.font_bold, - a.self_start, - web(a.leading_normal), - ]} - numberOfLines={1}> - {displayName} - </Text> + <View style={[a.flex_row, a.align_center]}> + <Text + emoji + style={[ + a.text_md, + a.font_bold, + a.self_start, + web(a.leading_normal), + ]} + numberOfLines={1}> + {displayName} + </Text> + {verification.showBadge && ( + <View style={[a.pl_xs]}> + <VerificationCheck + width={14} + verifier={verification.role === 'verifier'} + /> + </View> + )} + </View> {!isDeletedAccount && ( <Text style={[ diff --git a/src/components/icons/CircleCheck.tsx b/src/components/icons/CircleCheck.tsx new file mode 100644 index 000000000..98abc9296 --- /dev/null +++ b/src/components/icons/CircleCheck.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm13.633-3.274a1 1 0 0 1 .141 1.407l-4.5 5.5a1 1 0 0 1-1.481.074l-2-2a1 1 0 1 1 1.414-1.414l1.219 1.219 3.8-4.645a1 1 0 0 1 1.407-.141Z', +}) diff --git a/src/components/icons/Sparkle.tsx b/src/components/icons/Sparkle.tsx new file mode 100644 index 000000000..73ce21d02 --- /dev/null +++ b/src/components/icons/Sparkle.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Sparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 2a1 1 0 0 1 1 1c0 3.188.669 5.256 1.882 6.536C16.084 10.805 18.01 11.5 21 11.5a1 1 0 1 1 0 2c-2.99 0-4.916.695-6.118 1.964C13.67 16.744 13 18.812 13 22a1 1 0 1 1-2 0c0-3.188-.669-5.256-1.882-6.536C7.916 14.195 5.99 13.5 3 13.5a1 1 0 1 1 0-2c2.99 0 4.916-.695 6.118-1.964C10.33 8.256 11 6.188 11 3a1 1 0 0 1 1-1Zm0 6.734a7.608 7.608 0 0 1-1.43 2.178A7.285 7.285 0 0 1 8.349 12.5c.846.397 1.589.921 2.22 1.588A7.607 7.607 0 0 1 12 16.267a7.607 7.607 0 0 1 1.43-2.179 7.284 7.284 0 0 1 2.221-1.588 7.284 7.284 0 0 1-2.22-1.588A7.608 7.608 0 0 1 12 8.734Z', +}) diff --git a/src/components/icons/VerifiedCheck.tsx b/src/components/icons/VerifiedCheck.tsx new file mode 100644 index 000000000..9299eb6e3 --- /dev/null +++ b/src/components/icons/VerifiedCheck.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import Svg, {Circle, Path} from 'react-native-svg' + +import {type Props, useCommonSVGProps} from '#/components/icons/common' + +export const VerifiedCheck = React.forwardRef<Svg, Props>(function LogoImpl( + props, + ref, +) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + <Circle cx="12" cy="12" r="12" fill={fill} /> + <Path + fill="#fff" + fillRule="evenodd" + clipRule="evenodd" + d="M18.311 7.421a1.437 1.437 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L6.42 12.74a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" + /> + </Svg> + ) +}) 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<Svg, Props>(function LogoImpl( + props, + ref, +) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + <Path + fill={fill} + fillRule="evenodd" + clipRule="evenodd" + d="M8.792 1.54a4.11 4.11 0 0 1 6.416 0 4.128 4.128 0 0 0 3.146 1.54c2.616.04 4.544 2.5 4 5.1a4.277 4.277 0 0 0 .777 3.462c1.6 2.104.912 5.17-1.427 6.36a4.21 4.21 0 0 0-2.177 2.774c-.62 2.584-3.408 3.948-5.781 2.83a4.092 4.092 0 0 0-3.492 0c-2.373 1.118-5.16-.246-5.78-2.83a4.21 4.21 0 0 0-2.178-2.775c-2.34-1.19-3.028-4.256-1.427-6.36a4.277 4.277 0 0 0 .776-3.46c-.543-2.602 1.385-5.06 4.001-5.1a4.128 4.128 0 0 0 3.146-1.54Z" + /> + <Path + fill="#fff" + fillRule="evenodd" + clipRule="evenodd" + d="M17.659 8.399a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.435a1.361 1.361 0 1 1 1.925-1.925l2.149 2.15 5.26-5.261a1.361 1.361 0 0 1 1.925 0Z" + /> + </Svg> + ) +}) 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 ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} /> +} 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<bsky.profile.AnyProfileView> + size: 'lg' | 'md' | 'sm' +}) { + const state = useFullVerificationState({ + profile, + }) + + if (shouldShowVerificationCheckButton(state)) { + return <Badge profile={profile} verificationState={state} size={size} /> + } + + return null +} + +export function Badge({ + profile, + verificationState: state, + size, +}: { + profile: Shadow<bsky.profile.AnyProfileView> + 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 ( + <> + <Button + label={ + state.profile.isViewer + ? _(msg`View your verifications`) + : _(msg`View this user's verifications`) + } + hitSlop={20} + onPress={() => { + logger.metric('verification:badge:click', {}) + if (state.profile.role === 'verifier') { + verifierDialogControl.open() + } else { + verificationsDialogControl.open() + } + }} + style={[]}> + {({hovered}) => ( + <View + style={[ + a.justify_end, + a.align_end, + a.transition_transform, + { + width: dimensions, + height: dimensions, + transform: [ + { + scale: hovered ? 1.1 : 1, + }, + ], + }, + ]}> + <VerificationCheck + width={dimensions} + fill={ + verifiedByHidden + ? t.atoms.bg_contrast_100.backgroundColor + : state.profile.isVerified + ? t.palette.primary_500 + : t.atoms.bg_contrast_100.backgroundColor + } + verifier={state.profile.role === 'verifier'} + /> + </View> + )} + </Button> + + <VerificationsDialog + control={verificationsDialogControl} + profile={profile} + verificationState={state} + /> + + <VerifierDialog + control={verifierDialogControl} + profile={profile} + verificationState={state} + /> + </> + ) +} 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 ( + <Prompt.Outer control={control}> + <View style={[a.flex_row, a.align_center, a.gap_sm, a.pb_sm]}> + <VerifiedCheck width={18} /> + <Prompt.TitleText style={[a.pb_0]}> + {_(msg`Verify this account?`)} + </Prompt.TitleText> + </View> + <Prompt.DescriptionText> + {_(msg`This action can be undone at any time.`)} + </Prompt.DescriptionText> + <View style={[a.pb_xl]}> + {moderationOpts ? ( + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + </ProfileCard.Header> + ) : null} + </View> + <Prompt.Actions> + <Prompt.Action cta={_(msg`Verify account`)} onPress={onConfirm} /> + <Prompt.Cancel /> + </Prompt.Actions> + </Prompt.Outer> + ) +} 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 ( + <Prompt.Basic + control={control} + title={_(msg`Remove your verification for this account?`)} + onConfirm={onConfirm} + confirmButtonCta={_(msg`Remove verification`)} + confirmButtonColor="negative" + /> + ) +} 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 ( + <Dialog.Outer control={control}> + <Inner + control={control} + profile={profile} + verificationState={verificationState} + /> + <Dialog.Close /> + </Dialog.Outer> + ) +} + +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 ( + <Dialog.ScrollableInner + label={label} + style={[ + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, + ]}> + <Dialog.Handle /> + + <View style={[a.gap_sm, a.pb_lg]}> + <Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}> + {label} + </Text> + <Text style={[a.text_md, a.leading_snug]}> + {state.profile.isVerified ? ( + <Trans> + This account has a checkmark because it's been verified by trusted + sources. + </Trans> + ) : ( + <Trans> + This account has one or more verifications, but it is not + currently verified. + </Trans> + )} + </Text> + </View> + + {profile.verification ? ( + <View style={[a.pb_xl, a.gap_md]}> + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> + <Trans>Verified by:</Trans> + </Text> + + <View style={[a.gap_lg]}> + {profile.verification.verifications.map(v => ( + <VerifierCard + key={v.uri} + verification={v} + subject={profile} + outerDialogControl={control} + /> + ))} + </View> + + {profile.verification.verifications.some(v => !v.isValid) && + state.profile.isViewer && ( + <Admonition type="warning" style={[a.mt_xs]}> + <Trans>Some of your verifications are invalid.</Trans> + </Admonition> + )} + </View> + ) : null} + + <View + style={[ + a.w_full, + a.gap_sm, + a.justify_end, + gtMobile + ? [a.flex_row, a.flex_row_reverse, a.justify_start] + : [a.flex_col], + ]}> + <Button + label={_(msg`Close dialog`)} + size="small" + variant="solid" + color="primary" + onPress={() => { + control.close() + }}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + <Link + overridePresentation + to={urls.website.blog.initialVerificationAnnouncement} + label={_(msg`Learn more about verification on Bluesky`)} + size="small" + variant="solid" + color="secondary" + style={[a.justify_center]} + onPress={() => { + logger.metric('verification:learn-more', { + location: 'verificationsDialog', + }) + }}> + <ButtonText> + <Trans>Learn more</Trans> + </ButtonText> + </Link> + </View> + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +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 ( + <View + style={{ + opacity: verification.isValid ? 1 : 0.5, + }}> + <ProfileCard.Outer> + <ProfileCard.Header> + {error ? ( + <> + <ProfileCard.AvatarPlaceholder /> + <View style={[a.flex_1]}> + <Text + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + <Trans>Unknown verifier</Trans> + </Text> + <Text + emoji + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {verification.issuer} + </Text> + </View> + </> + ) : profile && moderationOpts ? ( + <> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + {canAdminister && ( + <View> + <Button + label={_(msg`Remove verification`)} + size="small" + variant="outline" + color="negative" + shape="round" + onPress={() => { + verificationRemovePromptControl.open() + }}> + <ButtonIcon icon={TrashIcon} /> + </Button> + </View> + )} + </> + ) : ( + <> + <ProfileCard.AvatarPlaceholder /> + <ProfileCard.NameAndHandlePlaceholder /> + </> + )} + </ProfileCard.Header> + </ProfileCard.Outer> + + <VerificationRemovePrompt + control={verificationRemovePromptControl} + profile={subject} + verifications={[verification]} + onConfirm={() => outerDialogControl.close()} + /> + </View> + ) +} 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 ( + <Dialog.Outer control={control}> + <Inner + control={control} + profile={profile} + verificationState={verificationState} + /> + <Dialog.Close /> + </Dialog.Outer> + ) +} + +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 ( + <Dialog.ScrollableInner + label={label} + style={[ + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, + ]}> + <Dialog.Handle /> + + <View style={[a.gap_lg]}> + <View + style={[ + a.w_full, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + {minHeight: 100}, + ]}> + <Image + accessibilityIgnoresInvertColors + source={require('../../../assets/images/initial_verification_announcement_1.png')} + style={[ + { + aspectRatio: 353 / 160, + }, + ]} + alt={_( + msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, + )} + /> + </View> + + <View style={[a.gap_sm]}> + <Text style={[a.text_2xl, a.font_bold, a.pr_4xl, a.leading_tight]}> + {label} + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + Accounts with a scalloped blue check mark + <RNText> + {NON_BREAKING_SPACE} + <VerifierCheck width={14} /> + {NON_BREAKING_SPACE} + </RNText> + can verify others. These trusted verifiers are selected by + Bluesky. + </Trans> + </Text> + </View> + + <View + style={[ + a.w_full, + a.gap_sm, + a.justify_end, + gtMobile ? [a.flex_row, a.justify_end] : [a.flex_col], + ]}> + <Link + overridePresentation + to={urls.website.blog.initialVerificationAnnouncement} + label={_(msg`Learn more about verification on Bluesky`)} + size="small" + variant="solid" + color="primary" + style={[a.justify_center]} + onPress={() => { + logger.metric('verification:learn-more', { + location: 'verifierDialog', + }) + }}> + <ButtonText> + <Trans>Learn more</Trans> + </ButtonText> + </Link> + <Button + label={_(msg`Close dialog`)} + size="small" + variant="solid" + color="secondary" + onPress={() => { + control.close() + }}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + </View> + </View> + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} 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({ <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> - <Text - numberOfLines={1} - style={[{maxWidth: '85%'}, web([a.leading_normal])]}> + <View style={[a.flex_shrink]}> <Text emoji + numberOfLines={1} style={[ a.text_md, t.atoms.text, @@ -399,22 +403,31 @@ function ChatListItemReady({ ]}> {displayName} </Text> - </Text> + </View> + {verification.showBadge && ( + <View style={[a.pl_xs, a.self_center]}> + <VerificationCheck + width={14} + verifier={verification.role === 'verifier'} + /> + </View> + )} {lastMessageSentAt && ( - <TimeElapsed timestamp={lastMessageSentAt}> - {({timeElapsed}) => ( - <Text - style={[ - a.text_sm, - {lineHeight: 21}, - t.atoms.text_contrast_medium, - web({whiteSpace: 'preserve nowrap'}), - ]}> - {' '} - · {timeElapsed} - </Text> - )} - </TimeElapsed> + <View style={[a.pl_xs]}> + <TimeElapsed timestamp={lastMessageSentAt}> + {({timeElapsed}) => ( + <Text + style={[ + a.text_sm, + {lineHeight: 21}, + t.atoms.text_contrast_medium, + web({whiteSpace: 'preserve nowrap'}), + ]}> + · {timeElapsed} + </Text> + )} + </TimeElapsed> + </View> )} {(convo.muted || moderation.blocked) && ( <Text diff --git a/src/screens/Moderation/VerificationSettings.tsx b/src/screens/Moderation/VerificationSettings.tsx new file mode 100644 index 000000000..f9665d6d9 --- /dev/null +++ b/src/screens/Moderation/VerificationSettings.tsx @@ -0,0 +1,96 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {urls} from '#/lib/constants' +import {logger} from '#/logger' +import { + usePreferencesQuery, + type UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {useSetVerificationPrefsMutation} from '#/state/queries/preferences' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a, useGutters} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' + +export function Screen() { + const {_} = useLingui() + const gutters = useGutters(['base']) + const {data: preferences} = usePreferencesQuery() + + return ( + <Layout.Screen testID="ModerationVerificationSettingsScreen"> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>Verification Settings</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <Layout.Content> + <SettingsList.Container> + <SettingsList.Item> + <Admonition type="tip" style={[a.flex_1]}> + <Trans> + Verifications on Bluesky work differently than on other + platforms.{' '} + <InlineLinkText + overridePresentation + to={urls.website.blog.initialVerificationAnnouncement} + label={_(msg`Learn more`)} + onPress={() => { + logger.metric('verification:learn-more', { + location: 'verificationSettings', + }) + }}> + Learn more here. + </InlineLinkText> + </Trans> + </Admonition> + </SettingsList.Item> + {preferences ? ( + <Inner preferences={preferences} /> + ) : ( + <View style={[gutters, a.justify_center, a.align_center]}> + <Loader size="xl" /> + </View> + )} + </SettingsList.Container> + </Layout.Content> + </Layout.Screen> + ) +} + +function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { + const {_} = useLingui() + const {hideBadges} = preferences.verificationPrefs + const {mutate: setVerificationPrefs, isPending} = + useSetVerificationPrefsMutation() + + return ( + <Toggle.Item + type="checkbox" + name="hideBadges" + label={_(msg`Hide verification badges`)} + value={hideBadges} + disabled={isPending} + onChange={value => { + setVerificationPrefs({hideBadges: value}) + }}> + <SettingsList.Item> + <SettingsList.ItemIcon icon={CircleCheck} /> + <SettingsList.ItemText> + <Trans>Hide verification badges</Trans> + </SettingsList.ItemText> + <Toggle.Platform /> + </SettingsList.Item> + </Toggle.Item> + ) +} 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({ /> )} </Link> + <Divider /> + <Link + label={_(msg`Manage verification settings`)} + testID="verificationSettingsBtn" + to="/moderation/verification-settings"> + {state => ( + <SubItem + title={_(msg`Verification settings`)} + icon={CircleCheck} + style={[ + (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], + ]} + /> + )} + </Link> </View> <Text diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx index 62bb5e00e..a0e24d78a 100644 --- a/src/screens/Profile/Header/EditProfileDialog.tsx +++ b/src/screens/Profile/Header/EditProfileDialog.tsx @@ -1,10 +1,11 @@ import {useCallback, useEffect, useState} from 'react' import {Dimensions, View} from 'react-native' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {AppBskyActorDefs} from '@atproto/api' +import {type Image as RNImage} from 'react-native-image-crop-picker' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {urls} from '#/lib/constants' import {compressIfNeeded} from '#/lib/media/manip' import {cleanError} from '#/lib/strings/errors' import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' @@ -16,10 +17,13 @@ import * as Toast from '#/view/com/util/Toast' import {EditableUserAvatar} from '#/view/com/util/UserAvatar' import {UserBanner} from '#/view/com/util/UserBanner' import {atoms as a, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' +import {InlineLinkText} from '#/components/Link' import * as Prompt from '#/components/Prompt' +import {useSimpleVerificationState} from '#/components/verification' const DISPLAY_NAME_MAX_GRAPHEMES = 64 const DESCRIPTION_MAX_GRAPHEMES = 256 @@ -102,6 +106,9 @@ function DialogInner({ const {_} = useLingui() const t = useTheme() const control = Dialog.useDialogContext() + const verification = useSimpleVerificationState({ + profile, + }) const { mutateAsync: updateProfileMutation, error: updateProfileError, @@ -342,6 +349,22 @@ function DialogInner({ )} </View> + {verification.isVerified && + verification.role === 'default' && + displayName !== initialDisplayName && ( + <Admonition type="error"> + <Trans> + You are verified. You will lose your verification status if you + change your display name.{' '} + <InlineLinkText + label={_(msg`Learn more`)} + to={urls.website.blog.initialVerificationAnnouncement}> + <Trans>Learn more.</Trans> + </InlineLinkText> + </Trans> + </Admonition> + )} + <View> <TextField.LabelText> <Trans>Description</Trans> 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<AppBskyActorDefs.ProfileViewDetailed> = useProfileShadow(profileUnshadowed) const {currentAccount, hasSession} = useSession() @@ -238,7 +242,31 @@ let ProfileHeaderStandard = ({ <ProfileMenu profile={profile} /> </View> <View style={[a.flex_col, a.gap_2xs, a.pt_2xs, a.pb_sm]}> - <ProfileHeaderDisplayName profile={profile} moderation={moderation} /> + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> + <Text + emoji + testID="profileHeaderDisplayName" + style={[ + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.self_start, + a.font_heavy, + ]}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + <View + style={[ + a.pl_xs, + { + marginTop: platform({ios: 2}), + }, + ]}> + <VerificationCheckButton profile={profile} size="lg" /> + </View> + </Text> + </View> <ProfileHeaderHandle profile={profile} /> </View> {!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 => ( <SearchProfileCard diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx index 5e62f2cd0..048203ed8 100644 --- a/src/screens/Search/components/SearchHistory.tsx +++ b/src/screens/Search/components/SearchHistory.tsx @@ -1,18 +1,23 @@ import {Pressable, ScrollView, StyleSheet, View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {createHitslop, HITSLOP_10} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {Link} from '#/view/com/util/Link' import {UserAvatar} from '#/view/com/util/UserAvatar' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' -import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' import type * as bsky from '#/types/bsky' export function SearchHistory({ @@ -31,8 +36,8 @@ export function SearchHistory({ onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void }) { const {gtMobile} = useBreakpoints() - const t = useTheme() const {_} = useLingui() + const moderationOpts = useModerationOpts() return ( <Layout.Content @@ -54,53 +59,25 @@ export function SearchHistory({ <ScrollView horizontal keyboardShouldPersistTaps="handled" + showsHorizontalScrollIndicator={false} style={[ a.flex_row, a.flex_nowrap, {marginHorizontal: tokens.space._2xl * -1}, ]} contentContainerStyle={[a.px_2xl, a.border_0]}> - {selectedProfiles.slice(0, 5).map((profile, index) => ( - <View - key={index} - style={[ - styles.profileItem, - !gtMobile && styles.profileItemMobile, - ]}> - <Link - href={makeProfileLink(profile)} - title={profile.handle} - asAnchor - anchorNoUnderline - onBeforePress={() => onProfileClick(profile)} - style={[a.align_center, a.w_full]}> - <UserAvatar - avatar={profile.avatar} - type={profile.associated?.labeler ? 'labeler' : 'user'} - size={60} + {moderationOpts && + selectedProfiles + .slice(0, 5) + .map(profile => ( + <RecentProfileItem + key={profile.did} + profile={profile} + moderationOpts={moderationOpts} + onPress={() => onProfileClick(profile)} + onRemove={() => onRemoveProfileClick(profile)} /> - <Text - emoji - style={[a.text_xs, a.text_center, styles.profileName]} - numberOfLines={1}> - {sanitizeDisplayName( - profile.displayName || profile.handle, - )} - </Text> - </Link> - <Pressable - accessibilityRole="button" - accessibilityLabel={_(msg`Remove profile`)} - accessibilityHint={_( - msg`Removes profile from search history`, - )} - onPress={() => onRemoveProfileClick(profile)} - hitSlop={createHitslop(6)} - style={styles.profileRemoveBtn}> - <XIcon size="xs" style={t.atoms.text_contrast_low} /> - </Pressable> - </View> - ))} + ))} </ScrollView> </BlockDrawerGesture> </View> @@ -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 ( + <View style={[styles.profileItem, !gtMobile && styles.profileItemMobile]}> + <Link + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline + onBeforePress={onPress} + style={[a.align_center, a.w_full]}> + <UserAvatar + avatar={profile.avatar} + type={profile.associated?.labeler ? 'labeler' : 'user'} + size={60} + moderation={moderation.ui('avatar')} + /> + <View style={styles.profileName}> + <View + style={[ + a.flex_row, + a.align_center, + a.justify_center, + web([a.flex_1]), + ]}> + <Text + emoji + style={[a.text_xs, a.leading_snug, a.self_start]} + numberOfLines={1}> + {name} + </Text> + {verification.showBadge && ( + <View style={[a.pl_xs]}> + <VerificationCheck + width={12} + verifier={verification.role === 'verifier'} + /> + </View> + )} + </View> + </View> + </Link> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Remove profile`)} + accessibilityHint={_(msg`Removes profile from search history`)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn} + onPress={onRemove}> + <XIcon size="xs" style={t.atoms.text_contrast_low} /> + </Pressable> + </View> + ) +} + 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<CommonNavigatorParams, 'Settings'> 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'} /> - <Text - emoji - testID="profileHeaderDisplayName" - style={[ - a.pt_sm, - t.atoms.text, - gtMobile ? a.text_4xl : a.text_3xl, - a.font_heavy, - ]}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), + <View style={[a.flex_row, a.gap_xs, a.align_center]}> + <Text + emoji + testID="profileHeaderDisplayName" + numberOfLines={1} + style={[ + a.pt_sm, + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.font_heavy, + ]}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + {shouldShowVerificationCheckButton(verificationState) && ( + <View + style={[ + { + marginTop: platform({web: 8, ios: 8, android: 10}), + }, + ]}> + <VerificationCheckButton profile={shadow} size="lg" /> + </View> )} - </Text> + </View> <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> {sanitizeHandle(profile.handle, '@')} </Text> 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({ <Animated.View layout={native(LinearTransition)} style={[a.flex_1, a.gap_md]}> + {verification.isVerified && verification.role === 'default' && ( + <Admonition type="error"> + <Trans> + You are verified. You will lose your verification status if you + change your handle.{' '} + <InlineLinkText + label={_(msg`Learn more`)} + to={urls.website.blog.initialVerificationAnnouncement}> + <Trans>Learn more.</Trans> + </InlineLinkText> + </Trans> + </Admonition> + )} <View> <TextField.LabelText> <Trans>New handle</Trans> 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<TProfileView extends bsky.profile.AnyProfileView>( 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<Nux, zod.ZodObject<any> | 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<void, unknown, AppBskyActorDefs.VerificationPrefs>({ + 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<void, Error, ProfileUpdateParams>({ 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 ( <Pressable - style={[t.atoms.border_contrast_medium, styles.replyToLayout]} + style={[ + a.flex_row, + a.align_start, + a.pt_xs, + a.pb_lg, + a.mb_md, + a.mx_lg, + a.border_b, + t.atoms.border_contrast_medium, + ]} onPress={onPress} accessibilityRole="button" accessibilityLabel={_( msg`Expand or collapse the full post you are replying to`, )} - accessibilityHint={_( - msg`Expands or collapses the full post you are replying to`, - )}> + accessibilityHint=""> <PreviewableUserAvatar size={50} profile={replyTo.author} @@ -79,17 +90,30 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} disableNavigation={true} /> - <View style={styles.replyToPost}> - <Text type="xl-medium" style={t.atoms.text} numberOfLines={1} emoji> - {sanitizeDisplayName( - replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), + <View style={[a.flex_1, a.pl_md, a.pr_sm, a.gap_2xs]}> + <View style={[a.flex_row, a.align_center, a.pr_xs]}> + <Text + style={[a.font_bold, a.text_md, a.flex_shrink]} + numberOfLines={1} + emoji> + {sanitizeDisplayName( + replyTo.author.displayName || + sanitizeHandle(replyTo.author.handle), + )} + </Text> + {verification.showBadge && ( + <View style={[a.pl_xs]}> + <VerificationCheck + width={14} + verifier={verification.role === 'verifier'} + /> + </View> )} - </Text> - <View style={styles.replyToBody}> - <View style={styles.replyToText}> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View style={[a.flex_1, a.flex_grow]}> <Text - type="post-text" - style={t.atoms.text} + style={[a.text_md]} numberOfLines={!showFull ? 6 : undefined} emoji> {replyTo.text} @@ -112,7 +136,17 @@ function ComposerReplyToImages({ showFull: boolean }) { return ( - <View style={[styles.imagesContainer, a.mx_xs]}> + <View + style={[ + a.rounded_xs, + a.overflow_hidden, + a.mt_2xs, + a.mx_xs, + { + height: 64, + width: 64, + }, + ]}> {(images.length === 1 && ( <Image source={{uri: images[0].thumb}} @@ -196,35 +230,3 @@ function ComposerReplyToImages({ </View> ) } - -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 ( <KeyboardAvoidingView style={s.flex1} behavior="height"> @@ -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 && ( + <View style={{paddingTop: 8}}> + <Admonition type="error"> + <Trans> + You are verified. You will lose your verification status + if you change your display name.{' '} + <InlineLinkText + label={_(msg`Learn more`)} + to={urls.website.blog.initialVerificationAnnouncement}> + <Trans>Learn more.</Trans> + </InlineLinkText> + </Trans> + </Admonition> + </View> + )} </View> <View style={s.pb10}> <Text style={[styles.label, pal.text]}> 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 && ( + <View + style={[ + a.relative, + { + paddingTop: platform({android: 2}), + marginBottom: platform({ios: -7}), + top: platform({web: 1}), + paddingLeft: 3, + paddingRight: 2, + }, + ]}> + <VerificationCheck + width={14} + verifier={firstAuthorVerification.role === 'verifier'} + /> + </View> + )} </InlineLinkText> ) const additionalAuthorsCount = authors.length - 1 @@ -366,6 +390,60 @@ let NotificationFeedItem = ({ <StarterPack width={30} gradient="sky" /> </View> ) + // @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 ? ( + <Trans> + {firstAuthorLink} and{' '} + <Text style={[pal.text, s.bold]}> + <Plural + value={additionalAuthorsCount} + one={`${formattedAuthorsCount} other`} + other={`${formattedAuthorsCount} others`} + /> + </Text>{' '} + verified you + </Trans> + ) : ( + <Trans>{firstAuthorLink} verified you</Trans> + ) + icon = <VerifiedCheck size="xl" /> + // @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 ? ( + <Trans> + {firstAuthorLink} and{' '} + <Text style={[pal.text, s.bold]}> + <Plural + value={additionalAuthorsCount} + one={`${formattedAuthorsCount} other`} + other={`${formattedAuthorsCount} others`} + /> + </Text>{' '} + removed their verifications from your account + </Trans> + ) : ( + <Trans> + {firstAuthorLink} removed their verification from your account + </Trans> + ) + icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} /> } 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 = ({ </Text> </ExpandListPressable> {item.type === 'post-like' || item.type === 'repost' ? ( - <AdditionalPostText post={item.subject} /> + <View style={[a.pt_2xs]}> + <AdditionalPostText post={item.subject} /> + </View> ) : null} {item.type === 'feedgen-like' && item.subjectUri ? ( <FeedSourceCard @@ -672,8 +751,6 @@ function ExpandedAuthorsList({ visible: boolean authors: Author[] }) { - const {_} = useLingui() - const t = useTheme() const heightInterp = useAnimatedValue(visible ? 1 : 0) const targetHeight = authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ @@ -692,59 +769,78 @@ function ExpandedAuthorsList({ <Animated.View style={[a.overflow_hidden, heightStyle]}> {visible && authors.map(author => ( - <Link - key={author.profile.did} - label={author.profile.displayName || author.profile.handle} - accessibilityHint={_(msg`Opens this profile`)} - to={makeProfileLink({ - did: author.profile.did, - handle: author.profile.handle, - })} - style={styles.expandedAuthor}> - <View style={[a.mr_sm]}> - <ProfileHoverCard did={author.profile.did}> - <UserAvatar - size={35} - avatar={author.profile.avatar} - moderation={author.moderation.ui('avatar')} - type={author.profile.associated?.labeler ? 'labeler' : 'user'} - /> - </ProfileHoverCard> - </View> - <View style={[a.flex_1]}> - <View style={[a.flex_row, a.align_end]}> - <Text - numberOfLines={1} - emoji - style={[ - a.text_md, - a.font_bold, - a.leading_tight, - {maxWidth: '70%'}, - ]}> - {sanitizeDisplayName( - author.profile.displayName || author.profile.handle, - )} - </Text> - <Text - numberOfLines={1} - style={[ - a.pl_xs, - a.text_md, - a.leading_tight, - a.flex_shrink, - t.atoms.text_contrast_medium, - ]}> - {sanitizeHandle(author.profile.handle, '@')} - </Text> - </View> - </View> - </Link> + <ExpandedAuthorCard key={author.profile.did} author={author} /> ))} </Animated.View> ) } +function ExpandedAuthorCard({author}: {author: Author}) { + const t = useTheme() + const {_} = useLingui() + const verification = useSimpleVerificationState({ + profile: author.profile, + }) + return ( + <Link + key={author.profile.did} + label={author.profile.displayName || author.profile.handle} + accessibilityHint={_(msg`Opens this profile`)} + to={makeProfileLink({ + did: author.profile.did, + handle: author.profile.handle, + })} + style={styles.expandedAuthor}> + <View style={[a.mr_sm]}> + <ProfileHoverCard did={author.profile.did}> + <UserAvatar + size={35} + avatar={author.profile.avatar} + moderation={author.moderation.ui('avatar')} + type={author.profile.associated?.labeler ? 'labeler' : 'user'} + /> + </ProfileHoverCard> + </View> + <View style={[a.flex_1]}> + <View style={[a.flex_row, a.align_end]}> + <Text + numberOfLines={1} + emoji + style={[ + a.text_md, + a.font_bold, + a.leading_tight, + {maxWidth: '70%'}, + ]}> + {sanitizeDisplayName( + author.profile.displayName || author.profile.handle, + )} + </Text> + {verification.showBadge && ( + <View style={[a.pl_xs, a.self_center]}> + <VerificationCheck + width={14} + verifier={verification.role === 'verifier'} + /> + </View> + )} + <Text + numberOfLines={1} + style={[ + a.pl_xs, + a.text_md, + a.leading_tight, + a.flex_shrink, + t.atoms.text_contrast_medium, + ]}> + {sanitizeHandle(author.profile.handle, '@')} + </Text> + </View> + </View> + </Link> + ) +} + function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const t = useTheme() if ( @@ -761,7 +857,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { {text?.length > 0 && ( <Text emoji - style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> {text} </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'} /> <View style={[a.flex_1]}> - <Link style={s.flex1} href={authorHref} title={authorTitle}> - <Text - emoji - style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]} - numberOfLines={1}> - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} - </Text> - </Link> + <View style={[a.flex_row, a.align_center]}> + <Link + style={[a.flex_shrink]} + href={authorHref} + title={authorTitle}> + <Text + emoji + style={[ + a.text_lg, + a.font_bold, + a.leading_snug, + a.self_start, + ]} + numberOfLines={1}> + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + </Text> + </Link> + + <View style={[{paddingLeft: 3, top: -1}]}> + <VerificationCheckButton + profile={shadowedPostAuthor} + size="md" + /> + </View> + </View> <Link style={s.flex1} href={authorHref} title={authorTitle}> <Text emoji diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index fdf1cb814..97a43c753 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -1,5 +1,5 @@ import React, {memo} from 'react' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -7,11 +7,11 @@ import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' -import {NavigationProp} from '#/lib/routes/types' +import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' -import {Shadow} from '#/state/cache/types' +import {type Shadow} from '#/state/cache/types' import {useModalControls} from '#/state/modals' import {useDevModeEnabled} from '#/state/preferences/dev-mode' import { @@ -25,6 +25,8 @@ import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' +import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' @@ -43,6 +45,9 @@ import { useReportDialogControl, } from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' +import {useFullVerificationState} from '#/components/verification' +import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' +import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' let ProfileMenu = ({ profile, @@ -61,6 +66,7 @@ let ProfileMenu = ({ const isFollowingBlockedAccount = isFollowing && isBlocked const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked const [devModeEnabled] = useDevModeEnabled() + const verification = useFullVerificationState({profile}) const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) @@ -188,6 +194,13 @@ let ProfileMenu = ({ navigation.navigate('ProfileSearch', {name: profile.handle}) }, [navigation, profile.handle]) + const verificationCreatePromptControl = Prompt.usePromptControl() + const verificationRemovePromptControl = Prompt.usePromptControl() + const currentAccountVerifications = + profile.verification?.verifications?.filter(v => { + return v.issuer === currentAccount?.did + }) ?? [] + return ( <EventStopper onKeyDown={false}> <Menu.Root> @@ -277,6 +290,29 @@ let ProfileMenu = ({ </Menu.ItemText> <Menu.ItemIcon icon={List} /> </Menu.Item> + {verification.viewer.role === 'verifier' && + !verification.profile.isViewer && + (verification.viewer.hasIssuedVerification ? ( + <Menu.Item + testID="profileHeaderDropdownVerificationRemoveButton" + label={_(msg`Remove verification`)} + onPress={() => verificationRemovePromptControl.open()}> + <Menu.ItemText> + <Trans>Remove verification</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CircleX} /> + </Menu.Item> + ) : ( + <Menu.Item + testID="profileHeaderDropdownVerificationCreateButton" + label={_(msg`Verify account`)} + onPress={() => verificationCreatePromptControl.open()}> + <Menu.ItemText> + <Trans>Verify account</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CircleCheck} /> + </Menu.Item> + ))} {!isSelf && ( <> {!profile.viewer?.blocking && @@ -410,6 +446,16 @@ let ProfileMenu = ({ onConfirm={onPressShare} confirmButtonCta={_(msg`Share anyway`)} /> + + <VerificationCreatePrompt + control={verificationCreatePromptControl} + profile={profile} + /> + <VerificationRemovePrompt + control={verificationRemovePromptControl} + profile={profile} + verifications={currentAccountVerifications} + /> </EventStopper> ) } 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 ( <View @@ -56,83 +62,114 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { 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 && ( <View style={[a.self_center, a.mr_2xs]}> <PreviewableUserAvatar size={opts.avatarSize || 16} - profile={opts.author} + profile={author} moderation={opts.moderation?.ui('avatar')} - type={opts.author.associated?.labeler ? 'labeler' : 'user'} + type={author.associated?.labeler ? 'labeler' : 'user'} /> </View> )} - <ProfileHoverCard inline did={opts.author.did}> - <Text numberOfLines={1} style={[isAndroid ? a.flex_1 : a.flex_shrink]}> - <WebOnlyInlineLinkText - to={profileLink} - label={_(msg`View profile`)} - disableMismatchWarning - onPress={onBeforePressAuthor} - style={[t.atoms.text]}> - <Text emoji style={[a.text_md, a.font_bold, a.leading_snug]}> + <View style={[a.flex_row, a.align_end, a.flex_shrink]}> + <ProfileHoverCard inline did={author.did}> + <View style={[a.flex_row, a.align_end, a.flex_shrink]}> + <WebOnlyInlineLinkText + emoji + numberOfLines={1} + to={profileLink} + label={_(msg`View profile`)} + disableMismatchWarning + onPress={onBeforePressAuthor} + style={[ + a.text_md, + a.font_bold, + t.atoms.text, + a.leading_tight, + {maxWidth: '70%', flexShrink: 0}, + ]}> {forceLTR( sanitizeDisplayName( displayName, opts.moderation?.ui('displayName'), ), )} - </Text> - </WebOnlyInlineLinkText> - <WebOnlyInlineLinkText - to={profileLink} - label={_(msg`View profile`)} - disableMismatchWarning - disableUnderline - onPress={onBeforePressAuthor} - style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> - <Text - emoji - style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> + </WebOnlyInlineLinkText> + {verification.showBadge && ( + <View + style={[ + a.pl_2xs, + a.self_center, + { + marginTop: platform({web: -1, ios: -1, android: -2}), + }, + ]}> + <VerificationCheck + width={14} + verifier={verification.role === 'verifier'} + /> + </View> + )} + <WebOnlyInlineLinkText + numberOfLines={1} + to={profileLink} + label={_(msg`View profile`)} + disableMismatchWarning + disableUnderline + onPress={onBeforePressAuthor} + style={[ + a.text_md, + t.atoms.text_contrast_medium, + a.leading_tight, + {flexShrink: 10}, + ]}> {NON_BREAKING_SPACE + sanitizeHandle(handle, '@')} - </Text> - </WebOnlyInlineLinkText> - </Text> - </ProfileHoverCard> + </WebOnlyInlineLinkText> + </View> + </ProfileHoverCard> - {!isAndroid && ( - <Text - style={[a.text_md, t.atoms.text_contrast_medium]} - accessible={false}> - · - </Text> - )} - - <TimeElapsed timestamp={opts.timestamp}> - {({timeElapsed}) => ( - <WebOnlyInlineLinkText - to={opts.postHref} - label={timestampLabel} - title={timestampLabel} - disableMismatchWarning - disableUnderline - onPress={onBeforePressPost} - style={[ - a.text_md, - t.atoms.text_contrast_medium, - a.leading_snug, - web({ - whiteSpace: 'nowrap', - }), - ]}> - {timeElapsed} - </WebOnlyInlineLinkText> - )} - </TimeElapsed> + <TimeElapsed timestamp={opts.timestamp}> + {({timeElapsed}) => ( + <WebOnlyInlineLinkText + to={opts.postHref} + label={timestampLabel} + title={timestampLabel} + disableMismatchWarning + disableUnderline + onPress={onBeforePressPost} + style={[ + a.pl_xs, + a.text_md, + a.leading_tight, + isAndroid && a.flex_grow, + a.text_right, + t.atoms.text_contrast_medium, + web({ + whiteSpace: 'nowrap', + }), + ]}> + {!isAndroid && ( + <Text + style={[ + a.text_md, + a.leading_tight, + t.atoms.text_contrast_medium, + ]} + accessible={false}> + ·{' '} + </Text> + )} + {timeElapsed} + </WebOnlyInlineLinkText> + )} + </TimeElapsed> + </View> </View> ) } 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 ( <TouchableOpacity @@ -71,7 +74,7 @@ let DrawerProfileCard = ({ accessibilityLabel={_(msg`Profile`)} accessibilityHint={_(msg`Navigates to your profile`)} onPress={onPressProfile} - style={[a.gap_sm]}> + style={[a.gap_sm, a.pr_lg]}> <UserAvatar size={52} avatar={profile?.avatar} @@ -80,12 +83,25 @@ let DrawerProfileCard = ({ type={profile?.associated?.labeler ? 'labeler' : 'user'} /> <View style={[a.gap_2xs]}> - <Text - emoji - style={[a.font_heavy, a.text_xl, a.mt_2xs, a.leading_tight]} - numberOfLines={1}> - {profile?.displayName || account.handle} - </Text> + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> + <Text + emoji + style={[a.font_heavy, a.text_xl, a.mt_2xs, a.leading_tight]} + numberOfLines={1}> + {profile?.displayName || account.handle} + </Text> + {verification.showBadge && ( + <View + style={{ + top: 0, + }}> + <VerificationCheck + width={16} + verifier={verification.role === 'verifier'} + /> + </View> + )} + </View> <Text emoji style={[t.atoms.text_contrast_medium, a.text_md, a.leading_tight]} |